diff --git a/v3/src/components/data-display/d3-types.ts b/v3/src/components/data-display/d3-types.ts index c815f9011f..8c1499f291 100644 --- a/v3/src/components/data-display/d3-types.ts +++ b/v3/src/components/data-display/d3-types.ts @@ -1,2 +1,5 @@ // The data stored with each plot element (e.g. 'circle') export type CaseData = { plotNum: number, caseID: string } + +// The data stored with each plot element (e.g. 'circle') when subplots are used +export type CaseDataWithSubPlot = { plotNum: number; caseID: string; subPlotNum?: number } diff --git a/v3/src/components/data-display/data-display-utils.ts b/v3/src/components/data-display/data-display-utils.ts index 41368328d7..e02eb8b000 100644 --- a/v3/src/components/data-display/data-display-utils.ts +++ b/v3/src/components/data-display/data-display-utils.ts @@ -7,6 +7,7 @@ import { } from "../../utilities/color-utils" import {between} from "../../utilities/math-utils" import { IBarCover } from "../graph/graphing-types" +import {isGraphDataConfigurationModel} from "../graph/models/graph-data-configuration-model" import {ISetPointSelection} from "../graph/utilities/graph-utils" import { hoverRadiusFactor, kDataDisplayFont, Point, PointDisplayType, pointRadiusLogBase, pointRadiusMax, pointRadiusMin, @@ -14,6 +15,7 @@ import { } from "./data-display-types" import {IDataConfigurationModel } from "./models/data-configuration-model" import {IPixiPointStyle, PixiPoints} from "./pixi/pixi-points" +import {CaseDataWithSubPlot} from "./d3-types" export const maxWidthOfStringsD3 = (strings: Iterable) => { let maxWidth = 0 @@ -85,7 +87,9 @@ export interface IMatchCirclesProps { export function matchCirclesToData(props: IMatchCirclesProps) { const { dataConfiguration, pixiPoints, startAnimation, pointRadius, pointColor, pointStrokeColor, pointDisplayType = "points" } = props - const allCaseData = dataConfiguration.joinedCaseDataArrays + const allCaseData: CaseDataWithSubPlot[] = isGraphDataConfigurationModel(dataConfiguration) + ? dataConfiguration.caseDataWithSubPlot + : dataConfiguration.joinedCaseDataArrays startAnimation() @@ -212,4 +216,3 @@ export function rectToTreeRect(rect: Rect) { h: rect.height } } - diff --git a/v3/src/components/data-display/hooks/use-connecting-lines.ts b/v3/src/components/data-display/hooks/use-connecting-lines.ts index 8b0de48125..556afc2f5e 100644 --- a/v3/src/components/data-display/hooks/use-connecting-lines.ts +++ b/v3/src/components/data-display/hooks/use-connecting-lines.ts @@ -73,7 +73,7 @@ export const useConnectingLines = (props: IProps) => { const handleConnectingLinesMouseOver = useCallback((mouseOverProps: IMouseOverProps) => { const { caseIDs, event, parentAttrName, primaryAttrValue } = mouseOverProps - if (pixiPoints) pixiPoints.canvas.style.cursor = "pointer" + if (pixiPoints?.canvas) pixiPoints.canvas.style.cursor = "pointer" // TODO: In V2, the tool tip is only shown when there is a parent attribute. V3 should always show the tool tip, // but the text needs to be different when there is no parent attribute. We'll need to work out how to handle the // localization for this. When a parent attribute is present, the tool tip should look like: @@ -90,7 +90,7 @@ export const useConnectingLines = (props: IProps) => { }, [dataTip, dataset?.name, pixiPoints]) const handleConnectingLinesMouseOut = useCallback(() => { - if (pixiPoints) pixiPoints.canvas.style.cursor = "" + if (pixiPoints?.canvas) pixiPoints.canvas.style.cursor = "" dataTip.hide() }, [dataTip, pixiPoints]) diff --git a/v3/src/components/data-display/pixi/pixi-points.ts b/v3/src/components/data-display/pixi/pixi-points.ts index 4d941a9e42..80c560248b 100644 --- a/v3/src/components/data-display/pixi/pixi-points.ts +++ b/v3/src/components/data-display/pixi/pixi-points.ts @@ -1,5 +1,5 @@ import * as PIXI from "pixi.js" -import { CaseData } from "../d3-types" +import { CaseData, CaseDataWithSubPlot } from "../d3-types" import { PixiTransition, TransitionPropMap, TransitionProp } from "./pixi-transition" import { hoverRadiusFactor, transitionDuration } from "../data-display-types" import { isFiniteNumber } from "../../../utilities/math-utils" @@ -76,6 +76,7 @@ export class PixiPoints { stage = new PIXI.Container() pointsContainer = new PIXI.Container() background = new PIXI.Sprite(PIXI.Texture.EMPTY) + subPlotMasks: PIXI.Graphics[] = [] ticker = new PIXI.Ticker() tickerStopTimeoutId: number | undefined @@ -109,21 +110,26 @@ export class PixiPoints { // The function will prioritize the WebGL renderer as it is the most tested safe API to use. In the near future as // WebGPU becomes more stable and ubiquitous, it will be prioritized over WebGL. // See: https://pixijs.download/release/docs/rendering.html#autoDetectRenderer - this.renderer = await PIXI.autoDetectRenderer({ - resolution: window.devicePixelRatio, - autoDensity: true, - backgroundAlpha: 0, - antialias: true, - // `passive` is more performant and will be used by default in the future Pixi.JS versions - eventMode: "passive", - eventFeatures: { - move: true, - click: true, - // disables the global move events which can be very expensive in large scenes - globalMove: false, - wheel: false - } - }) + try { + this.renderer = await PIXI.autoDetectRenderer({ + resolution: window.devicePixelRatio, + autoDensity: true, + backgroundAlpha: 0, + antialias: true, + // `passive` is more performant and will be used by default in the future Pixi.JS versions + eventMode: "passive", + eventFeatures: { + move: true, + click: true, + // disables the global move events which can be very expensive in large scenes + globalMove: false, + wheel: false + } + }) + } catch (e) { + console.error("PixiPoints failed to initialize renderer") + return + } this.ticker.add(this.tick.bind(this)) this.stage.addChild(this.background) @@ -146,8 +152,8 @@ export class PixiPoints { } } - get canvas() { - return this.renderer?.view.canvas as HTMLCanvasElement + get canvas(): HTMLCanvasElement | null { + return this.renderer?.view.canvas as HTMLCanvasElement ?? null } get points() { @@ -172,7 +178,7 @@ export class PixiPoints { this.renderer?.render(this.stage) } - resize(width: number, height: number) { + resize(width: number, height: number, numColumns?: number, numRows?: number) { // We only set the background size if the width and height are valid. If we ever set width/height of background to // negative values, the background won't be able to detect pointer events. if (width > 0 && height > 0) { @@ -181,6 +187,24 @@ export class PixiPoints { this.background.height = height this.startRendering() } + + if (numColumns !== undefined && numRows !== undefined) { + this.subPlotMasks = [] + const maskWidth = width / numColumns + const maskHeight = height / numRows + // These two for loops follow order of the subPlots ordering. Subplots seem to be ordered by columns first (left + // to right), then rows (bottom to top). + for (let c = 0; c < numColumns; c++) { + for (let r = numRows - 1; r >= 0; r--) { + const mask = new PIXI.Graphics() + .rect(c * maskWidth, r * maskHeight, maskWidth, maskHeight) + .fill(0xffffff) + this.subPlotMasks.push(mask) + } + } + } else { + this.subPlotMasks = [] + } } setVisibility(isVisible: boolean) { @@ -365,6 +389,11 @@ export class PixiPoints { this.startRendering() } + setPointSubPlot(point: PIXI.Sprite, subPlotIndex: number) { + point.mask = this.subPlotMasks[subPlotIndex] + this.startRendering() + } + transition(callback: () => void, options: { duration: number }) { const { duration } = options if (duration === 0) { @@ -638,7 +667,10 @@ export class PixiPoints { }) } - matchPointsToData(datasetID:string, caseData: CaseData[], _displayType: string, style: IPixiPointStyle) { + matchPointsToData(datasetID:string, caseData: CaseDataWithSubPlot[], _displayType: string, style: IPixiPointStyle) { + if (!this.renderer) { + return + } // If the display type has changed, we need to prepare for the transition between types // For now, the only display type values PixiPoints supports are "points" and "bars", so // all other display type values passed to this method will be treated as "points". @@ -702,6 +734,8 @@ export class PixiPoints { } } + this.setPointsMask(caseData) + // Before rendering, reset the scale for all points. This may be necessary if the scale was modified // during a transition immediately before matchPointsToData is called. For example, when the Connecting // Lines graph adornment is activated or deactivated. @@ -710,6 +744,23 @@ export class PixiPoints { this.startRendering() } + setPointsMask(allCaseData: CaseDataWithSubPlot[]) { + if (!this.renderer || (window as any).Cypress) { + // This method causes Cypress tests to fail in the GitHub Actions environment, so we skip it in that case. + // The exact reason is unclear, but it seems likely that the WebGL (or WebGPU) renderer initialized in GitHub + // Actions is somehow faulty or incomplete, and using masking features causes it to break entirely. This isn't + // a feature that can be tested using Cypress anyway, so it's safe to skip it in this case. + return + } + allCaseData.forEach((caseData, i) => { + const point = this.getPointForCaseData(caseData) + if (point) { + const subPlotNum = caseData.subPlotNum + point.mask = subPlotNum !== undefined ? this.subPlotMasks[subPlotNum] : null + } + }) + } + dispose() { this.ticker.destroy() this.renderer?.destroy() diff --git a/v3/src/components/graph/components/graph.tsx b/v3/src/components/graph/components/graph.tsx index b48e39d52a..a7dc00d27b 100644 --- a/v3/src/components/graph/components/graph.tsx +++ b/v3/src/components/graph/components/graph.tsx @@ -69,7 +69,7 @@ export const Graph = observer(function Graph({graphController, graphRef, pixiPoi xAttrID = graphModel.getAttributeID('x'), yAttrID = graphModel.getAttributeID('y') - if (pixiPoints && pixiContainerRef.current && pixiContainerRef.current.children.length === 0) { + if (pixiPoints?.canvas && pixiContainerRef.current && pixiContainerRef.current.children.length === 0) { pixiContainerRef.current.appendChild(pixiPoints.canvas) pixiPoints.setupBackgroundEventDistribution({ elementToHide: pixiContainerRef.current @@ -110,9 +110,19 @@ export const Graph = observer(function Graph({graphController, graphRef, pixiPoi .attr("width", `${Math.max(0, layout.plotWidth)}px`) .attr("height", `${Math.max(0, layout.plotHeight)}px`) - pixiPoints?.resize(layout.plotWidth, layout.plotHeight) + pixiPoints?.resize(layout.plotWidth, layout.plotHeight, layout.numColumns, layout.numRows) + pixiPoints?.setPointsMask(graphModel.dataConfiguration.caseDataWithSubPlot) } - }, [dataset, layout, layout.plotHeight, layout.plotWidth, pixiPoints, xScale]) + }, [dataset, graphModel.dataConfiguration, layout, layout.plotHeight, layout.plotWidth, pixiPoints, xScale]) + + useEffect(function handleSubPlotsUpdate() { + return mstReaction( + () => graphModel.dataConfiguration.caseDataWithSubPlot, + () => { + pixiPoints?.setPointsMask(graphModel.dataConfiguration.caseDataWithSubPlot) + }, {name: "Graph.handleSubPlotsUpdate"}, graphModel + ) + }, [graphModel, graphModel.dataConfiguration, pixiPoints]) useEffect(function handleAttributeConfigurationChange() { // Handles attribute configuration changes from undo/redo, for instance, among others. diff --git a/v3/src/components/graph/models/graph-data-configuration-model.ts b/v3/src/components/graph/models/graph-data-configuration-model.ts index f66daaf04f..a5390db2c9 100644 --- a/v3/src/components/graph/models/graph-data-configuration-model.ts +++ b/v3/src/components/graph/models/graph-data-configuration-model.ts @@ -11,7 +11,7 @@ import {AttributeDescription, DataConfigurationModel, IAttributeDescriptionSnaps import {AttrRole, GraphAttrRole, graphPlaceToAttrRole, PrimaryAttrRoles} from "../../data-display/data-display-types" import {updateCellKey} from "../adornments/adornment-utils" import { isFiniteNumber } from "../../../utilities/math-utils" -import { CaseData } from "../../data-display/d3-types" +import { CaseData, CaseDataWithSubPlot } from "../../data-display/d3-types" export const kGraphDataConfigurationType = "graphDataConfigurationType" @@ -512,6 +512,21 @@ export const GraphDataConfigurationModel = DataConfigurationModel } }) })) + .views(self => ({ + get caseDataWithSubPlot() { + const allCaseData: CaseDataWithSubPlot[] = self.joinedCaseDataArrays + const caseIDToSubPlot: Record = {} + self.getAllCellKeys().forEach((cellKey, cellIndex) => { + self.subPlotCases(cellKey).forEach(caseID => { + caseIDToSubPlot[caseID] = cellIndex + }) + }) + allCaseData.forEach((caseData) => { + caseData.subPlotNum = caseIDToSubPlot[caseData.caseID] + }) + return allCaseData + } + })) .views(self => ({ casesInRange(min: number, max: number, attrId: string, cellKey: Record, inclusiveMax = true) { return self.subPlotCases(cellKey)?.filter(caseId => { diff --git a/v3/src/components/map/components/map-point-layer.tsx b/v3/src/components/map/components/map-point-layer.tsx index c7a79dde21..7d2eeb1f0c 100644 --- a/v3/src/components/map/components/map-point-layer.tsx +++ b/v3/src/components/map/components/map-point-layer.tsx @@ -146,7 +146,7 @@ export const MapPointLayer = observer(function MapPointLayer({mapLayerModel, set }, [dataConfiguration.dataset, mapModel, pixiPoints]) useEffect(() => { - if (pixiPoints != null && pixiContainerRef.current && pixiContainerRef.current.children.length === 0) { + if (pixiPoints?.canvas && pixiContainerRef.current && pixiContainerRef.current.children.length === 0) { pixiContainerRef.current.appendChild(pixiPoints.canvas) pixiPoints.resize(layout.contentWidth, layout.contentHeight) }