Skip to content

SW-1188: interactive scatter implementation#37

Open
vsmitskevich wants to merge 6 commits intomainfrom
SW-1188
Open

SW-1188: interactive scatter implementation#37
vsmitskevich wants to merge 6 commits intomainfrom
SW-1188

Conversation

@vsmitskevich
Copy link
Contributor

@vsmitskevich vsmitskevich commented Feb 25, 2026

JIRA ticket

https://tetrascience.atlassian.net/browse/SW-1188

Description

Adds a new InteractiveScatter organism — Plotlyjs based scatter plot component with interactive selection, data-driven styling and downsampling

<InteractiveScatter
  data={points}
  colorMapping={{
    type: "categorical",
    field: "category",
    categoryColors:{
      A: "#4575b4",
      B: "#d73027"
    }
  }}
  shapeMapping={{
    type: "categorical",
    field: "status",
    categoryShapes: {
      Active: "circle",
      Inactive: "square"
    }
  }}
  sizeMapping={{
    type: "continuous",
    field: "intensity",
    sizeRange: [4, 20]
  }}
/>

Interactive selection:

<InteractiveScatter
  data={points}
  selectedIds={selectedIds}
  onSelectionChange={(ids, mode) => setSelectedIds(ids)}
  enableClickSelection
  enableBoxSelection
  enableLassoSelection
/>

LTTB downsampling for datasets:

<InteractiveScatter data={largeDataset} downsampling={{ enabled: true, maxPoints: 2000, strategy: "lttb" }} />

Screenshots

image image image

@vsmitskevich vsmitskevich requested a review from a team as a code owner February 25, 2026 14:17
@vercel
Copy link

vercel bot commented Feb 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ts-lib-ui-kit-storybook Ready Ready Preview, Comment Feb 26, 2026 6:17pm

Request Review

@@ -0,0 +1,196 @@
import { describe, it, expect } from "vitest";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: the name of this test file is a bit odd, might be better to call it utils.test.ts

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed

* formed by adjacent bucket centroids and each candidate point.
* Exported for unit testing
*/
export function lttbDownsample(data: ScatterPoint[], threshold: number): ScatterPoint[] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise: Nice work on this!

Copy link
Contributor

@darwinboersma darwinboersma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good to me, under the assumption that any bugs reported by Kevin were addressed. Nice work!

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Plotly-based InteractiveScatter organism to the component library, providing interactive point selection, data-driven marker styling, and optional LTTB downsampling for large datasets.

Changes:

  • Introduces InteractiveScatter component with click/box/lasso selection and controlled/uncontrolled selection state.
  • Adds styling-mapping + downsampling utilities (including an LTTB implementation) and public types/constants.
  • Adds Storybook stories and unit tests for the LTTB downsampling behavior.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
src/index.ts Exposes the new organism and its exported types from the package entrypoint.
src/components/organisms/InteractiveScatter/InteractiveScatter.tsx Implements the Plotly scatter component, selection behavior, tooltips, and downsampling integration.
src/components/organisms/InteractiveScatter/utils.ts Provides mapping helpers, axis range calculations, selection helpers, and LTTB downsampling implementation.
src/components/organisms/InteractiveScatter/types.ts Defines the public API types for points, mappings, tooltips, selection, and downsampling config.
src/components/organisms/InteractiveScatter/constants.ts Centralizes default styling/constants for the component.
src/components/organisms/InteractiveScatter/index.ts Barrel exports for the organism folder.
src/components/organisms/InteractiveScatter/InteractiveScatter.stories.tsx Adds Storybook coverage for core use-cases (styling, selection, downsampling, etc.).
src/components/organisms/InteractiveScatter/tests/lttbDownsample.test.ts Adds Vitest unit tests for LTTB edge cases and correctness expectations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

