Skip to content

Commit

Permalink
feat: Graph histogram (PT-186948982) (#1286)
Browse files Browse the repository at this point in the history
  • Loading branch information
emcelroy authored Jun 5, 2024
1 parent ca44c2c commit 9ce3341
Show file tree
Hide file tree
Showing 20 changed files with 671 additions and 343 deletions.
18 changes: 18 additions & 0 deletions v3/cypress/e2e/graph.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,4 +581,22 @@ context("Graph UI", () => {
cy.get("[data-testid=bar-cover]").should("not.exist")
})
})
it("shows a histogram when 'Group into Bins' and 'Fuse Dots into Bars' are both checked", () => {
cy.dragAttributeToTarget("table", "Height", "bottom")
cy.get("[data-testid=bar-cover]").should("not.exist")
graph.getDisplayConfigButton().click()
cy.get("[data-testid=bins-radio-button]").click()
cy.get("[data-testid=bar-chart-checkbox]").click()
cy.get("[data-testid=bar-cover]").should("exist").and("have.length", 7)
cy.get("[data-testid=graph-bin-width-setting]").find("input").clear().type("2").type("{enter}")
cy.wait(500)
cy.get("[data-testid=bar-cover]").should("exist").and("have.length", 4)
cy.wait(500)
cy.dragAttributeToTarget("table", "Speed", "bottom")
cy.wait(500)
cy.get("[data-testid=bar-cover]").should("exist").and("have.length", 6)
graph.getDisplayConfigButton().click()
cy.get("[data-testid=bar-chart-checkbox]").click()
cy.get("[data-testid=bar-cover]").should("not.exist")
})
})
4 changes: 1 addition & 3 deletions v3/src/components/axis/hooks/use-sub-axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,7 @@ export const useSubAxis = ({
const formatter = (value: number) => multiScale.formatValueForScale(value)
const { tickValues, tickLabels } = displayModel.nonDraggableAxisTicks(formatter)
axisScale.tickValues(tickValues)
axisScale.tickFormat((d, i) => {
return tickLabels[i]
})
axisScale.tickFormat((d, i) => tickLabels[i])
}
select(subAxisElt)
.attr("transform", initialTransform)
Expand Down
2 changes: 1 addition & 1 deletion v3/src/components/data-display/data-display-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type Point = { x: number, y: number }
export type CPLine = { slope: number, intercept: number, pivot1?: Point, pivot2?: Point }
export const kNullPoint = {x: -999, y: -999}

export const PointDisplayTypes = ["points", "bars", "bins"] as const
export const PointDisplayTypes = ["points", "bars", "bins", "histogram"] as const
export type PointDisplayType = typeof PointDisplayTypes[number]

export const isPointDisplayType = (value: string): value is PointDisplayType => {
Expand Down
11 changes: 2 additions & 9 deletions v3/src/components/data-display/data-display-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,12 @@ export function handleClickOnCase(event: PointerEvent, caseID: string, dataset?:
interface IHandleClickOnBarProps {
event: PointerEvent
dataConfig: IDataConfigurationModel
primaryAttrRole: "x" | "y"
barCover: IBarCover
}

export const handleClickOnBar = ({ event, dataConfig, primaryAttrRole, barCover }: IHandleClickOnBarProps) => {
const { primeSplitCat, secSplitCat, legendCat, primeCat, secCat } = barCover
export const handleClickOnBar = ({ event, dataConfig, barCover }: IHandleClickOnBarProps) => {
const extendSelection = event.shiftKey
if (primeCat) {
const caseIDs = dataConfig.getCasesForCategoryValues(
primaryAttrRole, primeCat, secCat, primeSplitCat, secSplitCat, legendCat
)
setOrExtendSelection(caseIDs, dataConfig.dataset, extendSelection)
}
setOrExtendSelection(barCover.caseIDs, dataConfig.dataset, extendSelection)
}

export interface IMatchCirclesProps {
Expand Down
2 changes: 1 addition & 1 deletion v3/src/components/data-display/pixi/pixi-points.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ export class PixiPoints {
// 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".
const displayType = _displayType !== "bars" ? "points" : "bars"
const displayType = _displayType !== "bars" && _displayType !== "histogram" ? "points" : "bars"
if (this.displayType !== displayType) {
this.displayTypeTransitionState.isActive = true
this.forEachPoint(point => {
Expand Down
150 changes: 17 additions & 133 deletions v3/src/components/graph/components/binneddotplotdots.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,19 @@
import {ScaleBand, ScaleLinear, drag, select} from "d3"
import { comparer } from "mobx"
import {observer} from "mobx-react-lite"
import React, {useCallback, useEffect, useRef} from "react"
import { createPortal } from "react-dom"
import { clsx } from "clsx"
import {PlotProps} from "../graphing-types"
import {setPointSelection} from "../../data-display/data-display-utils"
import {useDataDisplayAnimation} from "../../data-display/hooks/use-data-display-animation"
import {usePixiDragHandlers, usePlotResponders} from "../hooks/use-plot"
import {useGraphDataConfigurationContext} from "../hooks/use-graph-data-configuration-context"
import {useDataSetContext} from "../../../hooks/use-data-set-context"
import {useGraphContentModelContext} from "../hooks/use-graph-content-model-context"
import {useGraphLayoutContext} from "../hooks/use-graph-layout-context"
import {circleAnchor} from "../../data-display/pixi/pixi-points"
import { setPointCoordinates } from "../utilities/graph-utils"
import { useInstanceIdContext } from "../../../hooks/use-instance-id-context"
import { computeBinPlacements, computePrimaryCoord, computeSecondaryCoord, adjustCoordForStacks,
determineBinForCase} from "../utilities/dot-plot-utils"
import { useDotPlotDragDrop } from "../hooks/use-dot-plot-drag-drop"
import { AxisPlace } from "../../axis/axis-types"
import { mstReaction } from "../../../utilities/mst-reaction"
import { t } from "../../../utilities/translation/translate"
import { isFiniteNumber } from "../../../utilities/math-utils"
import { useDotPlot } from "../hooks/use-dot-plot"
import { useDotPlotResponders } from "../hooks/use-dot-plot-responders"

const screenWidthToWorldWidth = (scale: ScaleLinear<number, number>, screenWidth: number) => {
return Math.abs(scale.invert(screenWidth) - scale.invert(0))
Expand All @@ -32,24 +24,15 @@ const worldWidthToScreenWidth = (scale: ScaleLinear<number, number>, worldWidth:
}

export const BinnedDotPlotDots = observer(function BinnedDotPlotDots(props: PlotProps) {
const {pixiPoints, abovePointsGroupRef} = props,
graphModel = useGraphContentModelContext(),
instanceId = useInstanceIdContext(),
{ isAnimating } = useDataDisplayAnimation(),
dataConfig = useGraphDataConfigurationContext(),
dataset = useDataSetContext(),
layout = useGraphLayoutContext(),
primaryAttrRole = dataConfig?.primaryRole ?? "x",
primaryIsBottom = primaryAttrRole === "x",
primaryPlace: AxisPlace = primaryIsBottom ? "bottom" : "left",
primaryAxisScale = layout.getAxisScale(primaryPlace) as ScaleLinear<number, number>,
secondaryAttrRole = primaryAttrRole === "x" ? "y" : "x",
{pointColor, pointStrokeColor} = graphModel.pointDescription,
pointDisplayType = graphModel.pointDisplayType,
kMinBinScreenWidth = 20,
binBoundariesRef = useRef<SVGGElement>(null),
primaryAxisScaleCopy = useRef<ScaleLinear<number, number>>(primaryAxisScale.copy()),
lowerBoundaryRef = useRef<number>(0)
const {pixiPoints, abovePointsGroupRef} = props
const { dataset, dataConfig, getPrimaryScreenCoord, getSecondaryScreenCoord, graphModel, isAnimating, layout,
pointColor, pointDisplayType, pointStrokeColor, primaryAxisScale, primaryIsBottom, primaryPlace,
refreshPointSelection } = useDotPlot(pixiPoints)
const instanceId = useInstanceIdContext()
const kMinBinScreenWidth = 20
const binBoundariesRef = useRef<SVGGElement>(null)
const primaryAxisScaleCopy = useRef<ScaleLinear<number, number>>(primaryAxisScale.copy())
const lowerBoundaryRef = useRef<number>(0)

const { onDrag, onDragEnd, onDragStart } = useDotPlotDragDrop()
usePixiDragHandlers(pixiPoints, {start: onDragStart, drag: onDrag, end: onDragEnd})
Expand Down Expand Up @@ -134,42 +117,11 @@ export const BinnedDotPlotDots = observer(function BinnedDotPlotDots(props: Plot
})
}, [handleDragBinBoundary, handleDragBinBoundaryEnd, handleDragBinBoundaryStart])

const refreshPointSelection = useCallback(() => {
dataConfig && setPointSelection({
pixiPoints, dataConfiguration: dataConfig, pointRadius: graphModel.getPointRadius(),
pointColor, pointStrokeColor, selectedPointRadius: graphModel.getPointRadius("select"),
pointDisplayType
})
}, [dataConfig, graphModel, pixiPoints, pointColor, pointStrokeColor, pointDisplayType])

const refreshPointPositions = useCallback((selectedOnly: boolean) => {
if (!dataConfig) return

const secondaryPlace = primaryIsBottom ? "left" : "bottom",
extraPrimaryPlace = primaryIsBottom ? "top" : "rightCat",
extraPrimaryRole = primaryIsBottom ? "topSplit" : "rightSplit",
extraSecondaryPlace = primaryIsBottom ? "rightCat" : "top",
extraSecondaryRole = primaryIsBottom ? "rightSplit" : "topSplit",
primaryAxis = graphModel.getNumericAxis(primaryPlace),
extraPrimaryAxisScale = layout.getAxisScale(extraPrimaryPlace) as ScaleBand<string>,
secondaryAxisScale = layout.getAxisScale(secondaryPlace) as ScaleBand<string>,
extraSecondaryAxisScale = layout.getAxisScale(extraSecondaryPlace) as ScaleBand<string>,
primaryAttrID = dataConfig?.attributeID(primaryAttrRole) ?? "",
extraPrimaryAttrID = dataConfig?.attributeID(extraPrimaryRole) ?? "",
numExtraPrimaryBands = Math.max(1, extraPrimaryAxisScale?.domain().length ?? 1),
pointDiameter = 2 * graphModel.getPointRadius(),
secondaryAttrID = dataConfig?.attributeID(secondaryAttrRole) ?? "",
extraSecondaryAttrID = dataConfig?.attributeID(extraSecondaryRole) ?? "",
secondaryRangeIndex = primaryIsBottom ? 0 : 1,
secondaryMax = Number(secondaryAxisScale.range()[secondaryRangeIndex]),
secondaryAxisExtent = Math.abs(Number(secondaryAxisScale.range()[0] - secondaryAxisScale.range()[1])),
fullSecondaryBandwidth = secondaryAxisScale.bandwidth?.() ?? secondaryAxisExtent,
numExtraSecondaryBands = Math.max(1, extraSecondaryAxisScale?.domain().length ?? 1),
secondaryBandwidth = fullSecondaryBandwidth / numExtraSecondaryBands,
extraSecondaryBandwidth = (extraSecondaryAxisScale.bandwidth?.() ?? secondaryAxisExtent),
secondarySign = primaryIsBottom ? -1 : 1,
baseCoord = primaryIsBottom ? secondaryMax : 0,
{ binWidth, maxBinEdge, minBinEdge, totalNumberOfBins } = graphModel.binDetails()
const primaryAxis = graphModel.getNumericAxis(primaryPlace)
const { maxBinEdge, minBinEdge } = graphModel.binDetails()

// Set the domain of the primary axis to the extent of the bins
primaryAxis?.setDomain(minBinEdge, maxBinEdge)
Expand All @@ -180,50 +132,6 @@ export const BinnedDotPlotDots = observer(function BinnedDotPlotDots(props: Plot
addBinBoundaryDragHandlers()
}

const binPlacementProps = {
binWidth, dataConfig, dataset, extraPrimaryAttrID, extraSecondaryAttrID, layout, minBinEdge,
numExtraPrimaryBands, pointDiameter, primaryAttrID, primaryAxisScale, primaryPlace, secondaryAttrID,
secondaryBandwidth, totalNumberOfBins
}
const { bins, binMap } = computeBinPlacements(binPlacementProps)
const overlap = 0

const getPrimaryScreenCoord = (anID: string) => {
const computePrimaryCoordProps = {
anID, binWidth, dataset, extraPrimaryAttrID, extraPrimaryAxisScale, isBinned: true, minBinEdge,
numExtraPrimaryBands, primaryAttrID, primaryAxisScale, totalNumberOfBins
}
const { primaryCoord, extraPrimaryCoord } = computePrimaryCoord(computePrimaryCoordProps)
let primaryScreenCoord = primaryCoord + extraPrimaryCoord
const caseValue = dataset?.getNumeric(anID, primaryAttrID) ?? -1
const binForCase = determineBinForCase(caseValue, binWidth, minBinEdge)
primaryScreenCoord = adjustCoordForStacks({
anID, axisType: "primary", binForCase, binMap, bins, pointDiameter, secondaryBandwidth,
screenCoord: primaryScreenCoord, primaryIsBottom
})

return primaryScreenCoord
}

const getSecondaryScreenCoord = (anID: string) => {
const { category: secondaryCat, extraCategory: extraSecondaryCat, indexInBin } = binMap[anID]
const onePixelOffset = primaryIsBottom ? -1 : 1
const secondaryCoordProps = {
baseCoord, extraSecondaryAxisScale, extraSecondaryBandwidth, extraSecondaryCat, indexInBin,
numExtraSecondaryBands, overlap, pointDiameter, primaryIsBottom, secondaryAxisExtent,
secondaryAxisScale, secondaryBandwidth, secondaryCat, secondarySign
}
let secondaryScreenCoord = computeSecondaryCoord(secondaryCoordProps) + onePixelOffset
const casePrimaryValue = dataset?.getNumeric(anID, primaryAttrID) ?? -1
const binForCase = determineBinForCase(casePrimaryValue, binWidth, minBinEdge)
secondaryScreenCoord = adjustCoordForStacks({
anID, axisType: "secondary", binForCase, binMap, bins, pointDiameter, secondaryBandwidth,
screenCoord: secondaryScreenCoord, primaryIsBottom
})

return secondaryScreenCoord
}

const getScreenX = primaryIsBottom ? getPrimaryScreenCoord : getSecondaryScreenCoord
const getScreenY = primaryIsBottom ? getSecondaryScreenCoord : getPrimaryScreenCoord

Expand All @@ -237,36 +145,12 @@ export const BinnedDotPlotDots = observer(function BinnedDotPlotDots(props: Plot
getScreenX, getScreenY, getLegendColor, getAnimationEnabled: isAnimating,
pointDisplayType, anchor: circleAnchor, dataset
})
},
[dataConfig, primaryIsBottom, graphModel, primaryPlace, layout, primaryAttrRole, secondaryAttrRole,
drawBinBoundaries, dataset, primaryAxisScale, pixiPoints, pointColor, pointStrokeColor, isAnimating,
pointDisplayType, addBinBoundaryDragHandlers])
}, [addBinBoundaryDragHandlers, dataConfig, dataset, drawBinBoundaries, getPrimaryScreenCoord,
getSecondaryScreenCoord, graphModel, isAnimating, pixiPoints, pointColor, pointDisplayType, pointStrokeColor,
primaryIsBottom, primaryPlace])

usePlotResponders({pixiPoints, refreshPointPositions, refreshPointSelection})

// Respond to binAlignment and binWidth changes. We include both the volatile and non-volatile versions of these
// properties. Changes to the volatile versions occur during bin boundary dragging and result in the appropriate
// behavior during a drag. Changes to the non-volatile versions occur when a drag ends (or the user sets the bin
// and alignment values via the form fields) and result in the behavior required when bin boundary dragging ends.
useEffect(function respondToGraphBinSettings() {
return mstReaction(
() => [graphModel._binAlignment, graphModel._binWidth, graphModel.binAlignment, graphModel.binWidth],
() => refreshPointPositions(false),
{name: "respondToGraphBinSettings", equals: comparer.structural}, graphModel)
}, [dataset, graphModel, refreshPointPositions])

// Initialize binWidth and binAlignment on the graph model if they haven't been defined yet.
// This can happen when a CODAP document containing a graph with binned points is imported.
useEffect(function setInitialBinSettings() {
if (!dataConfig) return
if (graphModel.binWidth === undefined || graphModel.binAlignment === undefined) {
const { binAlignment, binWidth } = graphModel.binDetails({ initialize: true })
graphModel.applyModelChange(() => {
graphModel.setBinWidth(binWidth)
graphModel.setBinAlignment(binAlignment)
})
}
})
useDotPlotResponders(refreshPointPositions)

// If the pixel width of graphModel.binWidth would be less than kMinBinPixelWidth, set it to kMinBinPixelWidth.
useEffect(function enforceMinBinPixelWidth() {
Expand Down
Loading

0 comments on commit 9ce3341

Please sign in to comment.