diff --git a/AGENTS.md b/AGENTS.md index 90c6fd03c..77e8b9b80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,5 @@ - only run `pnpm exec tsgo` when TypeScript files or TS-facing configs changed +- when TypeScript files change, run `pnpm exec biome lint` on touched paths only (read-only; no `--write`); do not run `jsformat`, `jslint`, or `biome check --write` on `./src` — formatting the tree would touch many unrelated lines; do not remove `biome-ignore` comments without reading the rationale and confirming the underlying constraint is still addressed - for Python checks in worktrees, prefer the local `venv`: - tests: `./venv/bin/python -m pytest python/mdvtools/tests -m "not performance"` - pyright: from `python/`, run `../venv/bin/pyright` diff --git a/package.json b/package.json index f579d5b14..1c40e5d92 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "docker_stack_prune": "docker stack rm mdv_stack && docker container prune -f && docker image prune -a -f && docker volume prune -f", "fix_pnpm_win": "pnpm config set script-shell \"cmd\"", "jslint": "biome lint --write ./src", + "jscheck": "biome lint ./src", "jsformat": "biome format --write ./src", "test": "vitest", "playwright-setup": "pnpm install && pnpm exec playwright install --with-deps", diff --git a/src/charts/css/charts.css b/src/charts/css/charts.css index 9a4c80063..abb32e201 100644 --- a/src/charts/css/charts.css +++ b/src/charts/css/charts.css @@ -972,4 +972,4 @@ select, input, textarea { width: 1em; height: 1em; } -} +} \ No newline at end of file diff --git a/src/charts/schemas/ChartConfigSchema.ts b/src/charts/schemas/ChartConfigSchema.ts index f0dbc8f42..497be63a0 100644 --- a/src/charts/schemas/ChartConfigSchema.ts +++ b/src/charts/schemas/ChartConfigSchema.ts @@ -110,6 +110,7 @@ const ContourVisualConfigSchema = z.object({ const DensityFieldsConfigSchema = z.object({ densityFields: FieldSpecsSchema.optional().describe("Numeric fields rendered as density overlays"), + density_mode: z.enum(["overlay", "grid"]).optional().describe("Whether density fields are overlaid or shown as a small-multiple grid"), }).describe("Density field selection shared by contour-based charts"); const LegacyContourCategorySelectionConfigSchema = z.object({ diff --git a/src/lib/deckMonkeypatch.ts b/src/lib/deckMonkeypatch.ts index 57505f862..6b2b4ff44 100644 --- a/src/lib/deckMonkeypatch.ts +++ b/src/lib/deckMonkeypatch.ts @@ -2,6 +2,8 @@ import { EventManager, InputDirection, Pan, Pinch, Tap } from 'mjolnir.js'; // import { EVENT_HANDLERS, RECOGNIZERS } from '@deck.gl/core/dist/lib/constants'; import { Deck } from '@deck.gl/core'; import { EditableGeoJsonLayer } from '@deck.gl-community/editable-layers'; +import type { Position } from '@turf/helpers'; +import { unprojectCanvasPoint } from '@/react/components/densityGridUtils'; // we don't need the keys; we'll break in. // we aren't really using this as an actual subclass, just declaring things as public so we can access them. @@ -47,7 +49,52 @@ class EditableLayer { ``` */ -export class MonkeyPatchEditableGeoJsonLayer extends EditableGeoJsonLayer {} +/** + * Editable layers use `context.viewport.unproject` with canvas-relative pixels. + * In density-grid mode one canvas hosts many sub-viewports, so we resolve the + * viewport under the pointer before unprojecting. + */ +export class MonkeyPatchEditableGeoJsonLayer extends EditableGeoJsonLayer { + getMapCoords(screenCoords: Position): Position { + const deck = this.context.deck; + const viewports = deck?.getViewports?.(); + if (viewports && viewports.length > 1) { + return unprojectCanvasPoint(viewports, screenCoords[0], screenCoords[1]) as Position; + } + return this.context.viewport.unproject([screenCoords[0], screenCoords[1]]) as Position; + } + + getPicks(screenCoords: [number, number]) { + const deck = this.context.deck; + if (!deck) { + return []; + } + const radius = this.props.pickingRadius; + const depth = this.props.pickingDepth; + const layerId = this.props.id; + const allPicks = deck.pickMultipleObjects({ + x: screenCoords[0], + y: screenCoords[1], + radius, + depth, + }); + const matching = allPicks.filter( + (pick) => + pick.layer?.id && + (pick.layer.id === layerId || pick.layer.id.startsWith(`${layerId}-`)), + ); + if (matching.length > 0) { + return matching; + } + return deck.pickMultipleObjects({ + x: screenCoords[0], + y: screenCoords[1], + layerIds: [layerId], + radius, + depth, + }); + } +} const EVENT_HANDLERS: { [eventName: string]: string } = { click: 'onClick', //is onClick a thing? diff --git a/src/react/components/ChartArrayLayout.tsx b/src/react/components/ChartArrayLayout.tsx new file mode 100644 index 000000000..5ef9fb63e --- /dev/null +++ b/src/react/components/ChartArrayLayout.tsx @@ -0,0 +1,103 @@ +/** + * Multi-chart layout shell (density grid and similar). Layout modes, flex/grid CSS, and + * resize measurement are an initial prototype — not a stable API; expect rework for + * user-configurable layouts and context-specific arrangements. + */ +import { + useCallback, + useImperativeHandle, + useRef, + type CSSProperties, + type ReactNode, + type Ref, +} from "react"; +import "./chartArrayLayout.css"; +import { + getChartArrayLayoutMode, + type ChartArrayLayoutMode, +} from "./chartArrayLayoutUtils"; +import { useChartArrayLayoutGeometry } from "../hooks/useChartArrayLayoutGeometry"; + +export type ChartArrayLayoutHandle = { + root: HTMLDivElement | null; + cells: readonly (HTMLDivElement | null)[]; +}; + +type ChartArrayLayoutProps = { + cellCount: number; + cellKeys?: readonly string[]; + /** Defaults from {@link getChartArrayLayoutMode}; override for future user layout prefs. */ + layout?: ChartArrayLayoutMode; + layoutRef?: Ref; + className?: string; + style?: CSSProperties; + /** Shared-canvas layer (e.g. DeckGL) sized to the grid root via CSS. */ + canvasOverlay?: ReactNode; + renderCell: (index: number) => ReactNode; +}; + +export default function ChartArrayLayout({ + cellCount, + cellKeys, + layout, + layoutRef, + className, + style, + canvasOverlay, + renderCell, +}: ChartArrayLayoutProps) { + const layoutMode = layout ?? getChartArrayLayoutMode(cellCount); + const rootRef = useRef(null); + const { gridFillsViewport, gridColumns } = useChartArrayLayoutGeometry( + rootRef, + cellCount, + layoutMode, + ); + const cellRefs = useRef<(HTMLDivElement | null)[]>([]); + + const setCellRef = useCallback((index: number, element: HTMLDivElement | null) => { + cellRefs.current[index] = element; + }, []); + + useImperativeHandle( + layoutRef, + () => ({ + get root() { + return rootRef.current; + }, + get cells() { + return cellRefs.current; + }, + }), + [], + ); + + return ( +
+ {canvasOverlay ?
{canvasOverlay}
: null} + {Array.from({ length: cellCount }, (_, index) => ( +
setCellRef(index, element)} + className="mdv-chart-array__cell" + data-chart-array-index={String(index)} + > + {renderCell(index)} +
+ ))} +
+ ); +} diff --git a/src/react/components/DeckDensityGridComponent.tsx b/src/react/components/DeckDensityGridComponent.tsx new file mode 100644 index 000000000..cab0486dc --- /dev/null +++ b/src/react/components/DeckDensityGridComponent.tsx @@ -0,0 +1,212 @@ +import DeckGL from "@deck.gl/react"; +import { OrthographicView, type OrthographicViewState } from "@deck.gl/core"; +import { action } from "mobx"; +import { useCallback, useMemo, useRef } from "react"; +import { getFieldColor } from "../fieldColorManager"; +import useGateLayers from "../hooks/useGateLayers"; +import { useChartArrayGrid } from "../hooks/useChartArrayGrid"; +import { + useDensityGridCells, + useDensityGridContours, + useDensityGridViewState, +} from "../hooks/useDensityGridCells"; +import { useChartID, useConfig, useFilteredIndices } from "../hooks"; +import { useDeckSelectionMouseRebind } from "../hooks/useDeckSelectionMouseRebind"; +import { useOuterContainer } from "../screen_state"; +import type { DualContourLegacyConfig } from "../contour_state"; +import type { ScatterPlotConfig2D } from "../scatter_state"; +import { useSpatialLayers } from "../spatial_context"; +import ChartArrayLayout from "./ChartArrayLayout"; +import { createHeatmapContourLayer } from "@/webgl/SpatialLayer"; +import { tagDeckLayerViewportScope } from "./deckLayerViewportScope"; +import { + cloneDeckLayer, + getDensityGridViewId, + getDensityGridViewStates, + getSerializableViewState, + shouldDrawLayerInDeckDensityGrid, +} from "./densityGridUtils"; + +type DensityGridConfig = ScatterPlotConfig2D & DualContourLegacyConfig; + +export default function DeckDensityGridComponent() { + const chartId = useChartID(); + const config = useConfig(); + const rows = useFilteredIndices(); + const outerContainer = useOuterContainer(); + const deckRef = useRef(null); + const { + scatterProps: { scatterplotLayer, greyScatterplotLayer, setScatterKeyboardActive }, + selectionLayer, + } = useSpatialLayers(); + useDeckSelectionMouseRebind(outerContainer, selectionLayer, deckRef, { + canvasKey: "grid", + }); + const { gateLabelLayer, gateDisplayLayer, controllerOptions } = useGateLayers(); + const { cells, densityFields, configuredFieldCount } = useDensityGridCells(); + + const grid = useChartArrayGrid({ + chartId, + cells, + getViewId: (id, cell, index) => getDensityGridViewId(id, cell.key, index), + }); + + const contourLayers = useDensityGridContours(densityFields, grid.visibleCellIndices); + const referenceCellWidth = grid.metrics.cellBounds[0]?.width ?? 0; + const referenceCellHeight = grid.metrics.cellBounds[0]?.height ?? 0; + const radiusPixels = useDensityGridViewState(referenceCellWidth, referenceCellHeight); + + const views = useMemo( + () => + grid.visibleCellIndices + .map((index) => { + const bounds = grid.getCellBounds(index); + const viewId = grid.viewIds[index]; + if (!bounds || bounds.width <= 0 || bounds.height <= 0 || !viewId) return null; + return new OrthographicView({ + id: viewId, + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + flipY: false, + controller: { dragPan: controllerOptions.dragPan }, + }); + }) + .filter((view): view is OrthographicView => view !== null), + [grid.visibleCellIndices, grid.viewIds, grid.metrics.cellBounds, controllerOptions.dragPan], + ); + + const viewState = useMemo( + () => getDensityGridViewStates(grid.visibleViewIds, config.viewState), + [grid.visibleViewIds, config.viewState], + ); + + const sharedGeometryLayers = useMemo( + () => [ + tagDeckLayerViewportScope(greyScatterplotLayer, "chart-shared"), + tagDeckLayerViewportScope( + cloneDeckLayer(scatterplotLayer, { contourLayers: [] }), + "chart-shared", + ), + ], + [greyScatterplotLayer, scatterplotLayer], + ); + + const perViewportLayers = useMemo( + () => + grid.visibleCellIndices.flatMap((index) => { + const contourLayer = contourLayers[index]; + const viewId = grid.viewIds[index]; + if (!contourLayer || !viewId) return []; + return [ + tagDeckLayerViewportScope( + createHeatmapContourLayer({ + ...contourLayer, + radiusPixels, + debounce: 250, + weightsTextureSize: 256, + id: `${viewId}-density`, + }), + "per-viewport", + { viewId }, + ), + ]; + }), + [grid.visibleCellIndices, grid.viewIds, contourLayers, radiusPixels], + ); + + const chartSharedLayers = useMemo( + () => + [gateDisplayLayer, gateLabelLayer, selectionLayer].filter( + (layer): layer is NonNullable => layer !== null, + ), + [gateDisplayLayer, gateLabelLayer, selectionLayer], + ); + + const allLayers = useMemo( + () => [...sharedGeometryLayers, ...perViewportLayers, ...chartSharedLayers], + [sharedGeometryLayers, perViewportLayers, chartSharedLayers], + ); + + const renderCell = useCallback( + (index: number) => { + const field = densityFields[index]; + if (!field) return null; + const color = getFieldColor(field.field); + return ( +
+ {field.name || field.field} +
+ ); + }, + [densityFields], + ); + + const hasCanvas = + grid.hasCanvas && Number.isFinite(radiusPixels) && radiusPixels > 0; + + const deckOverlay = useMemo( + () => + hasCanvas ? ( + + shouldDrawLayerInDeckDensityGrid( + { id: layer.id, props: layer.props as Record }, + viewport.id, + ) + } + layers={allLayers} + views={views} + viewState={viewState} + useDevicePixels={true} + onViewStateChange={({ viewState: nextViewState }: { viewState: OrthographicViewState }) => { + action(() => { + config.viewState = getSerializableViewState(nextViewState); + })(); + }} + getCursor={({ isDragging }) => (isDragging ? "grabbing" : "crosshair")} + /> + ) : null, + [hasCanvas, allLayers, views, viewState, config], + ); + + if (configuredFieldCount === 0) { + return
Choose density fields to build the grid.
; + } + if (densityFields.length === 0) { + return
Loading density fields...
; + } + if (rows.length === 0) { + return
No rows remain after the current filters.
; + } + + return ( +
+
setScatterKeyboardActive(true)} + onMouseEnter={() => setScatterKeyboardActive(true)} + onMouseLeave={() => setScatterKeyboardActive(false)} + > + +
+
+ ); +} diff --git a/src/react/components/DeckScatterComponent.tsx b/src/react/components/DeckScatterComponent.tsx index 59af7b41a..afe77e41a 100644 --- a/src/react/components/DeckScatterComponent.tsx +++ b/src/react/components/DeckScatterComponent.tsx @@ -14,13 +14,15 @@ import SelectionOverlay from "./SelectionOverlay"; import { useScatterRadius } from "../scatter_state"; import AxisComponent from "./AxisComponent"; import { useOuterContainer } from "../screen_state"; -import { rebindMouseEvents } from "@/lib/deckMonkeypatch"; +import { useDeckSelectionMouseRebind } from "../hooks/useDeckSelectionMouseRebind"; import useGateLayers from "../hooks/useGateLayers"; import FieldContourLegend from "./FieldContourLegend"; import { useFieldContourLegend, type DualContourLegacyConfig } from "../contour_state"; import { getPickingInfoWithAlternates } from "@/lib/deckPicking"; import { getCombinedScatterTooltip } from "@/lib/scatterTooltip"; import { useOuterContainerDeckTooltip } from "../hooks/useOuterContainerDeckTooltip"; +import DeckDensityGridComponent from "./DeckDensityGridComponent"; +import { supportsDensityGridMode } from "./densityGridUtils"; //todo this should be in a common place etc. const colMid = ({ minMax }: DataColumn) => minMax[0] + (minMax[1] - minMax[0]) / 2; @@ -30,17 +32,22 @@ const colMid = ({ minMax }: DataColumn) => minMax[0] + (minMax[1 * there should be hooks getting the range of a filtered column * so that multiple charts can re-use the computation (but if not used, it's not computed) */ -function useZoomOnFilter(data: Uint32Array) { +function useZoomOnFilter(data: Uint32Array, enabled: boolean) { const [width, height] = useChartSize(); const [cx, cy, cz] = useParamColumns(); // const data = useFilteredIndices(); //<< does calling this multiple times mean wasting space? const config = useConfig(); const chart = useChart() as any; const { pendingRecenter } = chart; + const paramKey = `${cx.field}\u0000${cy.field}`; + const lastParamKeyRef = useRef(""); useEffect(() => { + if (!enabled) return; if (chart.ignoreStateUpdate) return; if (data.length === 0) return; // [0, 0, 1, 1]; - if (!pendingRecenter && !config.zoom_on_filter) return; + const paramChanged = lastParamKeyRef.current !== paramKey; + lastParamKeyRef.current = paramKey; + if (!pendingRecenter && !config.zoom_on_filter && !paramChanged) return; //there is also cx.minMax, cy.minMax - but not for filtered indices let minX = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; @@ -106,7 +113,7 @@ function useZoomOnFilter(data: Uint32Array) { chart.ignoreStateUpdate = false; })(); - }, [data, cx, cy, cz, width, height, config, pendingRecenter, chart]); + }, [data, cx, cy, cz, width, height, config, pendingRecenter, chart, paramKey, enabled]); } /** @@ -140,6 +147,8 @@ const DeckScatter = observer(function DeckScatterComponent({ const contourConfig = useConfig(); const { viewState, dimension } = config; const is2d = dimension === "2d"; + const showDensityGrid = + supportsDensityGridMode(config.type) && is2d && contourConfig.density_mode === "grid"; //todo more clarity on radius units - but large radius was causing big problems after deck upgrade const radiusScale = useScatterRadius(); //todo colorBy should be done differently (also bearing in mind multiple layers) @@ -170,7 +179,7 @@ const DeckScatter = observer(function DeckScatterComponent({ //if we don't fully understand reasons for `- 3.5` here. //prevents overlapping with x-axis. const chartHeight = height - margin.top - margin.bottom - 3.5; - useZoomOnFilter(data); + useZoomOnFilter(data, !showDensityGrid); const { scatterProps, selectionLayer } = useSpatialLayers(); // this is now somewhat able to render for "2d", pending further tweaks @@ -184,7 +193,7 @@ const DeckScatter = observer(function DeckScatterComponent({ } = useGateLayers(); const legendFields = useFieldContourLegend(contourConfig.densityFields); - const showLegend = contourConfig.field_legend.display; + const showLegend = contourConfig.field_legend.display && !showDensityGrid; const legendPosition = { x: 10, y: 10 }; const axisLinesLayer = useMemo(() => { @@ -271,28 +280,20 @@ const DeckScatter = observer(function DeckScatterComponent({ // Unproject from screen coordinates to world coordinates return viewport.unproject([coords[0], coords[1]]); }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: selectionLayer might change without us caring - useEffect(() => { - outerContainer; // make sure the hook runs when this changes - if (deckRef.current) { - try { - // const deck: Deck = deckRef.current.deck; - const deck = deckRef.current.deck; // as Deck; - return rebindMouseEvents(deck, selectionLayer); - } catch (e) { - console.error( - "attempt to reset deck eventManager element failed - could be related to brittle deck monkeypatch", - e, - ); - } - } - }, [outerContainer]); + useDeckSelectionMouseRebind(outerContainer, selectionLayer, deckRef, { + enabled: !showDensityGrid, + canvasKey: "overlay", + }); // we want default controller options, but we want a new one when the outerContainer changes // this doesn't seem to help re-register mouse events. // const controller = useMemo(() => ({inertia: 10+Math.random()}), [outerContainer]) + if (showDensityGrid) { + return ; + } + return ( <> diff --git a/src/react/components/DeckScatterReactWrapper.tsx b/src/react/components/DeckScatterReactWrapper.tsx index 436287575..55c73b171 100644 --- a/src/react/components/DeckScatterReactWrapper.tsx +++ b/src/react/components/DeckScatterReactWrapper.tsx @@ -131,6 +131,7 @@ class DeckScatterReact extends BaseReactChart { ...getSharedScatterSettings(c, { chart: this, includeDensitySettings: deckContourScatterTypes.has(c.type), + includeDensityModeToggle: deckContourScatterTypes.has(c.type), includePointShape: true, }), g({ diff --git a/src/react/components/SelectionOverlay.tsx b/src/react/components/SelectionOverlay.tsx index d33abe05b..26e59f63a 100644 --- a/src/react/components/SelectionOverlay.tsx +++ b/src/react/components/SelectionOverlay.tsx @@ -23,12 +23,15 @@ import { DrawRectangleByDraggingMode } from "@/editable-layers/deck-community-is import useGateActions from "../hooks/useGateActions"; import { useChartID, useConfig, useParamColumns, useTheme } from "../hooks"; import IconWithTooltip from "./IconWithTooltip"; -import { getEmptyFeatureCollection } from "../deck_state"; -import { TuneOutlined } from "@mui/icons-material"; +import { GridView, TuneOutlined } from "@mui/icons-material"; +import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'; import { LassoIcon, SplineIcon } from "lucide-react"; import ManageGateDialogWrapper from "./ManageGateDialogWrapper"; import type { DeckScatterConfigWithRegion } from "./DeckScatterReactWrapper"; import { useChart } from "../context"; +import { action } from "mobx"; +import type { DualContourLegacyConfig } from "../contour_state"; +import { supportsDensityGridMode } from "./densityGridUtils"; class EditMode extends CompositeMode { constructor() { @@ -147,6 +150,10 @@ export default observer(function SelectionOverlay() { const chart = useChart(); const chartId = useChartID(); const config = useConfig(); + const contourConfig = useConfig(); + const is2d = config.dimension === "2d"; + const supportsDensityGrid = supportsDensityGridMode(config.type); + const isDensityGrid = supportsDensityGrid && is2d && contourConfig.density_mode === "grid"; const { setSelectionMode, selectionFeatureCollection, @@ -183,27 +190,47 @@ export default observer(function SelectionOverlay() { }; }, [chartId]); - const toolsToShow = useMemo( - () => - editingGateId - ? ToolArray.filter((t) => EDIT_MODE_TOOL_NAMES.includes(t.name)) - : ToolArray, - [editingGateId], + const toolsToShow = useMemo(() => { + if (editingGateId) { + return ToolArray.filter((t) => EDIT_MODE_TOOL_NAMES.includes(t.name)); + } + return ToolArray; + }, [editingGateId]); + + const setSelectedTool = useCallback( + (tool: Tool) => { + const mode = Object.values(Tools).find((t) => t.name === tool)?.mode; + if (!mode) { + console.error("no mode found for tool", tool); + return; + } + setSelectionMode(new mode()); + setSelectedToolX(tool); + }, + [setSelectionMode, setSelectedToolX], ); - const setSelectedTool = useCallback((tool: Tool) => { - // pending refactor - const mode = Object.values(Tools).find((t) => t.name === tool)?.mode; - if (!mode) { - console.error("no mode found for tool", tool); - return; + const wasDensityGridRef = useRef(isDensityGrid); + useEffect(() => { + const enteredGrid = isDensityGrid && !wasDensityGridRef.current; + const leftGrid = wasDensityGridRef.current && !isDensityGrid; + if (enteredGrid || leftGrid) { + // Deck remounts between overlay and grid; recreate the active edit mode and handlers. + setSelectedTool(selectedTool); } + wasDensityGridRef.current = isDensityGrid; + }, [isDensityGrid, selectedTool, setSelectedTool]); + + const toggleDensityGrid = useCallback(() => { + action(() => { + const nextIsGrid = contourConfig.density_mode !== "grid"; + contourConfig.density_mode = nextIsGrid ? "grid" : "overlay"; + if (nextIsGrid) { + contourConfig.contour_fill = true; + } + })(); + }, [contourConfig]); - //same composite mode order doesn't work for all tools, so making `mode()` be more explicit for each - //setSelectionMode(new CompositeMode([new mode(), new TranslateModeEx()])); - setSelectionMode(new mode()); - setSelectedToolX(tool); - }, [setSelectionMode, editingGateId, setSelectionFeatureCollection, setSelectedToolX]); // add a row of buttons to the top of the chart // rectangle, circle, polygon, lasso, magic wand, etc. // (thanks copilot, that may be over-ambitious) @@ -266,7 +293,6 @@ export default observer(function SelectionOverlay() { {toolButtons} + {supportsDensityGrid && is2d && ( + + {isDensityGrid ? : } + + )} store.viewState); + const outerContainer = useOuterContainer(); + const deckContainerRef = useRef(null); + const rows = useFilteredIndices(); + + const { + scatterProps: { scatterplotLayer, greyScatterplotLayer, getTooltip, setScatterKeyboardActive }, + selectionLayer, + } = useSpatialLayers(); + const { gateLabelLayer, gateDisplayLayer, controllerOptions } = useGateLayers(); + const { showJson } = useConfig(); + const jsonLayer = useJsonLayer(showJson); + const { cells, densityFields, configuredFieldCount } = useDensityGridCells(); + + const grid = useChartArrayGrid({ + chartId, + cells, + getViewId: (id, cell, index) => getVivGridDetailViewId(id, cell.key, index), + }); + + const contourLayers = useDensityGridContours(densityFields, grid.visibleCellIndices); + const referenceCellWidth = grid.metrics.cellBounds[0]?.width ?? 0; + const referenceCellHeight = grid.metrics.cellBounds[0]?.height ?? 0; + const radiusPixels = useVivDensityGridViewState(referenceCellWidth, referenceCellHeight); + + const { colors, contrastLimits, channelsVisible, selections, brightness, contrast } = useChannelsStore( + ({ colors, contrastLimits, channelsVisible, selections, brightness, contrast }) => ({ + colors, + contrastLimits, + channelsVisible, + selections, + brightness, + contrast, + }), + shallow, + ); + + const extensions = useMemo(() => [new ColorPaletteExtension(), new VivContrastExtension()], []); + const layerConfig = useMemo( + () => ({ + loader: ome, + selections, + contrastLimits, + extensions, + colors, + channelsVisible, + brightness, + contrast, + }), + [ome, selections, contrastLimits, extensions, colors, channelsVisible, brightness, contrast], + ); + + const detailViews = useMemo( + () => + grid.visibleCellIndices + .map((index) => { + const bounds = grid.getCellBounds(index); + const detailId = grid.viewIds[index]; + if (!bounds || bounds.width <= 0 || bounds.height <= 0 || !detailId) return null; + return new DetailView({ + id: detailId, + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + // @ts-expect-error viv runtime supports this, types do not + snapScaleBar: false, + }); + }) + .filter((view): view is DetailView => view !== null), + [grid.visibleCellIndices, grid.viewIds, grid.metrics.cellBounds], + ); + + const viewStates = useMemo(() => { + if (!viewState) return []; + return grid.visibleCellIndices.map((index) => { + const detailId = grid.viewIds[index]; + if (!detailId) return null; + return { ...viewState, id: detailId }; + }).filter((vs): vs is NonNullable => vs !== null); + }, [grid.visibleCellIndices, grid.viewIds, viewState]); + + const sharedGeometryLayers = useMemo( + () => [ + tagDeckLayerViewportScope(greyScatterplotLayer, "chart-shared"), + tagDeckLayerViewportScope( + cloneDeckLayer(scatterplotLayer, { contourLayers: [] }), + "chart-shared", + ), + ], + [greyScatterplotLayer, scatterplotLayer], + ); + + const perViewportLayers = useMemo( + () => + grid.visibleCellIndices.flatMap((index) => { + const contourLayer = contourLayers[index]; + const detailId = grid.viewIds[index]; + if (!contourLayer || !detailId) return []; + return [ + tagDeckLayerViewportScope( + createHeatmapContourLayer({ + ...contourLayer, + radiusPixels, + debounce: 250, + weightsTextureSize: 256, + id: `density_${getVivId(detailId)}`, + }), + "per-viewport", + { viewId: detailId }, + ), + ]; + }), + [grid.visibleCellIndices, grid.viewIds, contourLayers, radiusPixels], + ); + + const chartSharedLayers = useMemo( + () => + [jsonLayer, gateDisplayLayer, gateLabelLayer, selectionLayer].filter( + (layer): layer is NonNullable => layer !== null, + ), + [jsonLayer, gateDisplayLayer, gateLabelLayer, selectionLayer], + ); + + const allDeckLayers = useMemo( + () => [...sharedGeometryLayers, ...perViewportLayers, ...chartSharedLayers], + [sharedGeometryLayers, perViewportLayers, chartSharedLayers], + ); + + const getTooltipContent = useCallback( + (info: PickingInfo) => + getCombinedScatterTooltip(info, { + gateDisplayLayerId: gateDisplayLayer?.id, + gateLabelLayerId: gateLabelLayer?.id, + getPointTooltip: getTooltip, + }), + [gateDisplayLayer?.id, gateLabelLayer?.id, getTooltip], + ); + + const { + clearTooltip, + getTooltip: getPortalTooltip, + suppressTooltipUntilPointerUp, + tooltipPortal, + } = useOuterContainerDeckTooltip(getTooltipContent, deckContainerRef); + + const deckProps: Partial = useMemo( + () => ({ + getTooltip: getPortalTooltip, + layers: allDeckLayers, + controller: { + doubleClickZoom: false, + dragPan: controllerOptions.dragPan, + }, + }), + [allDeckLayers, getPortalTooltip, controllerOptions.dragPan], + ); + + const renderCell = useCallback( + (index: number) => { + const field = densityFields[index]; + if (!field) return null; + const color = getFieldColor(field.field); + return ( +
+ {field.name || field.field} +
+ ); + }, + [densityFields], + ); + + const hasCanvas = + grid.hasCanvas && + !!ome && + !!viewState && + Number.isFinite(radiusPixels) && + radiusPixels > 0 && + detailViews.length > 0; + + const vivOverlay = useMemo( + () => + hasCanvas ? ( + layerConfig)} + viewStates={viewStates} + useDevicePixels={true} + onViewStateChange={(e: { + viewId: string; + viewState: OrthographicViewState | OrbitViewState; + }) => { + viewerStore.setState({ + viewState: { ...e.viewState, id: e.viewId }, + }); + }} + deckProps={deckProps} + /> + ) : null, + [hasCanvas, outerContainer, selectionLayer, detailViews, layerConfig, viewStates, viewerStore, deckProps], + ); + + if (!viewState) { + return
Loading...
; + } + if (configuredFieldCount === 0) { + return
Choose density fields to build the grid.
; + } + if (densityFields.length === 0) { + return
Loading density fields...
; + } + if (rows.length === 0) { + return
No rows remain after the current filters.
; + } + + return ( + <> +
setScatterKeyboardActive(true)} + onMouseEnter={() => setScatterKeyboardActive(true)} + onMouseLeave={() => { + clearTooltip(); + setScatterKeyboardActive(false); + }} + > +
+ +
+
+ {tooltipPortal} + + ); +} diff --git a/src/react/components/VivMDVReact.tsx b/src/react/components/VivMDVReact.tsx index 65ec241d8..e2e11f63d 100644 --- a/src/react/components/VivMDVReact.tsx +++ b/src/react/components/VivMDVReact.tsx @@ -224,6 +224,7 @@ class VivMdvReact extends BaseReactChart { ...getSharedScatterSettings(c, { chart: this, includeDensitySettings: true, + includeDensityModeToggle: true, includePointShape: true, }), g({ diff --git a/src/react/components/VivScatterComponent.tsx b/src/react/components/VivScatterComponent.tsx index 3e27e41cc..bb076764c 100644 --- a/src/react/components/VivScatterComponent.tsx +++ b/src/react/components/VivScatterComponent.tsx @@ -7,6 +7,8 @@ import SelectionOverlay from "./SelectionOverlay"; import FieldContourLegend from "./FieldContourLegend"; import { useFieldContourLegend } from "../contour_state"; import type { DualContourLegacyConfig } from "../contour_state"; +import VivDensityGridComponent from "./VivDensityGridComponent"; +import { supportsDensityGridMode } from "./densityGridUtils"; import type { FieldName } from "@/charts/charts"; import { useLoader, type OME_TIFF, useViewerStoreApi, useChannelsStore, useViewerStore } from "./avivatorish/state"; import { useViewStateLink } from "../chartLinkHooks"; @@ -22,6 +24,7 @@ import type { DeckGLProps, OrbitViewState, OrthographicViewState, PickingInfo } import useGateLayers from "../hooks/useGateLayers"; import { getCombinedScatterTooltip } from "@/lib/scatterTooltip"; import { useOuterContainerDeckTooltip } from "../hooks/useOuterContainerDeckTooltip"; +import { tagDeckLayerViewportScope } from "./deckLayerViewportScope"; export type ViewState = ReturnType; //<< move this / check if there's an existing type @@ -37,33 +40,33 @@ export const VivScatter = () => { ); }; -const useJsonLayer = (showJson: boolean) => { +export const useJsonLayer = (showJson: boolean) => { const id = useChartID(); const { root } = useProject(); const { json } = useRegion(); // return type is 'any' and we assume 'json' will be a string - but want that to be different in future. const layer_id = `json_${getVivId(`${id}detail-react`)}`; const layer = useMemo(() => { - return json - ? new GeoJsonLayer({ - id: layer_id, - data: `${root}/${json}`, - opacity: 1, - filled: true, - getFillColor: (f) => [255, 255, 255, 150], - getLineColor: (f) => [255, 255, 255, 150], - getLineWidth: 2, - lineWidthMinPixels: 1, - getPointRadius: 10, - pickable: true, - autoHighlight: true, - //@ts-expect-error GeoJson getText: might think about using zod to type/validate this - getText: (f) => f.properties.DN, - getTextColor: [255, 255, 255, 255], - getTextSize: 12, - textBackground: true, - visible: showJson, - }) - : null; + if (!json) return null; + const geoJsonLayer = new GeoJsonLayer({ + id: layer_id, + data: `${root}/${json}`, + opacity: 1, + filled: true, + getFillColor: (f) => [255, 255, 255, 150], + getLineColor: (f) => [255, 255, 255, 150], + getLineWidth: 2, + lineWidthMinPixels: 1, + getPointRadius: 10, + pickable: true, + autoHighlight: true, + //@ts-expect-error GeoJson getText: might think about using zod to type/validate this + getText: (f) => f.properties.DN, + getTextColor: [255, 255, 255, 255], + getTextSize: 12, + textBackground: true, + visible: showJson, + }); + return tagDeckLayerViewportScope(geoJsonLayer, "chart-shared"); }, [json, showJson, layer_id, root]); return layer; }; @@ -99,12 +102,19 @@ const Main = observer( controllerOptions, } = useGateLayers(); + const roiConfig = useConfig(); + const contourConfig = useConfig(); + const showDensityGrid = + supportsDensityGridMode(roiConfig.type) && + roiConfig.dimension !== "3d" && + (contourConfig.densityFields?.length ?? 0) > 0 && + contourConfig.density_mode === "grid"; + // Get field contour legend data - const config = useConfig(); - const legendFields = useFieldContourLegend(config.densityFields); + const legendFields = useFieldContourLegend(contourConfig.densityFields); // Legend visibility - fixed position in bottom-left - const showLegend = config.field_legend.display; + const showLegend = contourConfig.field_legend.display; // Fixed bottom-left position: 10px from left, 10px from bottom const legendPosition = { x: 10, y: 10 }; @@ -246,6 +256,16 @@ const Main = observer( ], ); if (!viewState) return
Loading...
; //this was causing uniforms["sizeScale"] to be NaN, errors in console, no scalebar units... + + if (showDensityGrid) { + return ( + <> + + + + ); + } + // if (import.meta.env.DEV) trace(); return ( <> diff --git a/src/react/components/avivatorish/MDVivViewer.tsx b/src/react/components/avivatorish/MDVivViewer.tsx index 9a07f7b41..ccb550535 100644 --- a/src/react/components/avivatorish/MDVivViewer.tsx +++ b/src/react/components/avivatorish/MDVivViewer.tsx @@ -9,6 +9,7 @@ import type { OrthographicViewState, OrbitViewState, DeckGLProps, PickingInfo } import { rebindMouseEvents } from "@/lib/deckMonkeypatch"; import type { EditableGeoJsonLayer } from "@deck.gl-community/editable-layers"; import { getPickingInfoWithAlternates } from "@/lib/deckPicking"; +import { shouldDrawLayerInViewport } from "../densityGridUtils"; export function getVivId(id: string) { return `-#${id}#`; } @@ -127,8 +128,8 @@ class MDVivViewerWrapper extends React.PureComponent< */ // eslint-disable-next-line class-methods-use-this layerFilter({ layer, viewport }: any) { - //return true; // for testing whether viv id matching is an issue - return layer.id.includes(getVivId(viewport.id)); + const viewportId = viewport.id as string; + return shouldDrawLayerInViewport(layer, viewportId, getVivId(viewportId)); } /** @@ -167,28 +168,37 @@ class MDVivViewerWrapper extends React.PureComponent< * as of anything related to that, this is dubious and liable to need attention in the future. */ _cleanupMouseEvents?: () => void; + _rebindSelectionMouseEvents() { + if (!this.state.deckRef?.current) return; + try { + const deck = this.state.deckRef.current.deck; + this._cleanupMouseEvents?.(); + this._cleanupMouseEvents = rebindMouseEvents(deck, this.props.selectionLayer); + } catch (e) { + console.error( + "attempt to reset deck eventManager element failed", + e, + ); + } + } componentDidUpdate(prevProps: VivViewerWrapperProps) { const { props } = this; - const { views, outerContainer, selectionLayer } = props; + const { views, outerContainer } = props; + const outerContainerChanged = outerContainer !== this.state.outerContainer; + const viewsIdentityChanged = + prevProps.views.length !== views.length || + views.some((view, index) => view.id !== prevProps.views[index]?.id); + + if (outerContainerChanged) { + this.setState({ outerContainer }); + } if ( - outerContainer !== this.state.outerContainer && - this.state.deckRef?.current + this.state.deckRef?.current && + (outerContainerChanged || viewsIdentityChanged) ) { - try { - this.setState({ outerContainer }); - const deck = this.state.deckRef.current.deck; - this._cleanupMouseEvents?.(); - //this should be common with DeckScatterComponent - make a helper/hook... - this._cleanupMouseEvents = rebindMouseEvents(deck, selectionLayer); - } catch (e) { - console.error( - "attempt to reset deck eventManager element failed", - e, - ); - } + this._rebindSelectionMouseEvents(); } - // Only update state if the previous viewState prop does not match the current one // so that people can update viewState @@ -385,7 +395,8 @@ class MDVivViewerWrapper extends React.PureComponent< } // MDV: make sure the scalebar is on top of other layers // perhaps this should be in _renderLayers(), yolo. - const vivLayers = this._renderLayers()[0]; + const vivLayerGroups = this._renderLayers(); + const vivLayers = vivLayerGroups.flat(); const scaleBarLayer = vivLayers.find((layer: any) => layer instanceof ScaleBarLayer); const otherLayers = vivLayers.filter((layer: any) => layer !== scaleBarLayer); const layers = deckProps?.layers === undefined diff --git a/src/react/components/chartArrayGridUtils.ts b/src/react/components/chartArrayGridUtils.ts new file mode 100644 index 000000000..d6425cc08 --- /dev/null +++ b/src/react/components/chartArrayGridUtils.ts @@ -0,0 +1,38 @@ +import type { OrthographicViewState } from "@deck.gl/core"; + +export function getChartArrayViewId( + chartId: string, + cellKey: string, + index: number, + prefix = "chart-array-grid", +) { + const safeChartId = chartId.replace(/[^A-Za-z0-9_-]/g, "_"); + const safeCellKey = cellKey.replace(/[^A-Za-z0-9_-]/g, "_"); + return `${prefix}-${safeChartId}-${index}-${safeCellKey}`; +} + +export function getChartArrayViewStates( + viewIds: string[], + sharedViewState: OrthographicViewState, +): Record { + return Object.fromEntries( + viewIds.map((viewId) => [ + viewId, + { + ...sharedViewState, + }, + ]), + ); +} + +export function hasUsableOrthographicViewState(viewState: OrthographicViewState | undefined): boolean { + if (!viewState?.target || viewState.target.length < 2) return false; + const x = Number(viewState.target[0]); + const y = Number(viewState.target[1]); + if (!Number.isFinite(x) || !Number.isFinite(y)) return false; + return Number.isFinite(Number(viewState.zoom)); +} + +export function getVivGridDetailViewId(chartId: string, cellKey: string, index: number) { + return `${getChartArrayViewId(chartId, cellKey, index, "density-grid")}detail-react`; +} diff --git a/src/react/components/chartArrayLayout.css b/src/react/components/chartArrayLayout.css new file mode 100644 index 000000000..80073ebd3 --- /dev/null +++ b/src/react/components/chartArrayLayout.css @@ -0,0 +1,96 @@ +/* Prototype chart-array layout — paired with ChartArrayLayout.tsx; not final. */ + +.mdv-chart-array { + --mdv-chart-array-gap: 8px; + --mdv-chart-array-padding: 12px; + --mdv-chart-array-min-cell: 260px; + --mdv-chart-array-columns: 1; + + box-sizing: border-box; + width: 100%; + min-width: 0; + display: grid; + gap: var(--mdv-chart-array-gap); + padding: var(--mdv-chart-array-padding); + position: relative; +} + +/* Fill the scrollport; one cell uses the full inner area (padding aside). */ +.mdv-chart-array[data-layout="single"], +.mdv-chart-array[data-layout="pair"] { + height: 100%; + min-height: 100%; +} + +.mdv-chart-array[data-layout="single"] { + grid-template-columns: 1fr; + grid-template-rows: 1fr; +} + +.mdv-chart-array[data-layout="pair"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; +} + +/* Three or more: flex rows; each item grows to an even share of the row width. */ +.mdv-chart-array[data-layout="grid"] { + display: flex; + flex-wrap: wrap; + align-items: stretch; + align-content: flex-start; +} + +.mdv-chart-array[data-layout="grid"][data-grid-fill="true"] { + height: 100%; + min-height: 100%; + align-content: stretch; +} + +.mdv-chart-array[data-layout="grid"] .mdv-chart-array__cell { + box-sizing: border-box; + flex: 1 1 + calc( + (100% - var(--mdv-chart-array-gap) * (var(--mdv-chart-array-columns) - 1)) / + var(--mdv-chart-array-columns) + ); + max-width: calc( + (100% - var(--mdv-chart-array-gap) * (var(--mdv-chart-array-columns) - 1)) / + var(--mdv-chart-array-columns) + ); + min-width: min(var(--mdv-chart-array-min-cell), 100%); +} + +.mdv-chart-array[data-layout="grid"]:not([data-grid-fill="true"]) .mdv-chart-array__cell { + aspect-ratio: 1; +} + +.mdv-chart-array[data-layout="grid"][data-grid-fill="true"] .mdv-chart-array__cell { + min-height: 0; +} + +.mdv-chart-array__canvas { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: auto; + overflow: hidden; +} + +.mdv-chart-array__canvas .deckgl-overlay, +.mdv-chart-array__canvas canvas { + position: absolute !important; + left: 0 !important; + top: 0 !important; + width: 100% !important; + height: 100% !important; +} + +.mdv-chart-array__cell { + min-width: 0; + position: relative; + z-index: 1; + overflow: hidden; + border-radius: 2px; + border: 1px solid rgba(148, 163, 184, 0.4); + pointer-events: none; +} diff --git a/src/react/components/chartArrayLayoutUtils.test.ts b/src/react/components/chartArrayLayoutUtils.test.ts new file mode 100644 index 000000000..8ebf35389 --- /dev/null +++ b/src/react/components/chartArrayLayoutUtils.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; +import { + CHART_ARRAY_LAYOUT_DEFAULT_SIZING, + chartArrayGridFitsViewport, + estimateGridColumnCount, + estimateSquareGridContentHeight, + getChartArrayLayoutMode, + getPreferredGridColumnCount, +} from "./chartArrayLayoutUtils"; + +const sizing = CHART_ARRAY_LAYOUT_DEFAULT_SIZING; + +describe("getChartArrayLayoutMode", () => { + test("uses single layout for zero or one cell", () => { + expect(getChartArrayLayoutMode(0)).toBe("single"); + expect(getChartArrayLayoutMode(1)).toBe("single"); + }); + + test("uses pair layout for two cells", () => { + expect(getChartArrayLayoutMode(2)).toBe("pair"); + }); + + test("uses grid layout for three or more cells", () => { + expect(getChartArrayLayoutMode(3)).toBe("grid"); + expect(getChartArrayLayoutMode(12)).toBe("grid"); + }); +}); + +describe("getPreferredGridColumnCount", () => { + test("uses one row when every item can meet the minimum width", () => { + expect(getPreferredGridColumnCount(4, 1200, sizing)).toBe(4); + }); + + test("wraps to fewer columns when a single-row share would be too narrow", () => { + expect(getPreferredGridColumnCount(4, 800, sizing)).toBe(2); + expect(estimateGridColumnCount(800, sizing)).toBe(3); + }); +}); + +describe("chart array grid viewport fit", () => { + test("estimates max column count from min cell width", () => { + expect(estimateGridColumnCount(500, sizing)).toBe(1); + expect(estimateGridColumnCount(800, sizing)).toBe(3); + }); + + test("four cells in a wide short viewport need scrolling", () => { + const innerWidth = 800; + const contentHeight = estimateSquareGridContentHeight(4, innerWidth, sizing); + expect(contentHeight).toBeGreaterThan(400); + expect(chartArrayGridFitsViewport(4, 400, innerWidth, sizing)).toBe(false); + }); + + test("four cells in a tall viewport fit without scrolling", () => { + const innerWidth = 800; + const contentHeight = estimateSquareGridContentHeight(4, innerWidth, sizing); + expect(chartArrayGridFitsViewport(4, contentHeight, innerWidth, sizing)).toBe(true); + expect(chartArrayGridFitsViewport(4, contentHeight + 1, innerWidth, sizing)).toBe(true); + }); + + test("three cells in a typical panel height usually fit", () => { + expect(chartArrayGridFitsViewport(3, 600, 800, sizing)).toBe(true); + }); +}); diff --git a/src/react/components/chartArrayLayoutUtils.ts b/src/react/components/chartArrayLayoutUtils.ts new file mode 100644 index 000000000..847bee60b --- /dev/null +++ b/src/react/components/chartArrayLayoutUtils.ts @@ -0,0 +1,107 @@ +/** + * Layout helpers for {@link ChartArrayLayout} (prototype — heuristics and sizing may change). + */ + +/** Layout mode for {@link ChartArrayLayout}; overridable later for user prefs. */ +export type ChartArrayLayoutMode = "single" | "pair" | "grid"; + +/** Mirrors defaults in chartArrayLayout.css. */ +export type ChartArrayLayoutSizing = { + gap: number; + paddingX: number; + paddingY: number; + minCell: number; +}; + +export const CHART_ARRAY_LAYOUT_DEFAULT_SIZING: ChartArrayLayoutSizing = { + gap: 8, + paddingX: 24, + paddingY: 24, + minCell: 260, +}; + +export function getChartArrayLayoutMode(cellCount: number): ChartArrayLayoutMode { + if (cellCount <= 1) return "single"; + if (cellCount === 2) return "pair"; + return "grid"; +} + +function parseCssPx(value: string): number { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function readChartArraySizingFromRoot(root: HTMLElement): ChartArrayLayoutSizing { + const styles = getComputedStyle(root); + const paddingLeft = parseCssPx(styles.paddingLeft); + const paddingRight = parseCssPx(styles.paddingRight); + const paddingTop = parseCssPx(styles.paddingTop); + const paddingBottom = parseCssPx(styles.paddingBottom); + const gap = + parseCssPx(styles.columnGap) || + parseCssPx(styles.rowGap) || + parseCssPx(styles.gap) || + CHART_ARRAY_LAYOUT_DEFAULT_SIZING.gap; + const minCell = + parseCssPx(styles.getPropertyValue("--mdv-chart-array-min-cell")) || + CHART_ARRAY_LAYOUT_DEFAULT_SIZING.minCell; + + return { + gap, + paddingX: paddingLeft + paddingRight, + paddingY: paddingTop + paddingBottom, + minCell, + }; +} + +/** Matches `repeat(auto-fill, minmax(min(minCell, 100%), 1fr))` column count. */ +export function estimateGridColumnCount( + innerWidth: number, + sizing: ChartArrayLayoutSizing = CHART_ARRAY_LAYOUT_DEFAULT_SIZING, +): number { + if (innerWidth <= 0) return 1; + const trackMin = Math.min(sizing.minCell, innerWidth); + return Math.max(1, Math.floor((innerWidth + sizing.gap) / (trackMin + sizing.gap))); +} + +/** + * Column count that lets items share row width evenly, wrapping only when a single-row + * share would be narrower than {@link ChartArrayLayoutSizing.minCell}. + */ +export function getPreferredGridColumnCount( + cellCount: number, + innerWidth: number, + sizing: ChartArrayLayoutSizing = CHART_ARRAY_LAYOUT_DEFAULT_SIZING, +): number { + if (cellCount <= 0) return 1; + const maxColumns = Math.min(estimateGridColumnCount(innerWidth, sizing), cellCount); + const equalShareWidth = (innerWidth - (cellCount - 1) * sizing.gap) / cellCount; + if (equalShareWidth >= sizing.minCell) { + return cellCount; + } + const rowsIfMaxColumns = Math.ceil(cellCount / maxColumns); + return Math.max(1, Math.ceil(cellCount / rowsIfMaxColumns)); +} + +export function estimateSquareGridContentHeight( + cellCount: number, + innerWidth: number, + sizing: ChartArrayLayoutSizing = CHART_ARRAY_LAYOUT_DEFAULT_SIZING, +): number { + if (cellCount <= 0) return 0; + const columns = getPreferredGridColumnCount(cellCount, innerWidth, sizing); + const rows = Math.ceil(cellCount / columns); + const cellWidth = (innerWidth - (columns - 1) * sizing.gap) / columns; + return rows * cellWidth + (rows - 1) * sizing.gap + sizing.paddingY; +} + +/** True when square cells at the current width would fit inside the scrollport without scrolling. */ +export function chartArrayGridFitsViewport( + cellCount: number, + viewportHeight: number, + innerWidth: number, + sizing: ChartArrayLayoutSizing = CHART_ARRAY_LAYOUT_DEFAULT_SIZING, +): boolean { + if (cellCount <= 0 || viewportHeight <= 0 || innerWidth <= 0) return true; + return estimateSquareGridContentHeight(cellCount, innerWidth, sizing) <= viewportHeight; +} diff --git a/src/react/components/chartArrayUtils.test.ts b/src/react/components/chartArrayUtils.test.ts new file mode 100644 index 000000000..286c874a7 --- /dev/null +++ b/src/react/components/chartArrayUtils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { + indicesFromIntersectionEntries, + roundRect, + snapRectToDevicePixels, +} from "./chartArrayUtils"; + +describe("chartArrayUtils", () => { + test("snapRectToDevicePixels aligns bounds to the device pixel grid", () => { + expect(snapRectToDevicePixels({ x: 10.3, y: 0, width: 50.4, height: 40.2 }, 2)).toEqual({ + x: 10.5, + y: 0, + width: 50, + height: 40, + }); + }); + + test("roundRect snaps edges to integer pixels", () => { + expect(roundRect({ x: 12.4, y: 8.6, width: 100.2, height: 99.7 })).toEqual({ + x: 12, + y: 9, + width: 101, + height: 99, + }); + }); + + test("indicesFromIntersectionEntries returns visible sorted indices", () => { + const target = document.createElement("div"); + target.dataset.chartArrayIndex = "2"; + const entries = [ + { target, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + + expect(indicesFromIntersectionEntries(entries, 5)).toEqual([2]); + }); + + test("indicesFromIntersectionEntries ignores out-of-range indices", () => { + const target = document.createElement("div"); + target.dataset.chartArrayIndex = "9"; + const entries = [ + { target, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + + expect(indicesFromIntersectionEntries(entries, 3)).toEqual([]); + }); +}); diff --git a/src/react/components/chartArrayUtils.ts b/src/react/components/chartArrayUtils.ts new file mode 100644 index 000000000..c2a70dad1 --- /dev/null +++ b/src/react/components/chartArrayUtils.ts @@ -0,0 +1,90 @@ +export type ChartArrayRect = { + x: number; + y: number; + width: number; + height: number; +}; + +export function roundRect(rect: ChartArrayRect): ChartArrayRect { + const x = Math.round(rect.x); + const y = Math.round(rect.y); + const right = Math.round(rect.x + rect.width); + const bottom = Math.round(rect.y + rect.height); + return { + x, + y, + width: Math.max(0, right - x), + height: Math.max(0, bottom - y), + }; +} + +/** Align CSS-pixel bounds to the device-pixel grid so Deck sub-viewports stay sharp. */ +export function snapRectToDevicePixels( + rect: ChartArrayRect, + devicePixelRatio = typeof window !== "undefined" ? window.devicePixelRatio : 1, +): ChartArrayRect { + const dpr = Math.max(1, devicePixelRatio); + const snap = (value: number) => Math.round(value * dpr) / dpr; + const x = snap(rect.x); + const y = snap(rect.y); + const right = snap(rect.x + rect.width); + const bottom = snap(rect.y + rect.height); + return { + x, + y, + width: Math.max(0, right - x), + height: Math.max(0, bottom - y), + }; +} + +export function snapRectsToDevicePixels( + rects: ChartArrayRect[], + devicePixelRatio?: number, +): ChartArrayRect[] { + return rects.map((rect) => snapRectToDevicePixels(rect, devicePixelRatio)); +} + +export function measureCellRectsRelativeToRoot( + root: HTMLElement, + cells: readonly (HTMLElement | null)[], +): ChartArrayRect[] { + const rootRect = root.getBoundingClientRect(); + return cells.map((cell) => { + if (!cell) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + const cellRect = cell.getBoundingClientRect(); + return snapRectToDevicePixels({ + x: cellRect.left - rootRect.left, + y: cellRect.top - rootRect.top, + width: cellRect.width, + height: cellRect.height, + }); + }); +} + +export function measureRootSize(root: HTMLElement): ChartArrayRect { + const rect = root.getBoundingClientRect(); + return snapRectToDevicePixels({ + x: 0, + y: 0, + width: rect.width, + height: rect.height, + }); +} + +/** Cell indices with any intersection in the scroll root. */ +export function indicesFromIntersectionEntries( + entries: IntersectionObserverEntry[], + cellCount: number, +): number[] { + const visible = new Set(); + for (const entry of entries) { + const index = Number((entry.target as HTMLElement).dataset.chartArrayIndex); + if (!Number.isInteger(index) || index < 0 || index >= cellCount) continue; + if (entry.isIntersecting) { + visible.add(index); + } + } + return [...visible].sort((a, b) => a - b); +} diff --git a/src/react/components/deckLayerViewportScope.test.ts b/src/react/components/deckLayerViewportScope.test.ts new file mode 100644 index 000000000..0239d129b --- /dev/null +++ b/src/react/components/deckLayerViewportScope.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; +import { getDensityGridViewId } from "./densityGridUtils"; +import { + MDV_DECK_LAYER_VIEWPORT_SCOPE, + shouldDrawDeckLayerInViewport, +} from "./deckLayerViewportScope"; + +describe("deckLayerViewportScope", () => { + const detailId = "chart-1detail-react"; + const vivSuffix = `-#${detailId}#`; + const gridView = getDensityGridViewId("chart-1", "field_a", 0); + + test("chart-shared layers draw on overlay detail and every grid cell", () => { + const gate = { + id: `gate_${vivSuffix}`, + props: { [MDV_DECK_LAYER_VIEWPORT_SCOPE]: "chart-shared" }, + }; + const json = { + id: `json_${vivSuffix}`, + props: { [MDV_DECK_LAYER_VIEWPORT_SCOPE]: "chart-shared" }, + }; + + expect(shouldDrawDeckLayerInViewport(gate, detailId, { overlayDetailVivId: vivSuffix })).toBe(true); + expect(shouldDrawDeckLayerInViewport(json, detailId, { overlayDetailVivId: vivSuffix })).toBe(true); + expect(shouldDrawDeckLayerInViewport(gate, gridView, { overlayDetailVivId: vivSuffix })).toBe(true); + expect(shouldDrawDeckLayerInViewport(json, gridView, { overlayDetailVivId: vivSuffix })).toBe(true); + }); + + test("per-viewport layers only draw in their grid cell", () => { + const density = { + id: `${gridView}-density`, + props: { + [MDV_DECK_LAYER_VIEWPORT_SCOPE]: "per-viewport", + viewId: gridView, + }, + }; + const otherCell = getDensityGridViewId("chart-1", "field_b", 1); + + expect(shouldDrawDeckLayerInViewport(density, gridView, { overlayDetailVivId: vivSuffix })).toBe(true); + expect(shouldDrawDeckLayerInViewport(density, otherCell, { overlayDetailVivId: vivSuffix })).toBe(false); + expect(shouldDrawDeckLayerInViewport(density, detailId, { overlayDetailVivId: vivSuffix })).toBe(false); + }); + + test("infers chart-shared for selection layers without explicit scope", () => { + const selection = { id: "selection_-#chartdetail-react#", props: {} }; + expect(shouldDrawDeckLayerInViewport(selection, gridView)).toBe(true); + }); +}); diff --git a/src/react/components/deckLayerViewportScope.ts b/src/react/components/deckLayerViewportScope.ts new file mode 100644 index 000000000..1f60cc008 --- /dev/null +++ b/src/react/components/deckLayerViewportScope.ts @@ -0,0 +1,89 @@ +import { isDensityGridViewport, matchesDensityGridView } from "./densityGridUtils"; + +/** + * Declares how a deck layer participates in multi-viewport charts (single scatter vs density grid). + * + * - `chart-shared`: one layer instance, same geometry/data in every viewport (points, gates, JSON, + * selection). deck.gl `layerFilter` draws it in each cell; avoids N clones and duplicate GPU buffers. + * - `per-viewport`: viewport-specific visuals (density/contour per field today; per-cell scatter tint later). + * + * Set on layer props via {@link tagDeckLayerViewportScope}. Prefer tagging at layer creation over id heuristics. + */ +export const MDV_DECK_LAYER_VIEWPORT_SCOPE = "mdvDeckLayerViewportScope" as const; + +export type DeckLayerViewportScope = "chart-shared" | "per-viewport"; + +export type DeckLayerScopeInput = { + id: string; + props?: Record | null; +}; + +export function getDeckLayerViewportScope(layer: DeckLayerScopeInput): DeckLayerViewportScope | undefined { + const scope = layer.props?.[MDV_DECK_LAYER_VIEWPORT_SCOPE]; + if (scope === "chart-shared" || scope === "per-viewport") { + return scope; + } + return undefined; +} + +/** Fallback for layers not yet tagged (selection id prefix, explicit viewId, viv detail id). */ +export function inferDeckLayerViewportScope(layer: DeckLayerScopeInput): DeckLayerViewportScope { + const explicit = getDeckLayerViewportScope(layer); + if (explicit) return explicit; + if (layer.id.startsWith("selection_")) { + return "chart-shared"; + } + if (typeof layer.props?.viewId === "string") { + return "per-viewport"; + } + return "chart-shared"; +} + +type CloneableDeckLayer = { + clone: (props: Record) => unknown; +}; + +export function tagDeckLayerViewportScope( + layer: L, + scope: DeckLayerViewportScope, + extraProps?: Record, +): L { + return layer.clone({ + ...extraProps, + [MDV_DECK_LAYER_VIEWPORT_SCOPE]: scope, + }) as L; +} + +export type DeckLayerViewportFilterOptions = { + /** Viv id suffix for the overlay detail viewport, e.g. `-#my-chartdetail-react#`. */ + overlayDetailVivId?: string; +}; + +/** + * Whether `layer` should draw in `viewportId` (MDVivViewer detail view or density-grid cell). + */ +export function shouldDrawDeckLayerInViewport( + layer: DeckLayerScopeInput, + viewportId: string, + options?: DeckLayerViewportFilterOptions, +): boolean { + const scope = inferDeckLayerViewportScope(layer); + const overlayVivId = options?.overlayDetailVivId; + + if (scope === "chart-shared") { + if (overlayVivId && layer.id.includes(overlayVivId)) { + return true; + } + return isDensityGridViewport(viewportId); + } + + if (!isDensityGridViewport(viewportId)) { + return overlayVivId ? layer.id.includes(overlayVivId) : false; + } + + const layerViewId = layer.props?.viewId; + if (typeof layerViewId === "string") { + return layerViewId === viewportId || matchesDensityGridView(layer.id, viewportId); + } + return matchesDensityGridView(layer.id, viewportId); +} diff --git a/src/react/components/densityGridUtils.test.ts b/src/react/components/densityGridUtils.test.ts new file mode 100644 index 000000000..4a34cbd54 --- /dev/null +++ b/src/react/components/densityGridUtils.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "vitest"; +import { getVivGridDetailViewId } from "./chartArrayGridUtils"; +import { + getDensityGridViewId, + getDensityGridViewStates, + getViewportAtCanvasPoint, + hasUsableOrthographicViewState, + isDensityGridViewport, + isEditableSelectionLayerId, + matchesDensityGridView, + shouldDrawLayerInDeckDensityGrid, + shouldDrawLayerInViewport, + supportsDensityGridMode, + unprojectCanvasPoint, +} from "./densityGridUtils"; + +describe("densityGridUtils", () => { + test("creates stable view ids and scopes sublayers", () => { + const viewId = getDensityGridViewId("chart:#1", "marker/a", 2); + + expect(viewId).toBe("density-grid-chart__1-2-marker_a"); + expect(matchesDensityGridView(`${viewId}-weights`, viewId)).toBe(true); + expect(matchesDensityGridView(`${viewId}-selection`, viewId)).toBe(true); + expect(matchesDensityGridView(`${viewId}-weights`, "other-view")).toBe(false); + }); + + test("supports density grid for deck and viv spatial chart types", () => { + expect(supportsDensityGridMode("DeckContourScatter")).toBe(true); + expect(supportsDensityGridMode("DeckDensity")).toBe(true); + expect(supportsDensityGridMode("VivMdvRegionReact")).toBe(true); + expect(supportsDensityGridMode("viv_scatter_plot")).toBe(false); + expect(supportsDensityGridMode("wgl_scatter_plot")).toBe(false); + expect(supportsDensityGridMode(undefined)).toBe(false); + }); + + test("creates viv detail view ids from grid view ids", () => { + const detailId = getVivGridDetailViewId("chart:#1", "marker/a", 2); + expect(detailId).toBe("density-grid-chart__1-2-marker_adetail-react"); + expect(matchesDensityGridView(`${detailId}-gate`, detailId)).toBe(true); + }); + + test("detects usable orthographic view state", () => { + expect(hasUsableOrthographicViewState(undefined)).toBe(false); + expect(hasUsableOrthographicViewState({ target: [1, 2, 0], zoom: 3 })).toBe(true); + expect(hasUsableOrthographicViewState({ target: [Number.NaN, 2, 0], zoom: 3 })).toBe(false); + }); + + test("identifies density grid viewports", () => { + expect(isDensityGridViewport("density-grid-chart__1-0-field")).toBe(true); + expect(isDensityGridViewport("my-chartdetail-react")).toBe(false); + }); + + test("routes layers by viewport scope through shouldDrawLayerInViewport", () => { + const detailId = "chart-1detail-react"; + const vivSuffix = `-#${detailId}#`; + const gridView = getDensityGridViewId("chart-1", "field_a", 0); + const chartShared = { + id: `gate_${vivSuffix}`, + props: { mdvDeckLayerViewportScope: "chart-shared" }, + }; + const perViewport = { + id: `${gridView}-density`, + props: { mdvDeckLayerViewportScope: "per-viewport", viewId: gridView }, + }; + + expect(shouldDrawLayerInViewport(chartShared, detailId, vivSuffix)).toBe(true); + expect(shouldDrawLayerInViewport(chartShared, gridView, vivSuffix)).toBe(true); + expect(shouldDrawLayerInDeckDensityGrid(chartShared, gridView)).toBe(true); + expect(shouldDrawLayerInViewport(perViewport, gridView, vivSuffix)).toBe(true); + expect(shouldDrawLayerInDeckDensityGrid(perViewport, gridView)).toBe(true); + expect( + shouldDrawLayerInDeckDensityGrid( + perViewport, + getDensityGridViewId("chart-1", "field_b", 1), + ), + ).toBe(false); + }); + + test("identifies editable selection layers", () => { + expect(isEditableSelectionLayerId("selection_-#chartdetail-react#")).toBe(true); + expect(isEditableSelectionLayerId("gate_-#chartdetail-react#")).toBe(false); + }); + + test("draws selection in every density grid viewport", () => { + const selection = { id: "selection_-#chartdetail-react#", props: {} }; + const gridView = getDensityGridViewId("chart-1", "field_a", 0); + + expect(shouldDrawLayerInDeckDensityGrid(selection, gridView)).toBe(true); + expect(shouldDrawLayerInViewport(selection, gridView, "-#ignored#")).toBe(true); + expect(shouldDrawLayerInDeckDensityGrid(selection, "chart-1detail-react")).toBe(false); + }); + + test("unprojects through the sub-viewport under the pointer", () => { + const viewports = [ + { + id: "density-grid-a-0-x", + x: 0, + y: 0, + width: 100, + height: 100, + unproject: ([px, py]: number[]) => [px, py, 0], + }, + { + id: "density-grid-a-1-y", + x: 100, + y: 0, + width: 100, + height: 100, + unproject: ([px, py]: number[]) => [px + 1000, py, 0], + }, + ]; + + expect(getViewportAtCanvasPoint(viewports, 10, 10)?.id).toBe("density-grid-a-0-x"); + expect(getViewportAtCanvasPoint(viewports, 150, 10)?.id).toBe("density-grid-a-1-y"); + expect(unprojectCanvasPoint(viewports, 150, 20)).toEqual([1050, 20, 0]); + }); + + test("maps shared view state onto each view id", () => { + const viewStates = getDensityGridViewStates( + ["view-a", "view-b"], + { target: [1, 2, 0], zoom: 3, minZoom: -50 }, + ); + + expect(viewStates["view-a"]).toMatchObject({ target: [1, 2, 0], zoom: 3 }); + expect(viewStates["view-b"]).toMatchObject({ target: [1, 2, 0], zoom: 3 }); + }); +}); diff --git a/src/react/components/densityGridUtils.ts b/src/react/components/densityGridUtils.ts new file mode 100644 index 000000000..cff0c3b0f --- /dev/null +++ b/src/react/components/densityGridUtils.ts @@ -0,0 +1,123 @@ +import type { Layer, OrthographicViewState } from "@deck.gl/core"; +import { + getChartArrayViewId, + getChartArrayViewStates, + hasUsableOrthographicViewState, + getVivGridDetailViewId, +} from "./chartArrayGridUtils"; +import { shouldDrawDeckLayerInViewport, type DeckLayerScopeInput } from "./deckLayerViewportScope"; + +export { getChartArrayViewStates, hasUsableOrthographicViewState, getVivGridDetailViewId }; + +/** Chart types that expose the density grid setting (see `includeDensityModeToggle`). */ +export const DENSITY_GRID_CHART_TYPES = new Set([ + "DeckContourScatter", + "DeckDensity", + "VivMdvRegionReact", +]); + +export function supportsDensityGridMode(chartType: string | undefined) { + return typeof chartType === "string" && DENSITY_GRID_CHART_TYPES.has(chartType); +} + +export function getDensityGridViewId(chartId: string, fieldId: string, index: number) { + return getChartArrayViewId(chartId, fieldId, index, "density-grid"); +} + +export function matchesDensityGridView(layerId: string, viewId: string) { + return layerId === viewId || layerId.startsWith(`${viewId}-`); +} + +export function isDensityGridViewport(viewportId: string) { + return viewportId.startsWith("density-grid-"); +} + +export function isEditableSelectionLayerId(layerId: string) { + return layerId.startsWith("selection_"); +} + +export type DeckCanvasViewport = { + id: string; + x?: number; + y?: number; + width?: number; + height?: number; + unproject: (coords: number[]) => number[]; +}; + +/** Viewport whose bounds contain the canvas point, or the topmost match when cells overlap in z-order. */ +export function getViewportAtCanvasPoint( + viewports: readonly DeckCanvasViewport[], + canvasX: number, + canvasY: number, +): DeckCanvasViewport | undefined { + for (let i = viewports.length - 1; i >= 0; i--) { + const vp = viewports[i]; + const x = vp.x ?? 0; + const y = vp.y ?? 0; + const width = vp.width ?? 0; + const height = vp.height ?? 0; + if (canvasX >= x && canvasX < x + width && canvasY >= y && canvasY < y + height) { + return vp; + } + } + return viewports[0]; +} + +/** Unproject canvas pixels through the sub-viewport under the pointer (multi-view grids). */ +export function unprojectCanvasPoint( + viewports: readonly DeckCanvasViewport[], + canvasX: number, + canvasY: number, +): number[] { + const viewport = getViewportAtCanvasPoint(viewports, canvasX, canvasY); + if (!viewport) return [0, 0, 0]; + const localX = canvasX - (viewport.x ?? 0); + const localY = canvasY - (viewport.y ?? 0); + return viewport.unproject([localX, localY]); +} + +export type LayerViewportFilterInput = DeckLayerScopeInput; + +/** + * Deck layerFilter for MDVivViewer: single detail viewports use viv id suffixes; + * density-grid cells route layers by {@link DeckLayerViewportScope} and optional viewId. + */ +export function shouldDrawLayerInViewport( + layer: LayerViewportFilterInput, + viewportId: string, + vivIdForViewport: string, +) { + return shouldDrawDeckLayerInViewport(layer, viewportId, { + overlayDetailVivId: vivIdForViewport, + }); +} + +/** layerFilter for Deck-only density grid (no viv id suffix on viewports). */ +export function shouldDrawLayerInDeckDensityGrid(layer: LayerViewportFilterInput, viewportId: string) { + return shouldDrawDeckLayerInViewport(layer, viewportId); +} + +export function getDensityGridViewStates( + viewIds: string[], + sharedViewState: OrthographicViewState, +): Record { + return getChartArrayViewStates(viewIds, sharedViewState); +} + +type CloneableDeckLayer = Layer & { + clone: (props: Record) => Layer; +}; + +export function cloneDeckLayer(layer: CloneableDeckLayer, props: Record): Layer { + return layer.clone(props); +} + +export function getSerializableViewState(viewState: OrthographicViewState): OrthographicViewState { + return { + target: viewState.target, + zoom: viewState.zoom, + minZoom: viewState.minZoom, + maxZoom: viewState.maxZoom, + }; +} diff --git a/src/react/components/sharedScatterSettings.ts b/src/react/components/sharedScatterSettings.ts index 462fae5c1..133e42afa 100644 --- a/src/react/components/sharedScatterSettings.ts +++ b/src/react/components/sharedScatterSettings.ts @@ -7,6 +7,7 @@ import { scatterDefaults, type ScatterPlotConfig } from "../scatter_state"; type SharedScatterSettingsOptions = { chart?: BaseChart; includeDensitySettings?: boolean; + includeDensityModeToggle?: boolean; includePointShape?: boolean; includeTooltip?: boolean; includeZoomOnFilter?: boolean; @@ -17,6 +18,7 @@ export function getSharedScatterSettings( { chart, includeDensitySettings = false, + includeDensityModeToggle = false, includePointShape = false, includeTooltip = true, includeZoomOnFilter = true, @@ -109,7 +111,7 @@ export function getSharedScatterSettings( } if (includeDensitySettings && chart) { - settings.push(getDensitySettings(config)); + settings.push(getDensitySettings(config, { includeDensityModeToggle })); } return settings; diff --git a/src/react/contour_state.test.ts b/src/react/contour_state.test.ts index 6f86a4251..cfbfcbd85 100644 --- a/src/react/contour_state.test.ts +++ b/src/react/contour_state.test.ts @@ -4,6 +4,7 @@ import type { CategoricalDataType } from '@/charts/charts'; import { autorun, makeAutoObservable, runInAction } from 'mobx'; import { useCategoryContour, + useFieldContour, getDensitySettings, getContourVisualSettings, type DualContourLegacyConfig, @@ -17,6 +18,7 @@ vi.mock('./hooks', () => ({ useFieldSpec: vi.fn(), useCategoryFilterIndices: vi.fn(), useParamColumns: vi.fn(), + useFilteredIndices: vi.fn(), })); vi.mock('./context', () => ({ @@ -35,7 +37,7 @@ vi.mock('use-debounce', () => ({ useDebounce: vi.fn((value) => [value]), })); -import { useFieldSpec, useCategoryFilterIndices, useParamColumns } from './hooks'; +import { useFieldSpec, useCategoryFilterIndices, useParamColumns, useFilteredIndices } from './hooks'; import { useDataStore } from './context'; import { useViewState } from './deck_state'; @@ -59,6 +61,7 @@ describe('useCategoryContour', () => { beforeEach(() => { vi.clearAllMocks(); (useParamColumns as any).mockReturnValue([mockCx, mockCy]); + (useFilteredIndices as any).mockReturnValue(new Uint32Array([0, 1, 2, 3, 4])); (useViewState as any).mockReturnValue({ zoom: 1 }); (useDataStore as any).mockReturnValue({ getColumnColors: vi.fn(() => [[255, 0, 0], [0, 255, 0], [0, 0, 255]]), @@ -258,6 +261,65 @@ describe('useCategoryContour', () => { }); }); +describe('useFieldContour', () => { + const mockCx = { + data: new Float32Array([0, 1, 2]), + }; + const mockCy = { + data: new Float32Array([0, 1, 2]), + }; + + beforeEach(() => { + vi.clearAllMocks(); + (useParamColumns as any).mockReturnValue([mockCx, mockCy]); + (useFilteredIndices as any).mockReturnValue(new Uint32Array([0, 1, 2])); + (useViewState as any).mockReturnValue({ zoom: 1 }); + }); + + test('builds contours only for visible field indices when provided', () => { + const mockFields = [ + { + name: 'field-a', + field: 'field-a', + data: new Float32Array([0, 0.5, 1]), + minMax: [0, 1] as const, + datatype: 'double' as const, + }, + { + name: 'field-b', + field: 'field-b', + data: new Float32Array([0, 0.5, 1]), + minMax: [0, 1] as const, + datatype: 'double' as const, + }, + { + name: 'field-c', + field: 'field-c', + data: new Float32Array([0, 0.5, 1]), + minMax: [0, 1] as const, + datatype: 'double' as const, + }, + ]; + + const { result } = renderHook(() => + useFieldContour({ + id: 'density-grid', + fill: true, + bandwidth: 0.1, + intensity: 0.1, + opacity: 0.2, + fillThreshold: 2, + fields: mockFields as any, + visibleFieldIndices: [1], + }), + ); + + expect(result.current[0]).toBeUndefined(); + expect(result.current[1]?.id).toBe('density-grid_field-b'); + expect(result.current[2]).toBeUndefined(); + }); +}); + describe('getDensitySettings category selection wiring', () => { test('scatter defaults seed density settings keys so later edits stay observable', () => { const config = makeAutoObservable({ @@ -287,6 +349,7 @@ describe('getDensitySettings category selection wiring', () => { { contourParameter: undefined, category1: [] }, { contourParameter: 'test-field', category1: ['tag-a'] }, ]); + expect(config.density_mode).toBe('overlay'); }); test('builds category selection controls that read from live config state', () => { @@ -326,6 +389,33 @@ describe('getDensitySettings category selection wiring', () => { expect(category2.sourceColumn?.()).toBe('test-field'); }); + test('builds a density grid mode toggle', () => { + const mockConfig: DualContourLegacyConfig & BaseConfig = { + ...scatterDefaults, + id: 'test-chart', + size: [800, 600], + title: 'Test Chart', + legend: '', + type: 'scatter', + param: ['x', 'y'], + density_mode: 'overlay', + }; + + const spec = getDensitySettings(mockConfig, { includeDensityModeToggle: true }); + const densityModeToggle = spec.current_value[1]; + if (densityModeToggle.type !== 'check') { + throw new Error('expected density mode check setting'); + } + + expect(densityModeToggle.label).toBe('Show density fields as grid'); + expect(densityModeToggle.current_value).toBe(false); + densityModeToggle.func?.(true); + expect(mockConfig.density_mode).toBe('grid'); + expect(mockConfig.contour_fill).toBe(true); + densityModeToggle.func?.(false); + expect(mockConfig.density_mode).toBe('overlay'); + }); + test('category selection getters follow source column and category changes', () => { const mockConfig: DualContourLegacyConfig & BaseConfig = { ...scatterDefaults, diff --git a/src/react/contour_state.ts b/src/react/contour_state.ts index dbacfa5c8..0ce4eb474 100644 --- a/src/react/contour_state.ts +++ b/src/react/contour_state.ts @@ -5,6 +5,7 @@ import type { Disposer, LoadedDataColumn, FieldName, + NumberDataType, } from "@/charts/charts"; import { useEffect, useMemo, useState } from "react"; import { @@ -50,9 +51,23 @@ export type FieldContourProps = { intensity: number; opacity: number; fillThreshold: number; - fields?: LoadedDataColumn<"double">[]; + fields?: LoadedDataColumn[]; + /** Indices into `fields` to build contours for. Omit to build all fields. */ + visibleFieldIndices?: readonly number[]; hoveredFieldId?: FieldName | null; } + +function getFieldContourIndices(fieldCount: number, visibleFieldIndices?: readonly number[]) { + return visibleFieldIndices ?? Array.from({ length: fieldCount }, (_, index) => index); +} + +export function isLoadedNumericContourField(field: DataColumn): field is LoadedDataColumn { + return ( + (field.datatype === "double" || field.datatype === "integer" || field.datatype === "int32") && + field.data !== undefined + ); +} + function rgb( r: number, g: number, @@ -157,6 +172,9 @@ function createBaseContourLayerProps(params: { debounce: 1000, weightsTextureSize: 256, // Intermediate value for balanced performance and quality pickable: false, + updateTriggers: { + getPosition: [cx, cy], + }, }; } @@ -216,28 +234,134 @@ export function useCategoryContour(props: CategoryContourProps) { } /** pending better definition */ export type ContourLayerProps = ReturnType; +function buildFieldContourLayerProps(params: { + id: string; + name: string; + fieldId: FieldName; + fieldData: ArrayLike; + minMax: readonly [number, number]; + data: ReturnType; + cx: { data: ArrayLike }; + cy: { data: ArrayLike }; + radiusPixels: number; + fill: boolean; + fillThreshold: number; + intensity: number; + opacity: number; + hoveredFieldId?: FieldName | null; +}) { + const { + id, + name, + fieldId, + fieldData, + minMax, + data, + cx, + cy, + radiusPixels, + fill, + fillThreshold, + intensity, + opacity, + hoveredFieldId, + } = params; + // Adjust opacity based on hover state + const isHovered = hoveredFieldId === fieldId; + const hasHover = hoveredFieldId !== null && hoveredFieldId !== undefined; + // Hovered field: increase opacity (clamped to 1.0) + // Non-hovered fields: reduce opacity when another field is hovered + const adjustedOpacity = isHovered + ? Math.min(1.0, opacity * 1.5) + : hasHover + ? opacity * 0.8 + : opacity; + const adjustedIntensity = isHovered + ? Math.min(1.0, intensity * 1.5) + : hasHover + ? intensity * 0.8 + : intensity; + const baseProps = createBaseContourLayerProps({ + id: `${id}_${name}`, // if we base this id on index rather than name we might do some transitions + data, // todo filter sparse data + cx, + cy, + radiusPixels, + fillOpacity: adjustedIntensity, + contourOpacity: adjustedOpacity, + contourFill: fill ? fillThreshold : 10000, + colorRange: [getFieldColor(fieldId)], + }); + + return { + ...baseProps, + getWeight: (i: number) => { + // potential for this to be animated in fun ways... + // phase shift for each field in shader... + + // normalization pending refactor/design + // things to consider: in some circumstances normalise range across all fields + const [min, max] = minMax; + const range = max - min; + if (range === 0) return 0; + const value = fieldData[i]; + if (value < min) return 0; + if (value > max) return 1; + const normalizedValue = (value - min) / range; + return normalizedValue; + }, + transitions: { + // nb - failed to get this working + // will need changes to the HeatmapContourExtension implementation + // (updateState vs draw, props vs state) but attempts thus far have been fruitless + // plan to implement that differently anyway. + // fillOpacity: 500, + // contourOpacity: 500 + /// getWeight transition not working either + getWeight: { + duration: 1000, + // easing: t => t, + }, + }, + updateTriggers: { + ...baseProps.updateTriggers, + getWeight: [fieldData], + }, + }; +} + export function useFieldContour(props: FieldContourProps) { - const { id, fill, bandwidth, intensity, opacity, fillThreshold, fields, hoveredFieldId } = - props; + const { + id, + fill, + bandwidth, + intensity, + opacity, + fillThreshold, + fields, + visibleFieldIndices, + hoveredFieldId, + } = props; // there's a possiblity that in future different layers of the same chart might draw from different data sources... // so encapsulating things like getPosition might be useful. const [cx, cy] = useParamColumns(); const data = useFilteredIndices(); const { zoom } = useViewState(); // we can compensate so that we don't have radiusPixels, but it makes it very slow... - //won't be necessary when we implement heatmap differently + // won't be necessary when we implement heatmap differently // const [debounceZoom] = useDebounce(zoom, 500); - const debounceZoom = zoom; //actually - re-evaluating memo was never a bottleneck + const debounceZoom = zoom; // actually - re-evaluating memo was never a bottleneck + const visibleFieldIndicesKey = visibleFieldIndices?.join("\u0000") ?? ""; return useMemo(() => { if (!fields) return []; - //If I return a layer here rather than props, will it behave as expected? - //not really - we want to pass this into getSublayerProps() so the id is used correctly + // If I return a layer here rather than props, will it behave as expected? + // not really - we want to pass this into getSublayerProps() so the id is used correctly const radiusPixels = 30 * bandwidth * 2 ** debounceZoom; // console.log('radiusPixels', radiusPixels); // there is an issue of the scaling of these layers e.g. with images that have been resized... // what is different about how we scale these layers vs other scatterplot layer? - //const fieldStats = fields.reduce((field) => { ... }); + // const fieldStats = fields.reduce((field) => { ... }); // Sort fields so that hovered field is drawn last (on top) // maybe too strong - could have more fine-tuning of boost/attenuation... // maybe want a copy of hoveredField drawn on top with different parameters (animation...) @@ -248,88 +372,54 @@ export function useFieldContour(props: FieldContourProps) { // if (!aIsHovered && bIsHovered) return -1; // a goes before b // return 0; // maintain original order for non-hovered fields // }); - return fields.map(({ name, field: fieldId, data: fieldData, minMax }) => { - // Adjust opacity based on hover state - const isHovered = hoveredFieldId === fieldId; - const hasHover = hoveredFieldId !== null && hoveredFieldId !== undefined; - - // Hovered field: increase opacity (clamped to 1.0) - // Non-hovered fields: reduce opacity when another field is hovered - const adjustedOpacity = isHovered - ? Math.min(1.0, opacity * 1.5) - : hasHover - ? opacity * 0.8 - : opacity; - - const adjustedIntensity = isHovered - ? Math.min(1.0, intensity * 1.5) - : hasHover - ? intensity * 0.8 - : intensity; - - const baseProps = createBaseContourLayerProps({ - id: `${id}_${name}`,//if we base this id on index rather than name we might do some transitions - data, //todo filter sparse data + const fieldIndices = getFieldContourIndices(fields.length, visibleFieldIndices); + const buildLayer = (fieldIndex: number) => { + const field = fields[fieldIndex]; + if (!field) return undefined; + const { name, field: fieldId, data: fieldData, minMax } = field; + return buildFieldContourLayerProps({ + id, + name, + fieldId, + fieldData, + minMax, + data, cx, cy, radiusPixels, - fillOpacity: adjustedIntensity, - contourOpacity: adjustedOpacity, - contourFill: fill ? fillThreshold : 10000, - colorRange: [getFieldColor(fieldId)], + fill, + fillThreshold, + intensity, + opacity, + hoveredFieldId, }); - - return { - ...baseProps, - getWeight: (i: number) => { - //potential for this to be animated in fun ways... - //phase shift for each field in shader... - - //normalization pending refactor/design - //things to consider: in some circumstances normalise range across all fields - const [min, max] = minMax; - const range = max - min; - if (range === 0) return 0; - const value = fieldData[i]; - if (value < min) return 0; - if (value > max) return 1; - const normalizedValue = (value - min) / range; - return normalizedValue; - }, - transitions: { - // nb - failed to get this working - // will need changes to the HeatmapContourExtension implementation - // (updateState vs draw, props vs state) but attempts thus far have been fruitless - // plan to implement that differently anyway. - // fillOpacity: 500, - // contourOpacity: 500 - /// getWeight transition not working either - getWeight: { - duration: 1000, - // easing: t => t, - }, - }, - updateTriggers: { - getWeight: [fieldData], - // getFilterValue: [fieldData] - }, - // not working? - // getFilterValue: (i: number) => fieldData[i] === 0, - // extensions: [new DataFilterExtension({filterSize: 1})] - }; - }); + }; + + if (!visibleFieldIndices) { + return fields.map((_, fieldIndex) => buildLayer(fieldIndex)).filter((layer) => layer !== undefined); + } + + // Sparse array aligned with `fields` so callers can index by original field position. + const layers: Array | undefined> = []; + for (const fieldIndex of fieldIndices) { + layers[fieldIndex] = buildLayer(fieldIndex); + } + return layers; }, [ id, data, intensity, cx, cy, + cx.field, + cy.field, debounceZoom, bandwidth, fill, opacity, fillThreshold, fields, + visibleFieldIndicesKey, hoveredFieldId, ]); } @@ -342,6 +432,9 @@ export type ContourVisualConfig = { contour_intensity: number; contour_opacity: number; } + +export type DensityMode = "overlay" | "grid"; + /** In future I think we want something more flexible & expressive, * but this should be somewhat compatible with the previous implementation */ @@ -351,6 +444,7 @@ export type DualContourLegacyConfig = { category1?: string | string[]; category2?: string | string[]; densityFields?: FieldSpecs; // don't have a way of specifying datatype here + density_mode?: DensityMode; field_legend: { display: boolean; // todo - more legend configuration options @@ -359,13 +453,14 @@ export type DualContourLegacyConfig = { type DensityVisualisationFolderOptions = { categorySelectionControls: AnyGuiSpec[]; + displayControls?: AnyGuiSpec[]; legendControls?: AnyGuiSpec[]; disposers?: Disposer[]; }; export function getDensityVisualisationFolder( config: ContourVisualConfig, - { categorySelectionControls, legendControls = [], disposers = [] }: DensityVisualisationFolderOptions, + { categorySelectionControls, displayControls = [], legendControls = [], disposers = [] }: DensityVisualisationFolderOptions, ) { const currentValue: AnyGuiSpec[] = [ g({ @@ -373,6 +468,7 @@ export function getDensityVisualisationFolder( label: "Category selection", current_value: categorySelectionControls, }), + ...displayControls, ...getContourVisualSettings(config), ]; @@ -463,7 +559,10 @@ function useContourCategorySelections(config: DualContourLegacyConfig) { return categories; } -export function getDensitySettings(c: DualContourLegacyConfig & BaseConfig) { +export function getDensitySettings( + c: DualContourLegacyConfig & BaseConfig, + { includeDensityModeToggle = false }: { includeDensityModeToggle?: boolean } = {}, +) { return getDensityVisualisationFolder(c, { categorySelectionControls: [ //maybe 2-spaces format is better... @@ -519,6 +618,19 @@ export function getDensitySettings(c: DualContourLegacyConfig & BaseConfig) { }, }), ], + displayControls: includeDensityModeToggle + ? [ + g({ + type: "check", + label: "Show density fields as grid", + current_value: c.density_mode === "grid", + func: (x) => { + c.density_mode = x ? "grid" : "overlay"; + if (x) c.contour_fill = true; + }, + }), + ] + : [], legendControls: [ g({ type: "check", @@ -619,10 +731,14 @@ export function useLegacyDualContour(hoveredFieldId?: FieldName | null): Contour fillThreshold: config.contour_fillThreshold ?? 2, }; const fields = useFieldSpecs(config.densityFields); + const overlayFields = + config.density_mode === "grid" + ? [] + : fields.filter(isLoadedNumericContourField); const fieldContours = useFieldContour({ ...commonProps, id: "fieldContours", - fields: fields.filter(field => field.datatype === "double") as LoadedDataColumn<"double">[], + fields: overlayFields, hoveredFieldId, }); const contour1 = useCategoryContour({ @@ -653,8 +769,8 @@ export function useFieldContourLegend(densityFields?: FieldSpecs): FieldLegendIt const fields = useFieldSpecs(densityFields); return useMemo(() => { - // Filter to only double fields (matching the filter in useLegacyDualContour) - const doubleFields = fields.filter(field => field.datatype === "double"); + // Filter to numeric fields (matching the filter in useLegacyDualContour) + const doubleFields = fields.filter(isLoadedNumericContourField); return doubleFields.map(field => ({ name: field.name, diff --git a/src/react/hooks/useChartArrayGrid.ts b/src/react/hooks/useChartArrayGrid.ts new file mode 100644 index 000000000..b33dc9585 --- /dev/null +++ b/src/react/hooks/useChartArrayGrid.ts @@ -0,0 +1,99 @@ +import { useMemo, useRef, type RefObject } from "react"; +import type { ChartArrayLayoutHandle } from "../components/ChartArrayLayout"; +import type { ChartArrayRect } from "../components/chartArrayUtils"; +import { getChartArrayViewId } from "../components/chartArrayGridUtils"; +import { useChartArrayMetrics, type ChartArrayMetrics } from "./useChartArrayMetrics"; +import { useChartArrayVisibleIndices } from "./useChartArrayVisibleIndices"; + +export type ChartArrayCell = { + key: string; + label: string; +}; + +export type UseChartArrayGridOptions = { + chartId: string; + cells: ChartArrayCell[]; + getViewId?: (chartId: string, cell: ChartArrayCell, index: number) => string; +}; + +export type UseChartArrayGridResult = { + layoutRef: RefObject; + scrollContainerRef: RefObject; + metrics: ChartArrayMetrics; + visibleCellIndices: number[]; + viewIds: string[]; + visibleViewIds: string[]; + configuredCellCount: number; + cellCount: number; + cellKeys: string[]; + getCellBounds: (index: number) => ChartArrayRect | undefined; + hasValidVisibleCells: boolean; + hasCanvas: boolean; +}; + +export function useChartArrayGrid({ + chartId, + cells, + getViewId = (id, cell, index) => getChartArrayViewId(id, cell.key, index), +}: UseChartArrayGridOptions): UseChartArrayGridResult { + const layoutRef = useRef(null); + const scrollContainerRef = useRef(null); + const cellCount = cells.length; + const configuredCellCount = cellCount; + + const metrics = useChartArrayMetrics(layoutRef, cellCount); + const visibleCellIndices = useChartArrayVisibleIndices(scrollContainerRef, layoutRef, cellCount); + const visibleCellIndicesKey = visibleCellIndices.join("\u0000"); + const stableVisibleCellIndices = useMemo( + () => visibleCellIndices, + [visibleCellIndicesKey], + ); + + const viewIds = useMemo( + () => cells.map((cell, index) => getViewId(chartId, cell, index)), + [cells, chartId, getViewId], + ); + + const visibleViewIds = useMemo( + () => + stableVisibleCellIndices + .map((index) => viewIds[index]) + .filter((viewId): viewId is string => Boolean(viewId)), + [stableVisibleCellIndices, viewIds], + ); + + const cellKeys = useMemo(() => cells.map((cell) => cell.key), [cells]); + + const getCellBounds = (index: number) => metrics.cellBounds[index]; + + const hasValidVisibleCells = + stableVisibleCellIndices.length > 0 && + stableVisibleCellIndices.every((index) => { + const bounds = metrics.cellBounds[index]; + return ( + bounds !== undefined && + bounds.width > 0 && + bounds.height > 0 && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height) + ); + }); + + const hasCanvas = + metrics.rootSize.width > 0 && metrics.rootSize.height > 0 && hasValidVisibleCells; + + return { + layoutRef, + scrollContainerRef, + metrics, + visibleCellIndices: stableVisibleCellIndices, + viewIds, + visibleViewIds, + configuredCellCount, + cellCount, + cellKeys, + getCellBounds, + hasValidVisibleCells, + hasCanvas, + }; +} diff --git a/src/react/hooks/useChartArrayLayoutGeometry.ts b/src/react/hooks/useChartArrayLayoutGeometry.ts new file mode 100644 index 000000000..8f89b8ca4 --- /dev/null +++ b/src/react/hooks/useChartArrayLayoutGeometry.ts @@ -0,0 +1,93 @@ +/** + * Resize-driven column count and viewport-fill detection for chart-array grid mode. + * Prototype implementation — likely to be replaced or simplified when layout becomes user-configurable. + */ +import { useLayoutEffect, useState, type RefObject } from "react"; +import { + chartArrayGridFitsViewport, + getPreferredGridColumnCount, + readChartArraySizingFromRoot, + type ChartArrayLayoutMode, +} from "../components/chartArrayLayoutUtils"; + +export type ChartArrayLayoutGeometry = { + gridFillsViewport: boolean; + gridColumns: number; +}; + +const DEFAULT_GEOMETRY: ChartArrayLayoutGeometry = { + gridFillsViewport: true, + gridColumns: 1, +}; + +export function useChartArrayLayoutGeometry( + layoutRootRef: RefObject, + cellCount: number, + layoutMode: ChartArrayLayoutMode, +): ChartArrayLayoutGeometry { + const [geometry, setGeometry] = useState(DEFAULT_GEOMETRY); + + useLayoutEffect(() => { + if (layoutMode !== "grid" || cellCount === 0) { + setGeometry(DEFAULT_GEOMETRY); + return; + } + + let cancelled = false; + let resizeObserver: ResizeObserver | null = null; + + const measure = () => { + if (cancelled) return; + const root = layoutRootRef.current; + const scrollRoot = root?.parentElement; + if (!root || !scrollRoot) return; + + const sizing = readChartArraySizingFromRoot(root); + const innerWidth = Math.max(0, root.clientWidth - sizing.paddingX); + const viewportHeight = scrollRoot.clientHeight; + setGeometry({ + gridColumns: getPreferredGridColumnCount(cellCount, innerWidth, sizing), + gridFillsViewport: chartArrayGridFitsViewport( + cellCount, + viewportHeight, + innerWidth, + sizing, + ), + }); + }; + + let setupAttempts = 0; + const setup = () => { + if (cancelled) return; + const root = layoutRootRef.current; + const scrollRoot = root?.parentElement; + if (!root || !scrollRoot) { + if (setupAttempts < 20) { + setupAttempts += 1; + requestAnimationFrame(setup); + } + return; + } + + resizeObserver?.disconnect(); + measure(); + resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(scrollRoot); + resizeObserver.observe(root); + }; + + setup(); + window.addEventListener("resize", measure); + + return () => { + cancelled = true; + resizeObserver?.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [layoutRootRef, cellCount, layoutMode]); + + if (layoutMode !== "grid") { + return DEFAULT_GEOMETRY; + } + return geometry; +} diff --git a/src/react/hooks/useChartArrayMetrics.ts b/src/react/hooks/useChartArrayMetrics.ts new file mode 100644 index 000000000..cbb70fe68 --- /dev/null +++ b/src/react/hooks/useChartArrayMetrics.ts @@ -0,0 +1,79 @@ +import { useLayoutEffect, useState, type RefObject } from "react"; +import type { ChartArrayLayoutHandle } from "../components/ChartArrayLayout"; +import { + measureCellRectsRelativeToRoot, + measureRootSize, + type ChartArrayRect, +} from "../components/chartArrayUtils"; + +export type ChartArrayMetrics = { + rootSize: ChartArrayRect; + cellBounds: ChartArrayRect[]; +}; + +const EMPTY_METRICS: ChartArrayMetrics = { + rootSize: { x: 0, y: 0, width: 0, height: 0 }, + cellBounds: [], +}; + +export function useChartArrayMetrics( + layoutRef: RefObject, + cellCount: number, +): ChartArrayMetrics { + const [metrics, setMetrics] = useState(EMPTY_METRICS); + + useLayoutEffect(() => { + if (cellCount === 0) { + setMetrics(EMPTY_METRICS); + return; + } + + let cancelled = false; + let resizeObserver: ResizeObserver | null = null; + + const measure = () => { + if (cancelled) return; + const root = layoutRef.current?.root; + if (!root) return; + const cells = layoutRef.current?.cells ?? []; + setMetrics({ + rootSize: measureRootSize(root), + cellBounds: measureCellRectsRelativeToRoot(root, cells.slice(0, cellCount)), + }); + }; + + let setupAttempts = 0; + const setup = () => { + if (cancelled) return; + const root = layoutRef.current?.root; + if (!root) { + if (setupAttempts < 20) { + setupAttempts += 1; + requestAnimationFrame(setup); + } + return; + } + + resizeObserver?.disconnect(); + measure(); + resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(root); + const cells = layoutRef.current?.cells ?? []; + for (let i = 0; i < cellCount; i++) { + const cell = cells[i]; + if (cell) resizeObserver.observe(cell); + } + }; + + setup(); + window.addEventListener("resize", measure); + + return () => { + cancelled = true; + resizeObserver?.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [layoutRef, cellCount]); + + return metrics; +} diff --git a/src/react/hooks/useChartArrayVisibleIndices.ts b/src/react/hooks/useChartArrayVisibleIndices.ts new file mode 100644 index 000000000..77e36adcc --- /dev/null +++ b/src/react/hooks/useChartArrayVisibleIndices.ts @@ -0,0 +1,90 @@ +import { useLayoutEffect, useState, type RefObject } from "react"; +import type { ChartArrayLayoutHandle } from "../components/ChartArrayLayout"; + +function allCellIndices(cellCount: number) { + return Array.from({ length: cellCount }, (_, index) => index); +} + +export function useChartArrayVisibleIndices( + scrollRootRef: RefObject, + layoutRef: RefObject, + cellCount: number, +): number[] { + const [visibleIndices, setVisibleIndices] = useState(() => + cellCount > 0 ? allCellIndices(cellCount) : [], + ); + + useLayoutEffect(() => { + if (cellCount === 0) { + setVisibleIndices([]); + return; + } + + let cancelled = false; + let observer: IntersectionObserver | null = null; + let setupAttempts = 0; + const intersectionByIndex = new Map(); + const observedCells = new WeakSet(); + + const syncVisible = () => { + const indices = [...intersectionByIndex.entries()] + .filter(([, isVisible]) => isVisible) + .map(([index]) => index) + .sort((a, b) => a - b); + setVisibleIndices(indices.length > 0 ? indices : allCellIndices(cellCount)); + }; + + const setup = () => { + if (cancelled) return; + const scrollRoot = scrollRootRef.current; + if (!scrollRoot) { + if (setupAttempts < 20) { + setupAttempts += 1; + requestAnimationFrame(setup); + } + return; + } + + if (!observer) { + observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const index = Number((entry.target as HTMLElement).dataset.chartArrayIndex); + if (!Number.isInteger(index) || index < 0 || index >= cellCount) continue; + intersectionByIndex.set(index, entry.isIntersecting); + } + syncVisible(); + }, + { root: scrollRoot, rootMargin: "64px" }, + ); + } + + const cells = layoutRef.current?.cells ?? []; + let observed = 0; + for (let i = 0; i < cellCount; i++) { + const cell = cells[i]; + if (cell) { + if (!observedCells.has(cell)) { + observer.observe(cell); + observedCells.add(cell); + } + observed += 1; + } + } + syncVisible(); + if (observed < cellCount && setupAttempts < 20) { + setupAttempts += 1; + requestAnimationFrame(setup); + } + }; + + setup(); + + return () => { + cancelled = true; + observer?.disconnect(); + }; + }, [scrollRootRef, layoutRef, cellCount]); + + return visibleIndices; +} diff --git a/src/react/hooks/useDeckSelectionMouseRebind.ts b/src/react/hooks/useDeckSelectionMouseRebind.ts new file mode 100644 index 000000000..c76d0c2c6 --- /dev/null +++ b/src/react/hooks/useDeckSelectionMouseRebind.ts @@ -0,0 +1,85 @@ +import { rebindMouseEvents } from "@/lib/deckMonkeypatch"; +import type { EditableGeoJsonLayer } from "@deck.gl-community/editable-layers"; +import type { Deck } from "@deck.gl/core"; +import { type RefObject, useEffect, useRef } from "react"; +import { useRange } from "../spatial_context"; + +type DeckSelectionMouseRebindOptions = { + /** When false, skip binding (e.g. overlay Deck unmounted while grid is shown). */ + enabled?: boolean; + /** Bust the effect when the Deck canvas remounts (overlay vs density grid). */ + canvasKey?: string; +}; + +type DeckRefTarget = { + deck?: Deck; +} | null; + +const REBIND_POLL_MS = 50; +const REBIND_POLL_MAX_MS = 3000; + +/** + * Rebind deck + editable-layer mouse handlers when the chart moves to a popout + * or when switching between single-view and density-grid Deck canvases. + * + * The selection layer instance is recreated when geojson data/mode props change; + * that must not trigger a rebind (it destroys EventManager mid-gesture). Keep the + * latest layer in a ref and only rebind for container/canvas/enabled changes. + */ +export function useDeckSelectionMouseRebind( + outerContainer: HTMLElement | undefined, + selectionLayer: EditableGeoJsonLayer | undefined, + deckRef: RefObject, + options?: DeckSelectionMouseRebindOptions, +) { + const enabled = options?.enabled !== false; + const canvasKey = options?.canvasKey ?? "default"; + const { editingGateId } = useRange(); + const selectionLayerRef = useRef(selectionLayer); + selectionLayerRef.current = selectionLayer; + + // biome-ignore lint/correctness/useExhaustiveDependencies: selectionLayer is recreated on every geojson edit; listing it rebinds mid-gesture (use selectionLayerRef). outerContainer/canvasKey bust the effect when the Deck host changes. + useEffect(() => { + if (!enabled) return; + if (editingGateId) return; + + let cancelled = false; + let cleanup: (() => void) | undefined; + + const tryBind = () => { + const deck = deckRef.current?.deck; + const layer = selectionLayerRef.current; + if (!deck || cancelled) return false; + try { + cleanup = rebindMouseEvents(deck, layer); + return true; + } catch (e) { + console.error( + "attempt to reset deck eventManager element failed - could be related to brittle deck monkeypatch", + e, + ); + return false; + } + }; + + if (tryBind()) { + return () => { + cancelled = true; + cleanup?.(); + }; + } + + const started = Date.now(); + const timer = window.setInterval(() => { + if (tryBind() || Date.now() - started >= REBIND_POLL_MAX_MS) { + window.clearInterval(timer); + } + }, REBIND_POLL_MS); + + return () => { + cancelled = true; + window.clearInterval(timer); + cleanup?.(); + }; + }, [outerContainer, deckRef, enabled, canvasKey, editingGateId]); +} diff --git a/src/react/hooks/useDensityGridCells.ts b/src/react/hooks/useDensityGridCells.ts new file mode 100644 index 000000000..d5736d458 --- /dev/null +++ b/src/react/hooks/useDensityGridCells.ts @@ -0,0 +1,193 @@ +import type { Matrix4 } from "@math.gl/core"; +import type { OrthographicViewState } from "@deck.gl/core"; +import { action } from "mobx"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { + isLoadedNumericContourField, + useFieldContour, + type DualContourLegacyConfig, +} from "../contour_state"; +import { useChart } from "../context"; +import { useChartSize, useConfig, useFieldSpecs, useFilteredIndices, useParamColumns } from "../hooks"; +import type { LoadedDataColumn, NumberDataType } from "@/charts/charts"; +import { useRange } from "../spatial_context"; +import type { ScatterPlotConfig2D } from "../scatter_state"; +import { + hasUsableOrthographicViewState, +} from "../components/chartArrayGridUtils"; +import type { ChartArrayCell } from "./useChartArrayGrid"; + +type DensityGridConfig = ScatterPlotConfig2D & DualContourLegacyConfig; + +const BBOX_PERCENTILE_LOW = 0.01; +const BBOX_PERCENTILE_HIGH = 0.99; + +function percentile(sorted: number[], p: number) { + if (sorted.length === 0) return Number.NaN; + const index = Math.min(sorted.length - 1, Math.max(0, Math.floor(p * (sorted.length - 1)))); + return sorted[index]; +} + +export function fitViewStateToFilteredRows( + rows: Uint32Array, + cx: { data: ArrayLike }, + cy: { data: ArrayLike }, + modelMatrix: Matrix4, + viewportWidth: number, + viewportHeight: number, +): Pick | null { + if (rows.length === 0 || viewportWidth <= 0 || viewportHeight <= 0) return null; + + const xs: number[] = []; + const ys: number[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const x = cx.data[row]; + const y = cy.data[row]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + xs.push(x); + ys.push(y); + } + if (xs.length === 0 || ys.length === 0) return null; + + xs.sort((a, b) => a - b); + ys.sort((a, b) => a - b); + let minX = percentile(xs, BBOX_PERCENTILE_LOW); + let maxX = percentile(xs, BBOX_PERCENTILE_HIGH); + let minY = percentile(ys, BBOX_PERCENTILE_LOW); + let maxY = percentile(ys, BBOX_PERCENTILE_HIGH); + if (!Number.isFinite(minX) || !Number.isFinite(maxX) || !Number.isFinite(minY) || !Number.isFinite(maxY)) { + return null; + } + + const minWorld = modelMatrix.transformAsPoint([minX, minY, 0]); + const maxWorld = modelMatrix.transformAsPoint([maxX, maxY, 0]); + minX = minWorld[0]; + minY = minWorld[1]; + maxX = maxWorld[0]; + maxY = maxWorld[1]; + + const dx = maxX - minX; + const dy = maxY - minY; + const epsilon = Math.max(1e-9, Math.min(Math.abs(minX), Math.abs(maxX)) * 1e-6); + const safeDx = Math.max(epsilon, dx); + const safeDy = Math.max(epsilon, dy); + let zoom = Math.log2(Math.min(viewportWidth / safeDx, viewportHeight / safeDy)) - 0.6; + if (!Number.isFinite(zoom)) zoom = 0; + + return { + target: [(minX + maxX) / 2, (minY + maxY) / 2, 0], + zoom, + }; +} + +export function useDensityGridCells() { + const config = useConfig(); + const loadedFields = useFieldSpecs(config.densityFields); + const densityFields = loadedFields.filter(isLoadedNumericContourField); + const configuredFieldCount = Array.isArray(config.densityFields) ? config.densityFields.length : 0; + + const cells: ChartArrayCell[] = useMemo( + () => + densityFields.map((field) => ({ + key: field.field, + label: field.name || field.field, + })), + [densityFields], + ); + + return { + cells, + densityFields, + configuredFieldCount, + }; +} + +export function useDensityGridContours( + densityFields: LoadedDataColumn[], + visibleFieldIndices: readonly number[], +) { + const config = useConfig(); + const [cx, cy] = useParamColumns(); + const coordKey = `${cx.field}--${cy.field}`.replace(/[^A-Za-z0-9_-]/g, "_"); + + return useFieldContour({ + id: `density-grid-${coordKey}`, + fields: densityFields, + visibleFieldIndices, + fill: config.contour_fill, + bandwidth: config.contour_bandwidth ?? 0.1, + intensity: config.contour_intensity ?? 0.1, + opacity: config.contour_opacity ?? 0.2, + fillThreshold: config.contour_fillThreshold ?? 2, + }); +} + +export function useDensityGridViewState( + referenceCellWidth: number, + referenceCellHeight: number, +) { + const chart = useChart() as { pendingRecenter?: boolean }; + const config = useConfig(); + const [chartWidth, chartHeight] = useChartSize(); + const rows = useFilteredIndices(); + const [cx, cy] = useParamColumns(); + const { modelMatrix } = useRange(); + const lastParamKeyRef = useRef(""); + const lastFilteredRowCountRef = useRef(-1); + const paramKey = `${cx.field}\u0000${cy.field}`; + + useLayoutEffect(() => { + const paramChanged = lastParamKeyRef.current !== "" && lastParamKeyRef.current !== paramKey; + lastParamKeyRef.current = paramKey; + + const filterChanged = lastFilteredRowCountRef.current !== rows.length; + lastFilteredRowCountRef.current = rows.length; + + const viewportWidth = referenceCellWidth > 0 ? referenceCellWidth : chartWidth; + const viewportHeight = referenceCellHeight > 0 ? referenceCellHeight : chartHeight; + const pendingRecenter = !!chart.pendingRecenter; + const needsFullFit = + pendingRecenter || + paramChanged || + !hasUsableOrthographicViewState(config.viewState) || + (config.zoom_on_filter && filterChanged); + + if (needsFullFit && viewportWidth > 0 && viewportHeight > 0) { + const fitted = fitViewStateToFilteredRows( + rows, + cx, + cy, + modelMatrix, + viewportWidth, + viewportHeight, + ); + if (!fitted) return; + action(() => { + chart.pendingRecenter = false; + config.viewState = { + ...config.viewState, + target: fitted.target, + zoom: fitted.zoom, + }; + })(); + } + }, [ + paramKey, + rows.length, + cx, + cy, + modelMatrix, + referenceCellWidth, + referenceCellHeight, + chartWidth, + chartHeight, + config, + chart, + ]); + + return useMemo( + () => Math.max(1, 30 * (config.contour_bandwidth ?? 0.1) * 2 ** Number(config.viewState.zoom ?? 0)), + [config.contour_bandwidth, config.viewState.zoom], + ); +} diff --git a/src/react/hooks/useGateLayers.ts b/src/react/hooks/useGateLayers.ts index 9e443d3e7..1105cb886 100644 --- a/src/react/hooks/useGateLayers.ts +++ b/src/react/hooks/useGateLayers.ts @@ -4,11 +4,13 @@ import { useChartID, useConfig, useParamColumns } from "../hooks"; import type { LoadedDataColumn } from "@/charts/charts"; import { computeCentroid, DEFAULT_GATE_COLOR, getRelevantGates } from "../gates/gateUtils"; import type { DeckScatterConfigWithRegion } from "../components/DeckScatterReactWrapper"; +import type { DualContourLegacyConfig } from "../contour_state"; import { GeoJsonLayer, TextLayer } from "deck.gl"; import { getVivId } from "../components/avivatorish/MDVivViewer"; import { useSpatialLayers } from "../spatial_context"; import useGateActions from "./useGateActions"; import { truncateWithEllipsis } from "@/utilities/Utilities"; +import { tagDeckLayerViewportScope } from "../components/deckLayerViewportScope"; const MAX_LABEL_LENGTH = 18; @@ -18,6 +20,8 @@ const useGateLayers = () => { const [cx, cy] = useParamColumns() as LoadedDataColumn<"double">[]; const cz = useParamColumns()[2] as LoadedDataColumn<"double">; const config = useConfig(); + const contourConfig = useConfig(); + const densityMode = contourConfig.density_mode ?? "overlay"; const { selectionProps } = useSpatialLayers(); const { onEditGate } = useGateActions(); const { dimension } = config; @@ -76,7 +80,7 @@ const useGateLayers = () => { }; }); - return new TextLayer({ + const layer = new TextLayer({ id: `text-layer-${getVivId(`${chartId}detail-react`)}`, data: layerData, getPosition: (d: { position: [number, number, number] }) => d.position, @@ -150,6 +154,7 @@ const useGateLayers = () => { setDragPos(null); }, }); + return tagDeckLayerViewportScope(layer, "chart-shared"); }, [ cx, cy, @@ -164,6 +169,7 @@ const useGateLayers = () => { relevantGates, onEditGate, selectionFeatureCollection, + densityMode, ]); const gateDisplayLayer = useMemo(() => { @@ -187,7 +193,7 @@ const useGateLayers = () => { })), ); - return new GeoJsonLayer({ + const layer = new GeoJsonLayer({ id: `gate_${getVivId(`${chartId}detail-react`)}`, data: { type: "FeatureCollection", @@ -206,10 +212,11 @@ const useGateLayers = () => { lineWidthMinPixels: 2.5, pickable: true, }); - }, [relevantGates, chartId, editingGateId, gateManager, cx, cy]); + return tagDeckLayerViewportScope(layer, "chart-shared"); + }, [relevantGates, chartId, editingGateId, gateManager, cx, cy, densityMode]); - // To disable drag pan for the deck controller when label is being dragged or hovered - const dragPan = !(draggingId || isHoveringLabel); + // Disable deck pan while dragging labels or editing gate geometry (editable layer owns drags). + const dragPan = !(draggingId || isHoveringLabel || editingGateId); return { gateLabelLayer, diff --git a/src/react/hooks/useVivDensityGridViewState.ts b/src/react/hooks/useVivDensityGridViewState.ts new file mode 100644 index 000000000..0e70aded6 --- /dev/null +++ b/src/react/hooks/useVivDensityGridViewState.ts @@ -0,0 +1,87 @@ +import { action } from "mobx"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { useChart } from "../context"; +import { useChartSize, useConfig, useFilteredIndices, useParamColumns } from "../hooks"; +import type { DualContourLegacyConfig } from "../contour_state"; +import type { ScatterPlotConfig2D } from "../scatter_state"; +import { useRange } from "../spatial_context"; +import { useViewerStore, useViewerStoreApi } from "../components/avivatorish/state"; +import { fitViewStateToFilteredRows } from "./useDensityGridCells"; +import { hasUsableOrthographicViewState } from "../components/chartArrayGridUtils"; + +type VivDensityGridConfig = ScatterPlotConfig2D & DualContourLegacyConfig; + +export function useVivDensityGridViewState( + referenceCellWidth: number, + referenceCellHeight: number, +) { + const chart = useChart() as { pendingRecenter?: boolean }; + const config = useConfig(); + const [chartWidth, chartHeight] = useChartSize(); + const rows = useFilteredIndices(); + const [cx, cy] = useParamColumns(); + const { modelMatrix } = useRange(); + const viewerStore = useViewerStoreApi(); + const viewState = useViewerStore((store) => store.viewState); + const lastParamKeyRef = useRef(""); + const lastFilteredRowCountRef = useRef(-1); + const paramKey = `${cx.field}\u0000${cy.field}`; + + useLayoutEffect(() => { + const paramChanged = lastParamKeyRef.current !== "" && lastParamKeyRef.current !== paramKey; + lastParamKeyRef.current = paramKey; + + const filterChanged = lastFilteredRowCountRef.current !== rows.length; + lastFilteredRowCountRef.current = rows.length; + + const viewportWidth = referenceCellWidth > 0 ? referenceCellWidth : chartWidth; + const viewportHeight = referenceCellHeight > 0 ? referenceCellHeight : chartHeight; + const pendingRecenter = !!chart.pendingRecenter; + const needsFullFit = + pendingRecenter || + paramChanged || + !hasUsableOrthographicViewState(viewState) || + (config.zoom_on_filter && filterChanged); + + if (needsFullFit && viewportWidth > 0 && viewportHeight > 0) { + const fitted = fitViewStateToFilteredRows( + rows, + cx, + cy, + modelMatrix, + viewportWidth, + viewportHeight, + ); + if (!fitted) return; + action(() => { + chart.pendingRecenter = false; + viewerStore.setState({ + viewState: { + ...viewState, + target: fitted.target, + zoom: fitted.zoom, + }, + }); + })(); + } + }, [ + paramKey, + rows.length, + cx, + cy, + modelMatrix, + referenceCellWidth, + referenceCellHeight, + chartWidth, + chartHeight, + config, + chart, + viewState, + viewerStore, + ]); + + return useMemo( + () => Math.max(1, 30 * (config.contour_bandwidth ?? 0.1) * 2 ** Number(viewState?.zoom ?? 0)), + [config.contour_bandwidth, viewState?.zoom], + ); +} diff --git a/src/react/scatter_state.ts b/src/react/scatter_state.ts index 071a30045..3585c9a5f 100644 --- a/src/react/scatter_state.ts +++ b/src/react/scatter_state.ts @@ -118,6 +118,7 @@ export const scatterDefaults: Omit>subject to change<<. @@ -186,14 +187,22 @@ function useCreateRange(chart: BaseChart) { } }, [coords, filterPoly, removeFilter, chart, gateManager, editingGateId]); const [selectedFeatureIndexes, setSelectedFeatureIndexes] = useState([]); - + + useEffect(() => { + if (editingGateId && selectionFeatureCollection.features.length > 0) { + setSelectedFeatureIndexes([0]); + } else if (!editingGateId) { + setSelectedFeatureIndexes([]); + } + }, [editingGateId, selectionFeatureCollection.features.length]); + // we might be able to pass this to modeConfig, if it knows what to do with it? // const outerContainer = useOuterContainer(); // todo: we need to do something about editing gate color //! When the dev tools are open, there is lagging while dragging this layer const editableLayer = useMemo(() => { - return new MonkeyPatchEditableGeoJsonLayer({ + const layer = new MonkeyPatchEditableGeoJsonLayer({ id: `selection_${getVivId(`${id}detail-react`)}`, data: selectionFeatureCollection as any, mode: selectionMode, @@ -212,7 +221,6 @@ function useCreateRange(chart: BaseChart) { selectedFeatureIndexes, // adding `action` here gets rid of warnings but doesn't help with performance. onEdit: action(({ updatedData }) => { - // console.log("onEdit", editType, updatedData); const feature = updatedData.features.at(-1); // updatedData.features = [feature]; setSelectionFeatureCollection({ @@ -232,9 +240,16 @@ function useCreateRange(chart: BaseChart) { // it looks as though editable-layer.js _addEventHandlers() isn't called again when eventManager has been changed. // outerContainer } - }) - }, [selectionFeatureCollection, selectionMode, id, selectedFeatureIndexes, - setSelectionFeatureCollection, getFillColor, getLineColor, + }); + return tagDeckLayerViewportScope(layer, "chart-shared"); + }, [ + selectionFeatureCollection, + selectionMode, + id, + selectedFeatureIndexes, + setSelectionFeatureCollection, + getFillColor, + getLineColor, ]); return { diff --git a/src/tests/chartConfigSchema.spec.ts b/src/tests/chartConfigSchema.spec.ts index d9ac07186..fc5fab495 100644 --- a/src/tests/chartConfigSchema.spec.ts +++ b/src/tests/chartConfigSchema.spec.ts @@ -60,6 +60,7 @@ describe("chart config schema registration", () => { contourParameter: "cluster", category1: ["A"], densityFields: ["marker_a", "marker_b"], + density_mode: "grid", field_legend: { display: true, }, @@ -79,6 +80,7 @@ describe("chart config schema registration", () => { type: "DeckContourScatter", contourParameter: "cluster", densityFields: ["marker_a", "marker_b"], + density_mode: "grid", }); }); diff --git a/src/types/external-modules.d.ts b/src/types/external-modules.d.ts index 97e164146..8fbcc9c40 100644 --- a/src/types/external-modules.d.ts +++ b/src/types/external-modules.d.ts @@ -13,6 +13,8 @@ */ declare module "pako"; +declare module "*.css"; + // Minimal surface used by this repo; intentionally not a full Turf typing model. declare module "@turf/helpers" { export type Position = number[]; diff --git a/src/webgl/SpatialLayer.ts b/src/webgl/SpatialLayer.ts index 7e45133c0..4f487aee4 100644 --- a/src/webgl/SpatialLayer.ts +++ b/src/webgl/SpatialLayer.ts @@ -18,6 +18,11 @@ type DensityLayerProps = ScatterplotLayerProps & { // are framebuffers appropriate for use as props? framebuffer: Framebuffer; }; +type HeatmapContourLayerProps = NonNullable & { + extensions?: unknown; + viewId?: string; +}; + class DensityLayer extends Layer { layerName = "DensityLayer"; static defaultProps = {}; @@ -48,6 +53,24 @@ class DensityLayer extends Layer { } } +export function createHeatmapContourLayer(props: HeatmapContourLayerProps) { + const { extensions, ...p } = props; + const heatmapProps: any = { + ...p, + _subLayerProps: { + triangle: { + type: TriangleLayerContours, + }, + "triangle-layer": { + contourOpacity: p.contourOpacity, + contourFill: p.contourFill, + fillOpacity: p.fillOpacity, + }, + }, + }; + return new HeatmapLayer(heatmapProps); +} + export default class SpatialLayer extends CompositeLayer { static layerName = "SpatialLayer"; static defaultProps = ScatterplotLayer.defaultProps; @@ -101,22 +124,9 @@ export default class SpatialLayer extends CompositeLayer { // ...p, // }); // }); - const densityLayers = contourLayers.filter((l) => l).map(props => { - const { extensions, ...p } = this.getSubLayerProps(props); - return new HeatmapLayer({ - ...p, - _subLayerProps: { - triangle: { - type: TriangleLayerContours, - }, - "triangle-layer": { - contourOpacity: p.contourOpacity, - contourFill: p.contourFill, - fillOpacity: p.fillOpacity, - }, - }, - }); - }); + const densityLayers = contourLayers.filter((l) => l).map(props => + createHeatmapContourLayer(this.getSubLayerProps(props)), + ); return [ // add 'grey-out' layer here... that implies a different type of data being passed. // it might want to know about background_filter... diff --git a/tests_playwright/project/charts/density_grid_selection.spec.ts b/tests_playwright/project/charts/density_grid_selection.spec.ts new file mode 100644 index 000000000..9be56b208 --- /dev/null +++ b/tests_playwright/project/charts/density_grid_selection.spec.ts @@ -0,0 +1,66 @@ +import test, { expect } from "@playwright/test"; +import { createTemporaryProjectViaSyntheticAnndata } from "../../utils/projectFixtures"; +import { + addChartViaUi, + expectChartPanelHasNoError, + getNumericColumnNamesForChart, + getSelectionToolbar, + patchScatterChartConfig, + waitForChartByTitle, +} from "../../utils/helpers"; + +test.describe("Density grid selection overlay", () => { + test.setTimeout(180_000); + + test("keeps shape-drawing tools in grid mode and restores them in overlay mode", async ({ page }) => { + const projectHandle = await createTemporaryProjectViaSyntheticAnndata(page, { + synthetic: { + profile: "minimal", + nCells: 200, + nGenes: 12, + force: true, + }, + }); + + try { + const title = `2D Scatter Density Grid ${Date.now()}`; + await addChartViaUi(page, "2D Scatter Plot", title); + await waitForChartByTitle(page, title); + await expectChartPanelHasNoError(page, title); + + const densityFields = await getNumericColumnNamesForChart(page, title, 2); + expect(densityFields.length).toBeGreaterThan(0); + + await patchScatterChartConfig(page, title, { + type: "DeckContourScatter", + densityFields, + density_mode: "overlay", + }); + + const toolbar = getSelectionToolbar(page, title); + await expect(toolbar.getByRole("button", { name: "Rectangle", exact: true })).toBeVisible(); + await expect(toolbar.getByRole("button", { name: "Pan", exact: true })).toBeVisible(); + await expect(toolbar.getByRole("button", { name: "Density grid view", exact: true })).toBeVisible(); + + await toolbar.getByRole("button", { name: "Density grid view", exact: true }).click(); + + await expect(toolbar.getByRole("button", { name: "Rectangle", exact: true })).toBeVisible(); + await expect(toolbar.getByRole("button", { name: "Polygon", exact: true })).toBeVisible(); + await expect(toolbar.getByRole("button", { name: "Freehand", exact: true })).toBeVisible(); + await expect(toolbar.getByRole("button", { name: "Modify", exact: true })).toBeVisible(); + await expect(toolbar.getByRole("button", { name: "Pan", exact: true })).toBeVisible(); + await expect(toolbar.getByRole("button", { name: "Single scatter view", exact: true })).toBeVisible(); + await expect(toolbar.getByRole("button", { name: "Manage Gates", exact: true })).toBeVisible(); + + const panel = page.locator(".ciview-chart-panel").filter({ hasText: title }).first(); + await expect(panel.getByText("Choose density fields to build the grid.")).toHaveCount(0); + await expect(panel.locator(".deckgl-overlay").first()).toBeVisible({ timeout: 30_000 }); + + await toolbar.getByRole("button", { name: "Single scatter view", exact: true }).click(); + await expect(toolbar.getByRole("button", { name: "Rectangle", exact: true })).toBeVisible(); + await expect(toolbar.getByRole("button", { name: "Manage Gates", exact: true })).toBeVisible(); + } finally { + await projectHandle.cleanup(); + } + }); +}); diff --git a/tests_playwright/utils/helpers.ts b/tests_playwright/utils/helpers.ts index 024c5ac69..e777fdf5a 100644 --- a/tests_playwright/utils/helpers.ts +++ b/tests_playwright/utils/helpers.ts @@ -223,6 +223,68 @@ export async function selectViewViaUi(page: Page, viewName: string) { await expect.poll(async () => await getCurrentView(page)).toBe(viewName); } +export type ScatterDensityMode = "grid" | "overlay"; + +export async function patchScatterChartConfig( + page: Page, + title: string, + patch: { density_mode?: ScatterDensityMode; densityFields?: string[]; type?: string }, +) { + await page.evaluate( + ({ chartTitle, configPatch }) => { + const chartEntries = Object.values((window as any).mdv.chartManager.charts) as Array<{ + chart?: { config?: Record }; + }>; + const entry = chartEntries.find((item) => item.chart?.config?.title === chartTitle); + const config = entry?.chart?.config; + if (!config) { + throw new Error(`Chart not found: ${chartTitle}`); + } + if (configPatch.type) { + config.type = configPatch.type; + } + if (configPatch.density_mode) { + config.density_mode = configPatch.density_mode; + if (configPatch.density_mode === "grid") { + config.contour_fill = true; + } + } + if (configPatch.densityFields) { + config.densityFields = configPatch.densityFields; + } + }, + { chartTitle: title, configPatch: patch }, + ); +} + +export async function getNumericColumnNamesForChart(page: Page, title: string, limit = 2): Promise { + return await page.evaluate( + ({ chartTitle, maxCount }) => { + const chartEntries = Object.values((window as any).mdv.chartManager.charts) as Array<{ + chart?: { + config?: { title?: string }; + dataStore?: { columnIndex?: Record }; + }; + }>; + const entry = chartEntries.find((item) => item.chart?.config?.title === chartTitle); + const columnIndex = entry?.chart?.dataStore?.columnIndex; + if (!columnIndex) { + return []; + } + return Object.entries(columnIndex) + .filter(([, column]) => column.datatype === "double" || column.datatype === "integer") + .map(([name]) => name) + .slice(0, maxCount); + }, + { chartTitle: title, maxCount: limit }, + ); +} + +export function getSelectionToolbar(page: Page, chartTitle: string) { + const panel = getChartPanelByTitle(page, chartTitle); + return panel.getByRole("group", { name: "choose tool for manipulating view or selection" }); +} + export type RowsAsColumnsSubgroupInfo = { key: string; label: string;