return processedData.map((point) => {
if (tooltip.content) {
const content = tooltip.content(point);
return typeof content === "string" ? content : "";
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TooltipConfig.content is typed as returning string | HTMLElement, but when it returns an HTMLElement the current implementation drops it and uses an empty string, so consumers can’t actually provide rich tooltip content. Either (a) change the type to () => string and document that Plotly hover text is HTML string-based, or (b) serialize the element (e.g., outerHTML) before passing it to Plotly.

Suggested change
return typeof content === "string" ? content : "";
if (typeof content === "string") {
return content;
}
if (content instanceof HTMLElement) {
return content.outerHTML;
}
return "";

Copilot uses AI. Check for mistakes.
Comment on lines +203 to +218
showlegend: false,
unselected: {
marker: {
opacity: 0.3,
},
},
selected: {
marker: {
opacity: 1,
line: {
color: "#d73027",
width: 2,
},
},
},
};
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showLegend is exposed as a prop but the trace is always showlegend: false, and for categorical styling a Plotly legend won’t appear with a single trace anyway. Either implement an actual legend (e.g., separate traces per category) or rename/adjust the prop/docs to reflect what it really controls (currently it only affects the continuous colorbar via marker.showscale).

Copilot uses AI. Check for mistakes.
export function getUniqueCategories(data: ScatterPoint[], field: string): string[] {
const uniqueCategories = data
.map((point) => point.metadata?.[field])
.filter((value): value is string => value !== undefined && value !== null);
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getUniqueCategories claims to return string[], but the filter predicate doesn’t ensure values are strings (it only excludes null/undefined). This makes the return type unsound and can lead to unexpected categories like [object Object]. Consider filtering with typeof value === "string" (or explicitly coercing to string and reflecting that in the type).

Suggested change
.filter((value): value is string => value !== undefined && value !== null);
.filter((value): value is string => typeof value === "string");

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +100
if (colorMapping.type === "continuous" && colorMapping.field) {
const range =
colorMapping.min !== undefined && colorMapping.max !== undefined
? { min: colorMapping.min, max: colorMapping.max }
: calculateRange(data, colorMapping.field);

return data.map((point) => {
const value = point.metadata?.[colorMapping.field!];
if (typeof value === "number" && Number.isFinite(value)) {
// Normalize to 0-1 range
const normalized = (value - range.min) / (range.max - range.min);
return String(normalized);
}
return "0";
});
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the continuous branch of mapColors, the function returns String(normalized) values ("0".."1") rather than colors (or numeric values). This isn’t directly usable as marker colors unless the caller also wires up a colorscale, and it currently ignores colorMapping.colorScale. Consider either returning numeric values for Plotly (number[]), or returning actual interpolated colors, or removing this branch if continuous mapping is handled elsewhere.

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +21
const DEFAULT_MAX_POINTS_THRESHOLD = 5000;

/**
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEFAULT_MAX_POINTS_THRESHOLD duplicates DEFAULT_MAX_POINTS from constants.ts (both are 5000). This creates a risk of the defaults diverging later. Consider importing and using the single source of truth from constants.ts instead of maintaining two separate defaults.

Copilot uses AI. Check for mistakes.
Comment on lines +111 to +120
const tooltipText = useMemo(() => {
if (!tooltip.enabled) return [];

return processedData.map((point) => {
if (tooltip.content) {
const content = tooltip.content(point);
return typeof content === "string" ? content : "";
}
return generateTooltipContent(point, tooltip.fields);
});
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tooltip.enabled is treated as a strict boolean, but TooltipConfig.enabled is optional. If a consumer passes tooltip={{ fields: [...] }} (no enabled), tooltips will be disabled because enabled is undefined. Consider normalizing via something like const tooltipEnabled = tooltip.enabled !== false and using that everywhere instead of directly checking tooltip.enabled.

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +36
* - Keyboard modifiers for selection modes (Shift/Ctrl)
* - Customizable tooltips with rich content support
* - Axis customization (ranges, log/linear scales)
* - Performance optimizations for large datasets
* - Selection propagation via callbacks
*
* **Selection Modes:**
* - Default click/drag: Replace selection
* - Shift + click/drag: Add to selection
* - Ctrl/Cmd + click/drag: Remove from selection
* - Shift + Ctrl + click/drag: Toggle selection
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component-level docstring describes keyboard modifiers for “click/drag” selection, but box/lasso selection is currently hard-coded to mode: "replace" because Plotly doesn’t provide the original keyboard event. Update the docstring (and/or props docs) to clarify that modifiers apply to click selection only, or add an alternate way to detect modifiers for drag selection.

Suggested change
* - Keyboard modifiers for selection modes (Shift/Ctrl)
* - Customizable tooltips with rich content support
* - Axis customization (ranges, log/linear scales)
* - Performance optimizations for large datasets
* - Selection propagation via callbacks
*
* **Selection Modes:**
* - Default click/drag: Replace selection
* - Shift + click/drag: Add to selection
* - Ctrl/Cmd + click/drag: Remove from selection
* - Shift + Ctrl + click/drag: Toggle selection
* - Keyboard modifiers for click selection modes (Shift/Ctrl)
* - Customizable tooltips with rich content support
* - Axis customization (ranges, log/linear scales)
* - Performance optimizations for large datasets
* - Selection propagation via callbacks
*
* **Selection Modes:**
* - Default click: Replace selection
* - Shift + click: Add to selection
* - Ctrl/Cmd + click: Remove from selection
* - Shift + Ctrl + click: Toggle selection
* - Box/lasso drag: Replace selection (keyboard modifiers are not available for drag)

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +7
import {
DEFAULT_CATEGORY_COLORS,
// DEFAULT_COLOR_SCALE,
DEFAULT_MARKER_SIZE,
DEFAULT_SIZE_RANGE,
PLOT_CONSTANTS,
} from "./constants";
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s a commented-out import (// DEFAULT_COLOR_SCALE) left in the import list. If it’s not needed, it should be removed to avoid dead code and keep the module clean.

Copilot uses AI. Check for mistakes.
Comment on lines +349 to +360
export function calculateAxisRange(data: ScatterPoint[], axis: "x" | "y", padding = 0.1): [number, number] {
const values = data.map((p) => p[axis]);
const min = Math.min(...values);
const max = Math.max(...values);

if (min === max) {
return [min - 1, max + 1];
}

const range = max - min;
return [min - range * padding, max + range * padding];
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculateAxisRange uses Math.min(...values) / Math.max(...values) which (a) throws for empty arrays and (b) can be slow / hit argument limits for very large datasets. It also doesn’t ignore non-finite values, so a single NaN makes the range [NaN, NaN]. Consider computing min/max in a loop (filtering to finite numbers) and returning a sensible default when no finite values exist.

Copilot uses AI. Check for mistakes.
height?: number;

/**
* Show legend
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showLegend is documented as “Show legend”, but Plotly won’t render a legend for categorical per-point styling with a single trace, and the component always sets showlegend: false on the trace. Consider clarifying the prop name/docs (e.g., showColorScale) or changing the implementation to actually render a legend (separate traces per category).

Suggested change
* Show legend
* Show legend-like UI for styling mappings.
*
* For continuous color mappings, this typically controls whether a color scale
* or similar indicator is shown. Note that Plotly does not render a standard
* categorical legend for per-point styling with a single trace, and the
* underlying trace is configured with `showlegend: false` in that case, so
* enabling this flag will not produce a categorical legend for individual
* point categories.
*

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

@blee-tetrascience blee-tetrascience left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind addressing some of these ai comments that look legit?

@@ -0,0 +1,196 @@
import { describe, it, expect } from "vitest";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants