SW-1188: interactive scatter implementation#37
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
cb4184c to
de30289
Compare
| @@ -0,0 +1,196 @@ | |||
| import { describe, it, expect } from "vitest"; | |||
There was a problem hiding this comment.
NIT: the name of this test file is a bit odd, might be better to call it utils.test.ts
| * formed by adjacent bucket centroids and each candidate point. | ||
| * Exported for unit testing | ||
| */ | ||
| export function lttbDownsample(data: ScatterPoint[], threshold: number): ScatterPoint[] { |
There was a problem hiding this comment.
Praise: Nice work on this!
darwinboersma
left a comment
There was a problem hiding this comment.
Overall looks good to me, under the assumption that any bugs reported by Kevin were addressed. Nice work!
There was a problem hiding this comment.
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
InteractiveScattercomponent 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 : ""; |
There was a problem hiding this comment.
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.
| return typeof content === "string" ? content : ""; | |
| if (typeof content === "string") { | |
| return content; | |
| } | |
| if (content instanceof HTMLElement) { | |
| return content.outerHTML; | |
| } | |
| return ""; |
| showlegend: false, | ||
| unselected: { | ||
| marker: { | ||
| opacity: 0.3, | ||
| }, | ||
| }, | ||
| selected: { | ||
| marker: { | ||
| opacity: 1, | ||
| line: { | ||
| color: "#d73027", | ||
| width: 2, | ||
| }, | ||
| }, | ||
| }, | ||
| }; |
There was a problem hiding this comment.
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).
| 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); |
There was a problem hiding this comment.
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).
| .filter((value): value is string => value !== undefined && value !== null); | |
| .filter((value): value is string => typeof value === "string"); |
| 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"; | ||
| }); |
There was a problem hiding this comment.
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.
| const DEFAULT_MAX_POINTS_THRESHOLD = 5000; | ||
|
|
||
| /** |
There was a problem hiding this comment.
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.
| 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); | ||
| }); |
There was a problem hiding this comment.
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.
| * - 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 |
There was a problem hiding this comment.
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.
| * - 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) |
| import { | ||
| DEFAULT_CATEGORY_COLORS, | ||
| // DEFAULT_COLOR_SCALE, | ||
| DEFAULT_MARKER_SIZE, | ||
| DEFAULT_SIZE_RANGE, | ||
| PLOT_CONSTANTS, | ||
| } from "./constants"; |
There was a problem hiding this comment.
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.
| 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]; | ||
| } |
There was a problem hiding this comment.
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.
| height?: number; | ||
|
|
||
| /** | ||
| * Show legend |
There was a problem hiding this comment.
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).
| * 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. | |
| * |
blee-tetrascience
left a comment
There was a problem hiding this comment.
Mind addressing some of these ai comments that look legit?
| @@ -0,0 +1,196 @@ | |||
| import { describe, it, expect } from "vitest"; | |||
JIRA ticket
https://tetrascience.atlassian.net/browse/SW-1188
Description
Adds a new
InteractiveScatterorganism — Plotlyjs based scatter plot component with interactive selection, data-driven styling and downsamplingInteractive selection:
LTTB downsampling for datasets:
Screenshots