From 9ce3341f801d6b2ec7ed663dbcc5a594f9b14e28 Mon Sep 17 00:00:00 2001 From: Ethan McElroy Date: Wed, 5 Jun 2024 15:12:34 -0400 Subject: [PATCH] feat: Graph histogram (PT-186948982) (#1286) * feat: Graph histogram (PT-186948982) [#186948982](https://www.pivotaltracker.com/story/show/186948982) --- v3/cypress/e2e/graph.spec.ts | 18 ++ v3/src/components/axis/hooks/use-sub-axis.ts | 4 +- .../data-display/data-display-types.ts | 2 +- .../data-display/data-display-utils.ts | 11 +- .../data-display/pixi/pixi-points.ts | 2 +- .../graph/components/binneddotplotdots.tsx | 150 ++------------ .../graph/components/dot-chart-bars.tsx | 85 +------- .../graph/components/dotplotdots.tsx | 5 +- .../graph/components/freedotplotdots.tsx | 32 +-- v3/src/components/graph/components/graph.scss | 6 +- .../components/graph/components/histogram.tsx | 140 +++++++++++++ .../display-config-palette.tsx | 31 +-- v3/src/components/graph/graphing-types.ts | 1 + .../graph/hooks/use-dot-plot-responders.ts | 36 ++++ v3/src/components/graph/hooks/use-dot-plot.ts | 133 +++++++++++++ v3/src/components/graph/hooks/use-plot.ts | 4 + .../graph/models/graph-content-model.ts | 188 +++++++++++++----- .../models/graph-data-configuration-model.ts | 51 ++++- .../components/graph/utilities/bar-utils.ts | 75 +++++++ .../graph/utilities/dot-plot-utils.ts | 40 ++-- 20 files changed, 671 insertions(+), 343 deletions(-) create mode 100644 v3/src/components/graph/components/histogram.tsx create mode 100644 v3/src/components/graph/hooks/use-dot-plot-responders.ts create mode 100644 v3/src/components/graph/hooks/use-dot-plot.ts create mode 100644 v3/src/components/graph/utilities/bar-utils.ts diff --git a/v3/cypress/e2e/graph.spec.ts b/v3/cypress/e2e/graph.spec.ts index 6ad7e2e96d..c7acce2892 100644 --- a/v3/cypress/e2e/graph.spec.ts +++ b/v3/cypress/e2e/graph.spec.ts @@ -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") + }) }) diff --git a/v3/src/components/axis/hooks/use-sub-axis.ts b/v3/src/components/axis/hooks/use-sub-axis.ts index 92818c05d8..15db552708 100644 --- a/v3/src/components/axis/hooks/use-sub-axis.ts +++ b/v3/src/components/axis/hooks/use-sub-axis.ts @@ -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) diff --git a/v3/src/components/data-display/data-display-types.ts b/v3/src/components/data-display/data-display-types.ts index b86c6b8859..c75e80d375 100644 --- a/v3/src/components/data-display/data-display-types.ts +++ b/v3/src/components/data-display/data-display-types.ts @@ -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 => { diff --git a/v3/src/components/data-display/data-display-utils.ts b/v3/src/components/data-display/data-display-utils.ts index 1018bee932..deb3c65374 100644 --- a/v3/src/components/data-display/data-display-utils.ts +++ b/v3/src/components/data-display/data-display-utils.ts @@ -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 { diff --git a/v3/src/components/data-display/pixi/pixi-points.ts b/v3/src/components/data-display/pixi/pixi-points.ts index 8b6d783f45..a9f11b9df7 100644 --- a/v3/src/components/data-display/pixi/pixi-points.ts +++ b/v3/src/components/data-display/pixi/pixi-points.ts @@ -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 => { diff --git a/v3/src/components/graph/components/binneddotplotdots.tsx b/v3/src/components/graph/components/binneddotplotdots.tsx index 96fc707b99..bb284464d0 100644 --- a/v3/src/components/graph/components/binneddotplotdots.tsx +++ b/v3/src/components/graph/components/binneddotplotdots.tsx @@ -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, screenWidth: number) => { return Math.abs(scale.invert(screenWidth) - scale.invert(0)) @@ -32,24 +24,15 @@ const worldWidthToScreenWidth = (scale: ScaleLinear, 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, - secondaryAttrRole = primaryAttrRole === "x" ? "y" : "x", - {pointColor, pointStrokeColor} = graphModel.pointDescription, - pointDisplayType = graphModel.pointDisplayType, - kMinBinScreenWidth = 20, - binBoundariesRef = useRef(null), - primaryAxisScaleCopy = useRef>(primaryAxisScale.copy()), - lowerBoundaryRef = useRef(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(null) + const primaryAxisScaleCopy = useRef>(primaryAxisScale.copy()) + const lowerBoundaryRef = useRef(0) const { onDrag, onDragEnd, onDragStart } = useDotPlotDragDrop() usePixiDragHandlers(pixiPoints, {start: onDragStart, drag: onDrag, end: onDragEnd}) @@ -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, - secondaryAxisScale = layout.getAxisScale(secondaryPlace) as ScaleBand, - extraSecondaryAxisScale = layout.getAxisScale(extraSecondaryPlace) as ScaleBand, - 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) @@ -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 @@ -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() { diff --git a/v3/src/components/graph/components/dot-chart-bars.tsx b/v3/src/components/graph/components/dot-chart-bars.tsx index f7968223bc..a1a33c0dfb 100644 --- a/v3/src/components/graph/components/dot-chart-bars.tsx +++ b/v3/src/components/graph/components/dot-chart-bars.tsx @@ -1,86 +1,13 @@ -import { select } from "d3" import { observer } from "mobx-react-lite" import React, { useCallback, useEffect, useRef } from "react" import { createPortal } from "react-dom" -import { CellType, IBarCover, PlotProps } from "../graphing-types" +import { IBarCover, PlotProps } from "../graphing-types" import { usePlotResponders } from "../hooks/use-plot" -import { handleClickOnBar } from "../../data-display/data-display-utils" import { setPointCoordinates } from "../utilities/graph-utils" -import { IDataConfigurationModel } from "../../data-display/models/data-configuration-model" -import { GraphLayout } from "../models/graph-layout" -import { SubPlotCells } from "../models/sub-plot-cells" import { mstAutorun } from "../../../utilities/mst-autorun" import { useChartDots } from "../hooks/use-chart-dots" import { numericSortComparator } from "../../../utilities/data-utils" - -interface IRenderBarCoverProps { - barCovers: IBarCover[] - barCoversRef: React.RefObject - dataConfig: IDataConfigurationModel - primaryAttrRole: "x" | "y" -} - -interface IBarCoverDimensionsProps { - subPlotCells: SubPlotCells - cellIndices: CellType - layout: GraphLayout - maxInCell: number - minInCell?: number - primCatsCount: number -} - -const renderBarCovers = (props: IRenderBarCoverProps) => { - const { barCovers, barCoversRef, dataConfig, primaryAttrRole } = props - select(barCoversRef.current).selectAll("rect").remove() - select(barCoversRef.current).selectAll("rect") - .data(barCovers) - .join((enter) => enter.append("rect") - .attr("class", (d) => d.class) - .attr("data-testid", "bar-cover") - .attr("x", (d) => d.x) - .attr("y", (d) => d.y) - .attr("width", (d) => d.width) - .attr("height", (d) => d.height) - .on("mouseover", function() { select(this).classed("active", true) }) - .on("mouseout", function() { select(this).classed("active", false) }) - .on("click", function(event, d) { - dataConfig && handleClickOnBar({event, dataConfig, primaryAttrRole, barCover: d}) - }) - ) -} - -const barCoverDimensions = (props: IBarCoverDimensionsProps) => { - const { subPlotCells, cellIndices, maxInCell, minInCell = 0, primCatsCount } = props - const { numPrimarySplitBands, numSecondarySplitBands, primaryCellWidth, primaryIsBottom, primarySplitCellWidth, - secondaryCellHeight, secondaryNumericScale } = subPlotCells - const { p: primeCatIndex, ep: primeSplitCatIndex, es: secSplitCatIndex } = cellIndices - const adjustedPrimeSplitIndex = primaryIsBottom - ? primeSplitCatIndex - : numPrimarySplitBands - 1 - primeSplitCatIndex - const offsetPrimarySplit = adjustedPrimeSplitIndex * primarySplitCellWidth - const primaryInvertedIndex = primCatsCount - 1 - primeCatIndex - const offsetPrimary = primaryIsBottom - ? primeCatIndex * primaryCellWidth + offsetPrimarySplit - : primaryInvertedIndex * primaryCellWidth + offsetPrimarySplit - const secondaryCoord = secondaryNumericScale?.(maxInCell) ?? 0 - const secondaryBaseCoord = secondaryNumericScale?.(minInCell) ?? 0 - const secondaryIndex = primaryIsBottom - ? numSecondarySplitBands - 1 - secSplitCatIndex - : secSplitCatIndex - const offsetSecondary = secondaryIndex * secondaryCellHeight - const adjustedSecondaryCoord = primaryIsBottom - ? Math.abs(secondaryCoord / numSecondarySplitBands + offsetSecondary) - : secondaryBaseCoord / numSecondarySplitBands + offsetSecondary - const primaryDimension = primaryCellWidth / 2 - const secondaryDimension = Math.abs(secondaryCoord - secondaryBaseCoord) / numSecondarySplitBands - const barWidth = primaryIsBottom ? primaryDimension : secondaryDimension - const barHeight = primaryIsBottom ? secondaryDimension : primaryDimension - const primaryCoord = offsetPrimary + (primaryCellWidth / 2 - primaryDimension / 2) - const x = primaryIsBottom ? primaryCoord : adjustedSecondaryCoord - const y = primaryIsBottom ? adjustedSecondaryCoord : primaryCoord - - return { x, y, barWidth, barHeight } -} +import { barCoverDimensions, renderBarCovers } from "../utilities/bar-utils" export const DotChartBars = observer(function DotChartBars({ abovePointsGroupRef, pixiPoints }: PlotProps) { const { dataset, graphModel, isAnimating, layout, primaryScreenCoord, secondaryScreenCoord, @@ -166,7 +93,11 @@ export const DotChartBars = observer(function DotChartBars({ abovePointsGroupRef const { x, y, barWidth, barHeight } = barCoverDimensions({ subPlotCells, cellIndices: cellData.cell, layout, primCatsCount, maxInCell, minInCell }) + const caseIDs = dataConfig.getCasesForCategoryValues( + primaryAttrRole, primeCat, secCat, primeSplitCat, secSplitCat, legendCat + ) barCovers.push({ + caseIDs, class: `bar-cover ${primeCat} ${secCatKey} ${exPrimeCatKey} ${exSecCatKey} ${legendCat}`, primeCat, secCat, primeSplitCat, secSplitCat, legendCat, x: x.toString(), y: y.toString(), @@ -180,7 +111,11 @@ export const DotChartBars = observer(function DotChartBars({ abovePointsGroupRef const { x, y, barWidth, barHeight } = barCoverDimensions({ subPlotCells, cellIndices: cellData.cell, layout, primCatsCount, maxInCell }) + const caseIDs = dataConfig.getCasesForCategoryValues( + primaryAttrRole, primeCat, secCat, primeSplitCat, secSplitCat + ) barCovers.push({ + caseIDs, class: `bar-cover ${primeCat} ${secCatKey} ${exPrimeCatKey} ${exSecCatKey}`, primeCat, secCat, primeSplitCat, secSplitCat, x: x.toString(), y: y.toString(), diff --git a/v3/src/components/graph/components/dotplotdots.tsx b/v3/src/components/graph/components/dotplotdots.tsx index 3f6dd74475..f2c37cf22a 100644 --- a/v3/src/components/graph/components/dotplotdots.tsx +++ b/v3/src/components/graph/components/dotplotdots.tsx @@ -3,6 +3,7 @@ import React from "react" import { PlotProps } from "../graphing-types" import { useGraphContentModelContext } from "../hooks/use-graph-content-model-context" import { BinnedDotPlotDots } from "./binneddotplotdots" +import { Histogram } from "./histogram" import { FreeDotPlotDots } from "./freedotplotdots" export const DotPlotDots = observer(function DotPlotDots(props: PlotProps) { @@ -12,7 +13,9 @@ export const DotPlotDots = observer(function DotPlotDots(props: PlotProps) { const plotComponent = pointDisplayType === "bins" ? - : + : pointDisplayType === "histogram" + ? + : return plotComponent }) diff --git a/v3/src/components/graph/components/freedotplotdots.tsx b/v3/src/components/graph/components/freedotplotdots.tsx index 83fce4db26..73ea320eea 100644 --- a/v3/src/components/graph/components/freedotplotdots.tsx +++ b/v3/src/components/graph/components/freedotplotdots.tsx @@ -4,43 +4,23 @@ import React, {useCallback, useEffect} from "react" import { mstAutorun } from "../../../utilities/mst-autorun" import {mstReaction} from "../../../utilities/mst-reaction" 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 {setNiceDomain, setPointCoordinates} from "../utilities/graph-utils" import {circleAnchor, hBarAnchor, vBarAnchor} from "../../data-display/pixi/pixi-points" import { computeBinPlacements, computePrimaryCoord, computeSecondaryCoord } from "../utilities/dot-plot-utils" import { useDotPlotDragDrop } from "../hooks/use-dot-plot-drag-drop" import { AxisPlace } from "../../axis/axis-types" +import { useDotPlot } from "../hooks/use-dot-plot" export const FreeDotPlotDots = observer(function FreeDotPlotDots(props: PlotProps) { - const {pixiPoints} = props, - graphModel = useGraphContentModelContext(), - {isAnimating} = useDataDisplayAnimation(), - dataConfig = useGraphDataConfigurationContext(), - dataset = useDataSetContext(), - layout = useGraphLayoutContext(), - primaryAttrRole = dataConfig?.primaryRole ?? 'x', - primaryIsBottom = primaryAttrRole === 'x', - secondaryAttrRole = primaryAttrRole === 'x' ? 'y' : 'x', - {pointColor, pointStrokeColor} = graphModel.pointDescription, - pointDisplayType = graphModel.pointDisplayType - + const {pixiPoints} = props + const { dataset, dataConfig, graphModel, isAnimating, layout, + pointColor, pointDisplayType, pointStrokeColor, + primaryAttrRole, primaryIsBottom, + secondaryAttrRole, refreshPointSelection } = useDotPlot(pixiPoints) const { onDrag, onDragEnd, onDragStart } = useDotPlotDragDrop() usePixiDragHandlers(pixiPoints, {start: onDragStart, drag: onDrag, end: onDragEnd}) - 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) => { const primaryPlace: AxisPlace = primaryIsBottom ? 'bottom' : 'left', secondaryPlace = primaryIsBottom ? 'left' : 'bottom', diff --git a/v3/src/components/graph/components/graph.scss b/v3/src/components/graph/components/graph.scss index 4d210d6774..f2dc068263 100644 --- a/v3/src/components/graph/components/graph.scss +++ b/v3/src/components/graph/components/graph.scss @@ -171,10 +171,10 @@ .bar-cover { cursor: pointer; - fill: white; - opacity: 0; + fill: rgba(255, 255, 255, 0); + transition: 1s; &.active { - opacity: 0.3; + fill: rgba(255, 255, 255, 0.3); } } diff --git a/v3/src/components/graph/components/histogram.tsx b/v3/src/components/graph/components/histogram.tsx new file mode 100644 index 0000000000..27db8b1f6b --- /dev/null +++ b/v3/src/components/graph/components/histogram.tsx @@ -0,0 +1,140 @@ +import { ScaleBand } from "d3" +import { observer } from "mobx-react-lite" +import React, { useCallback, useEffect, useRef } from "react" +import { createPortal } from "react-dom" +import { IBarCover, PlotProps } from "../graphing-types" +import { usePlotResponders } from "../hooks/use-plot" +import { setPointCoordinates } from "../utilities/graph-utils" +import { mstAutorun } from "../../../utilities/mst-autorun" +import { computeBinPlacements } from "../utilities/dot-plot-utils" +import { useDotPlot } from "../hooks/use-dot-plot" +import { useDotPlotResponders } from "../hooks/use-dot-plot-responders" +import { renderBarCovers } from "../utilities/bar-utils" +import { SubPlotCells } from "../models/sub-plot-cells" + +export const Histogram = observer(function Histogram({ abovePointsGroupRef, pixiPoints }: PlotProps) { + const { dataset, dataConfig, graphModel, isAnimating, layout, getPrimaryScreenCoord, getSecondaryScreenCoord, + pointColor, pointStrokeColor, primaryAttrRole, primaryAxisScale, primaryIsBottom, primaryPlace, + refreshPointSelection, secondaryAttrRole } = useDotPlot(pixiPoints) + const barCoversRef = useRef(null) + const pointDisplayType = "histogram" + + 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", + extraPrimaryAxisScale = layout.getAxisScale(extraPrimaryPlace) as ScaleBand, + secondaryAxisScale = layout.getAxisScale(secondaryPlace) as ScaleBand, + extraSecondaryAxisScale = layout.getAxisScale(extraSecondaryPlace) as ScaleBand, + 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) ?? "", + 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, + { binWidth, minBinEdge, totalNumberOfBins } = graphModel.binDetails(), + subPlotCells = new SubPlotCells(layout, dataConfig), + { secondaryNumericUnitLength } = subPlotCells + + const binPlacementProps = { + binWidth, dataConfig, dataset, extraPrimaryAttrID, extraSecondaryAttrID, layout, minBinEdge, + numExtraPrimaryBands, pointDiameter, primaryAttrID, primaryAxisScale, primaryPlace, secondaryAttrID, + secondaryBandwidth, totalNumberOfBins + } + const { bins } = computeBinPlacements(binPlacementProps) + const primaryScaleDiff = primaryAxisScale(binWidth) - primaryAxisScale(0) + const getWidth = () => primaryIsBottom ? primaryScaleDiff / numExtraPrimaryBands : secondaryNumericUnitLength + const getHeight = () => primaryIsBottom ? secondaryNumericUnitLength : -primaryScaleDiff / numExtraPrimaryBands + const getScreenX = primaryIsBottom ? getPrimaryScreenCoord : getSecondaryScreenCoord + const getScreenY = primaryIsBottom ? getSecondaryScreenCoord : getPrimaryScreenCoord + const getLegendColor = dataConfig?.attributeID("legend") ? dataConfig?.getLegendColorForCase : undefined + + // build and render bar cover elements that will handle click events for the fused points + if (dataConfig && abovePointsGroupRef?.current) { + const uniqueBarCovers = new Map() + Object.keys(bins).forEach(primeCat => { + Object.keys(bins[primeCat]).forEach(secCat => { + Object.keys(bins[primeCat][secCat]).forEach(primeSplitCat => { + Object.keys(bins[primeCat][secCat][primeSplitCat]).forEach(secSplitCat => { + const cellData = bins[primeCat][secCat][primeSplitCat] + cellData.forEach((cell, cellIndex) => { + if (cell.length === 0) return + const barSecondaryDimension = secondaryNumericUnitLength * cell.length + const barWidth = primaryIsBottom + ? primaryScaleDiff / numExtraPrimaryBands + : barSecondaryDimension + const barHeight = primaryIsBottom + ? barSecondaryDimension + : -primaryScaleDiff / numExtraPrimaryBands + const x = primaryIsBottom + ? getPrimaryScreenCoord(cell[0]) - barWidth / 2 + : getSecondaryScreenCoord(cell[0]) - secondaryNumericUnitLength / 2 + const y = primaryIsBottom + ? getSecondaryScreenCoord(cell[0]) - barHeight + secondaryNumericUnitLength / 2 + : getPrimaryScreenCoord(cell[0]) - barHeight / 2 + const key = `${primeCat}-${secCat}-${primeSplitCat}-${cellIndex}` + const cover = { + caseIDs: cell, + class: `bar-cover bar-cover-${cellIndex} draggable-bin-boundary`, + primeCat, secCat, primeSplitCat, secSplitCat, + x: x.toString(), y: y.toString(), + width: barWidth.toString(), height: barHeight.toString() + } + if (!uniqueBarCovers.has(key)) uniqueBarCovers.set(key, cover) + }) + }) + }) + }) + }) + const barCovers: IBarCover[] = Array.from(uniqueBarCovers.entries()).map(([_, cover]) => cover) + renderBarCovers({ barCovers, barCoversRef, dataConfig, primaryAttrRole }) + } + + setPointCoordinates({ + pointRadius: graphModel.getPointRadius(), + selectedPointRadius: graphModel.getPointRadius("select"), + pixiPoints, selectedOnly, pointColor, pointStrokeColor, getWidth, getHeight, + getScreenX, getScreenY, getLegendColor, getAnimationEnabled: isAnimating, + pointDisplayType, dataset, pointsFusedIntoBars: true + }) + }, [abovePointsGroupRef, dataConfig, dataset, getPrimaryScreenCoord, getSecondaryScreenCoord, graphModel, + isAnimating, layout, pixiPoints, pointColor, pointStrokeColor, primaryAttrRole, primaryAxisScale, + primaryIsBottom, primaryPlace, secondaryAttrRole]) + + usePlotResponders({pixiPoints, refreshPointPositions, refreshPointSelection}) + useDotPlotResponders(refreshPointPositions) + + // when points are fused into bars, update pixiPoints and set the secondary axis scale type to linear + useEffect(function handleFuseIntoBars() { + return mstAutorun( + () => { + if (pixiPoints) { + pixiPoints.pointsFusedIntoBars = graphModel.pointsFusedIntoBars + } + if (graphModel.pointsFusedIntoBars) { + const secondaryRole = graphModel.dataConfiguration.primaryRole === "x" ? "y" : "x" + const secondaryPlace = secondaryRole === "y" ? "left" : "bottom" + layout.setAxisScaleType(secondaryPlace, "linear") + } + }, + {name: "useAxis [handleFuseIntoBars]"}, graphModel + ) + }, [graphModel, layout, pixiPoints]) + + return ( + <> + {abovePointsGroupRef?.current && createPortal( + , + abovePointsGroupRef.current + )} + + ) +}) diff --git a/v3/src/components/graph/components/inspector-panel/display-config-palette.tsx b/v3/src/components/graph/components/inspector-panel/display-config-palette.tsx index 01a46b7756..2bad443207 100644 --- a/v3/src/components/graph/components/inspector-panel/display-config-palette.tsx +++ b/v3/src/components/graph/components/inspector-panel/display-config-palette.tsx @@ -27,7 +27,9 @@ export const DisplayConfigPalette = observer(function DisplayConfigPanel(props: const plotType = graphModel?.plotType const plotHasExactlyOneCategoricalAxis = graphModel?.dataConfiguration.hasExactlyOneCategoricalAxis const showPointDisplayType = plotType !== "dotChart" || !plotHasExactlyOneCategoricalAxis - const showFuseIntoBars = plotType === "dotChart" && plotHasExactlyOneCategoricalAxis + const showFuseIntoBars = (plotType === "dotChart" && plotHasExactlyOneCategoricalAxis) || + (plotType === "dotPlot" && pointDisplayType === "bins") || + pointDisplayType === "histogram" const handleSelection = (configType: string) => { if (isPointDisplayType(configType)) { @@ -62,12 +64,16 @@ export const DisplayConfigPalette = observer(function DisplayConfigPanel(props: } const handleSetFuseIntoBars = (fuseIntoBars: boolean) => { - graphModel?.setPointsFusedIntoBars(fuseIntoBars) - if (fuseIntoBars) { - graphModel?.pointDescription.setPointStrokeSameAsFill(true) - } else { - graphModel?.pointDescription.setPointStrokeSameAsFill(false) - } + const [undoStringKey, redoStringKey] = fuseIntoBars + ? ["DG.Undo.graph.fuseDotsToRectangles", "DG.Redo.graph.fuseDotsToRectangles"] + : ["DG.Undo.graph.dissolveRectanglesToDots", "DG.Redo.graph.dissolveRectanglesToDots"] + + graphModel?.applyModelChange(() => { + graphModel?.setPointsFusedIntoBars(fuseIntoBars) + graphModel?.pointDescription.setPointStrokeSameAsFill(fuseIntoBars) + }, + { undoStringKey, redoStringKey } + ) } return ( @@ -80,7 +86,7 @@ export const DisplayConfigPalette = observer(function DisplayConfigPanel(props: > {showPointDisplayType && ( - + )} - {showPointDisplayType && pointDisplayType === "bins" && ( + {showPointDisplayType && (pointDisplayType === "bins" || pointDisplayType === "histogram") && ( @@ -141,13 +147,12 @@ export const DisplayConfigPalette = observer(function DisplayConfigPanel(props: )} - { // For now this option is only available for dot charts, but it should eventually - // be available for univariate plot graphs as well. - showFuseIntoBars && + {showFuseIntoBars && handleSetFuseIntoBars(e.target.checked)}> + onChange={e => handleSetFuseIntoBars(e.target.checked)} + > {t("DG.Inspector.graphBarChart")} } diff --git a/v3/src/components/graph/graphing-types.ts b/v3/src/components/graph/graphing-types.ts index 59a388f475..87af6fab04 100644 --- a/v3/src/components/graph/graphing-types.ts +++ b/v3/src/components/graph/graphing-types.ts @@ -18,6 +18,7 @@ export interface IDomainOptions { } export interface IBarCover { + caseIDs: string[] class: string legendCat?: string primeCat: string diff --git a/v3/src/components/graph/hooks/use-dot-plot-responders.ts b/v3/src/components/graph/hooks/use-dot-plot-responders.ts new file mode 100644 index 0000000000..b4ad59e156 --- /dev/null +++ b/v3/src/components/graph/hooks/use-dot-plot-responders.ts @@ -0,0 +1,36 @@ +import { useEffect } from "react" +import { mstReaction } from "../../../utilities/mst-reaction" +import { comparer } from "mobx" +import { useDataSetContext } from "../../../hooks/use-data-set-context" +import { useGraphContentModelContext } from "./use-graph-content-model-context" +import { useGraphDataConfigurationContext } from "./use-graph-data-configuration-context" + +export const useDotPlotResponders = (refreshPointPositions: (selected: boolean) => void) => { + const graphModel = useGraphContentModelContext() + const dataset = useDataSetContext() + const dataConfig = useGraphDataConfigurationContext() + + // 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) + }) + } + }) +} diff --git a/v3/src/components/graph/hooks/use-dot-plot.ts b/v3/src/components/graph/hooks/use-dot-plot.ts new file mode 100644 index 0000000000..e5c09176bc --- /dev/null +++ b/v3/src/components/graph/hooks/use-dot-plot.ts @@ -0,0 +1,133 @@ +import { useCallback } from "react" +import { ScaleBand, ScaleLinear } from "d3" +import { useMemo } from "use-memo-one" +import { useDataSetContext } from "../../../hooks/use-data-set-context" +import { computePrimaryCoord, determineBinForCase, adjustCoordForStacks, + computeBinPlacements, computeSecondaryCoord} from "../utilities/dot-plot-utils" +import { useGraphContentModelContext } from "./use-graph-content-model-context" +import { useGraphDataConfigurationContext } from "./use-graph-data-configuration-context" +import { useGraphLayoutContext } from "./use-graph-layout-context" +import { AxisPlace } from "../../axis/axis-types" +import { GraphAttrRole } from "../../data-display/data-display-types" +import { setPointSelection } from "../../data-display/data-display-utils" +import { useDataDisplayAnimation } from "../../data-display/hooks/use-data-display-animation" +import { SubPlotCells } from "../models/sub-plot-cells" +import { PixiPoints } from "../../data-display/pixi/pixi-points" + +export const useDotPlot = (pixiPoints?: PixiPoints) => { + const graphModel = useGraphContentModelContext() + const isHistogram = graphModel.pointDisplayType === "histogram" + const dataConfig = useGraphDataConfigurationContext() + const dataset = useDataSetContext() + const layout = useGraphLayoutContext() + const subPlotCells = useMemo(() => new SubPlotCells(layout, dataConfig), [dataConfig, layout]) + const { secondaryNumericScale } = subPlotCells + const primaryAttrRole = dataConfig?.primaryRole ?? "x" + const primaryIsBottom = primaryAttrRole === "x" + const secondaryPlace = primaryIsBottom ? "left" : "top" + const secondaryAttrRole: GraphAttrRole = primaryAttrRole === "x" ? "y" : "x" + const extraPrimaryRole = primaryIsBottom ? "topSplit" : "rightSplit" + const extraPrimaryPlace = primaryIsBottom ? "top" : "rightCat" + const extraPrimaryAttrID = dataConfig?.attributeID(extraPrimaryRole) ?? "" + // TODO: Instead of using `layout.getAxisScale` and casting to `ScaleBand` to set + // `extraPrimaryAxisScale`, `secondaryAxisScale`, and `extraSecondaryAxisScale` below, we should use + // `layout.getBandScale`. We're not doing so now because using `layout.getBandScale` here breaks things in + // strange ways. It's possibly related to the fact `getBandScale` can return `undefined` whereas `getAxisScale` + // will always return a scale of some kind. + const extraPrimaryAxisScale = layout.getAxisScale(extraPrimaryPlace) as ScaleBand + const numExtraPrimaryBands = Math.max(1, extraPrimaryAxisScale?.domain().length ?? 1) + const extraSecondaryRole = primaryIsBottom ? "rightSplit" : "topSplit" + const extraSecondaryAttrID = dataConfig?.attributeID(extraSecondaryRole) ?? "" + const primaryAttrID = dataConfig?.attributeID(primaryAttrRole) ?? "" + const primaryPlace: AxisPlace = primaryIsBottom ? "bottom" : "left" + const primaryAxisScale = layout.getAxisScale(primaryPlace) as ScaleLinear + const pointDiameter = 2 * graphModel.getPointRadius() + const secondaryAttrID = dataConfig?.attributeID(secondaryAttrRole) ?? "" + const secondaryAxisScale = layout.getAxisScale(secondaryPlace) as ScaleBand + const extraSecondaryPlace = primaryIsBottom ? "rightCat" : "top" + const extraSecondaryAxisScale = layout.getAxisScale(extraSecondaryPlace) as ScaleBand + const secondaryAxisExtent = Math.abs(Number(secondaryAxisScale.range()[0] - secondaryAxisScale.range()[1])) + const fullSecondaryBandwidth = secondaryAxisScale.bandwidth?.() ?? secondaryAxisExtent + const numExtraSecondaryBands = Math.max(1, extraSecondaryAxisScale?.domain().length ?? 1) + const secondaryBandwidth = fullSecondaryBandwidth / numExtraSecondaryBands + const extraSecondaryBandwidth = (extraSecondaryAxisScale.bandwidth?.() ?? secondaryAxisExtent) + const { binWidth, minBinEdge, totalNumberOfBins } = graphModel.binDetails() + 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 secondaryRangeIndex = primaryIsBottom ? 0 : 1 + const secondaryMax = Number(secondaryAxisScale.range()[secondaryRangeIndex]) + const secondarySign = primaryIsBottom ? -1 : 1 + const baseCoord = primaryIsBottom ? secondaryMax : 0 + const {pointColor, pointStrokeColor} = graphModel.pointDescription + const pointDisplayType = graphModel.pointDisplayType + const { isAnimating } = useDataDisplayAnimation() + + const refreshPointSelection = useCallback(() => { + const pointRadius = graphModel.getPointRadius() + const selectedPointRadius = graphModel.getPointRadius('select') + const pointsFusedIntoBars = graphModel.pointsFusedIntoBars + dataConfig && setPointSelection({ + pixiPoints, dataConfiguration: dataConfig, pointRadius, pointColor, pointStrokeColor, selectedPointRadius, + pointDisplayType, pointsFusedIntoBars + }) + }, [dataConfig, graphModel, pixiPoints, pointColor, pointStrokeColor, pointDisplayType]) + + const getPrimaryScreenCoord = useCallback((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 + + if (graphModel.pointDisplayType !== "histogram") { + 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 + }, [binMap, binWidth, bins, dataset, extraPrimaryAttrID, extraPrimaryAxisScale, graphModel.pointDisplayType, + minBinEdge, numExtraPrimaryBands, pointDiameter, primaryAttrID, primaryAxisScale, primaryIsBottom, + secondaryBandwidth, totalNumberOfBins]) + + const getSecondaryScreenCoord = useCallback((anID: string) => { + if (!binMap[anID]) return 0 + + const { category: secondaryCat, extraCategory: extraSecondaryCat, indexInBin } = binMap[anID] + const secondaryCoordProps = { + baseCoord, dataConfig, extraSecondaryAxisScale, extraSecondaryBandwidth, extraSecondaryCat, indexInBin, layout, + numExtraSecondaryBands, overlap, pointDiameter, primaryIsBottom, secondaryAxisExtent, secondaryNumericScale, + secondaryAxisScale, secondaryBandwidth, secondaryCat, secondarySign, isHistogram + } + let secondaryScreenCoord = computeSecondaryCoord(secondaryCoordProps) + + if (graphModel.pointDisplayType !== "histogram") { + const onePixelOffset = primaryIsBottom ? -1 : 1 + 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 + }) + onePixelOffset + } + return secondaryScreenCoord + }, [binMap, baseCoord, dataConfig, extraSecondaryAxisScale, extraSecondaryBandwidth, layout, numExtraSecondaryBands, + pointDiameter, primaryIsBottom, secondaryAxisExtent, secondaryNumericScale, secondaryAxisScale, + secondaryBandwidth, secondarySign, isHistogram, graphModel.pointDisplayType, dataset, primaryAttrID, binWidth, + minBinEdge, bins]) + + return { + dataset, dataConfig, getPrimaryScreenCoord, getSecondaryScreenCoord, graphModel, isAnimating, layout, pointColor, + pointDisplayType, pointStrokeColor, primaryAttrRole, primaryAxisScale, primaryIsBottom, primaryPlace, + refreshPointSelection, secondaryAttrRole, subPlotCells + } +} diff --git a/v3/src/components/graph/hooks/use-plot.ts b/v3/src/components/graph/hooks/use-plot.ts index 85295aa39d..e2d51f0166 100644 --- a/v3/src/components/graph/hooks/use-plot.ts +++ b/v3/src/components/graph/hooks/use-plot.ts @@ -100,6 +100,10 @@ export const usePlotResponders = (props: IPlotResponderProps) => { // if plot is not univariate and the attribute type changes, we need to update the pointConfig if (graphModel?.plotType !== "dotPlot" && !graphModel?.pointsFusedIntoBars) { graphModel?.setPointConfig("points") + // if the attribute change results in a univariate plot, make sure points are only fused into bars + // if the pointDisplayType is histogram + } else if (graphModel?.plotType === "dotPlot" && graphModel?.pointDisplayType !== "histogram") { + graphModel?.setPointsFusedIntoBars(false) } // If points are fused into bars and a secondary attribute is added or the primary attribute is removed, // unfuse the points. Otherwise, if a primary attribute exists, make sure the bar graph's count axis gets diff --git a/v3/src/components/graph/models/graph-content-model.ts b/v3/src/components/graph/models/graph-content-model.ts index e1e021d8b3..e5a0e25069 100644 --- a/v3/src/components/graph/models/graph-content-model.ts +++ b/v3/src/components/graph/models/graph-content-model.ts @@ -70,6 +70,7 @@ export const GraphContentModel = DataDisplayContentModel axes: types.map(AxisModelUnion), _binAlignment: types.maybe(types.number), _binWidth: types.maybe(types.number), + pointsAreBinned: types.optional(types.boolean, false), pointsFusedIntoBars: types.optional(types.boolean, false), // TODO: should the default plot be something like "nullPlot" (which doesn't exist yet)? plotType: types.optional(types.enumeration([...PlotTypes]), "casePlot"), @@ -150,7 +151,8 @@ export const GraphContentModel = DataDisplayContentModel return self.dataConfiguration.attributeID(place) ?? '' }, axisShouldShowGridLines(place: AxisPlace) { - return self.plotType === 'scatterPlot' && ['left', 'bottom'].includes(place) + return (self.plotType === 'scatterPlot' && ['left', 'bottom'].includes(place)) || + (self.pointDisplayType === "histogram") }, placeCanAcceptAttributeIDDrop(place: GraphPlace, dataset: IDataSet | undefined, @@ -279,6 +281,30 @@ export const GraphContentModel = DataDisplayContentModel }, setDynamicBinWidth(width: number) { self.dynamicBinWidth = width + }, + binnedAxisTicks(formatter?: (value: number) => string): { tickValues: number[], tickLabels: string[] } { + const tickValues: number[] = [] + const tickLabels: string[] = [] + const { binWidth, totalNumberOfBins, minBinEdge } = self.binDetails() + + let currentStart = minBinEdge + let binCount = 0 + + while (binCount < totalNumberOfBins) { + const currentEnd = currentStart + binWidth + if (formatter) { + const formattedCurrentStart = formatter(currentStart) + const formattedCurrentEnd = formatter(currentEnd) + tickValues.push(currentStart + (binWidth / 2)) + tickLabels.push(`[${formattedCurrentStart}, ${formattedCurrentEnd})`) + } else { + tickValues.push(currentStart + binWidth) + tickLabels.push(`${currentEnd}`) + } + currentStart += binWidth + binCount++ + } + return { tickValues, tickLabels } } })) .views(self => ({ @@ -295,12 +321,9 @@ export const GraphContentModel = DataDisplayContentModel let binCount = 0 while (binCount < totalNumberOfBins) { - const formattedCurrentStart = formatter - ? formatter(currentStart) - : currentStart - const formattedCurrentEnd = formatter - ? formatter(currentStart + binWidth) - : currentStart + binWidth + const currentEnd = currentStart + binWidth + const formattedCurrentStart = formatter(currentStart) + const formattedCurrentEnd = formatter(currentEnd) tickValues.push(currentStart + (binWidth / 2)) tickLabels.push(`[${formattedCurrentStart}, ${formattedCurrentEnd})`) currentStart += binWidth @@ -318,9 +341,31 @@ export const GraphContentModel = DataDisplayContentModel self.setBinAlignment(binAlignment) self.setBinWidth(binWidth) }, + matchingCasesForAttr(attrID: string, value?: string, _allCases?: ICase[]) { + const dataset = self.dataConfiguration?.dataset + const allCases = _allCases ?? dataset?.cases + let matchingCases: ICase[] = [] + + if (self.pointDisplayType === "histogram") { + const { binWidth, minBinEdge } = self.binDetails() + const binIndex = Math.floor((Number(value) - minBinEdge) / binWidth) + matchingCases = allCases?.filter(aCase => { + const caseValue = dataset?.getNumeric(aCase.__id__, attrID) ?? 0 + const bin = Math.floor((caseValue - minBinEdge) / binWidth) + return bin === binIndex + }) as ICase[] ?? [] + } else if (attrID && value) { + matchingCases = allCases?.filter(aCase => dataset?.getStrValue(aCase.__id__, attrID) === value) as ICase[] ?? [] + } + + return matchingCases + } + })) + .views(self => ({ fusedCasesTipText(caseID: string, legendAttrID?: string) { const dataConfig = self.dataConfiguration const dataset = dataConfig.dataset + const isHistogram = self.pointDisplayType === "histogram" const float = format('.1~f') const primaryRole = dataConfig?.primaryRole const primaryAttrID = primaryRole && dataConfig?.attributeID(primaryRole) @@ -330,49 +375,74 @@ export const GraphContentModel = DataDisplayContentModel const caseTopSplitValue = topSplitAttrID && dataset?.getStrValue(caseID, topSplitAttrID) const caseRightSplitValue = rightSplitAttrID && dataset?.getStrValue(caseID, rightSplitAttrID) const caseLegendValue = legendAttrID && dataset?.getStrValue(caseID, legendAttrID) - const getMatchingCases = (attrID?: string, value?: string, _allCases?: ICase[]) => { - const allCases = _allCases ?? dataset?.cases - const matchingCases = attrID && value - ? allCases?.filter(aCase => dataset?.getStrValue(aCase.__id__, attrID) === value) ?? [] - : [] - return matchingCases as ICase[] - } + if (!primaryAttrID) return "" - // for each existing attribute, get the cases that have the same value as the current case - const primaryMatches = getMatchingCases(primaryAttrID, casePrimaryValue) - const topSplitMatches = getMatchingCases(topSplitAttrID, caseTopSplitValue) - const rightSplitMatches = getMatchingCases(rightSplitAttrID, caseRightSplitValue) - const bothSplitMatches = topSplitMatches.filter(aCase => rightSplitMatches.includes(aCase)) - const legendMatches = getMatchingCases(legendAttrID, caseLegendValue, primaryMatches) + let tipText = "" const cellKey: Record = { - ...(casePrimaryValue && {[primaryAttrID]: casePrimaryValue}), + ...(!isHistogram && casePrimaryValue && {[primaryAttrID]: casePrimaryValue}), ...(caseTopSplitValue && {[topSplitAttrID]: caseTopSplitValue}), ...(caseRightSplitValue && {[rightSplitAttrID]: caseRightSplitValue}) } + const primaryMatches = self.matchingCasesForAttr(primaryAttrID, casePrimaryValue) const casesInSubPlot = dataConfig?.subPlotCases(cellKey) ?? [] - const totalCases = [ - legendMatches.length, - bothSplitMatches.length, - topSplitMatches.length, - rightSplitMatches.length, - dataset?.cases.length ?? 0 - ].find(length => length > 0) ?? 0 - const legendMatchesInSubplot = legendAttrID - ? casesInSubPlot.filter(aCaseID => dataset?.getStrValue(aCaseID, legendAttrID) === caseLegendValue).length - : 0 - const caseCategoryString = caseLegendValue ? casePrimaryValue : "" - const caseLegendCategoryString = caseLegendValue || casePrimaryValue - const firstCount = legendAttrID ? legendMatchesInSubplot : casesInSubPlot.length - const secondCount = legendAttrID ? casesInSubPlot.length : totalCases - const percent = float(100 * firstCount / secondCount) - // of (

%) are - const attrArray = [ - firstCount, secondCount, caseCategoryString, percent, caseLegendCategoryString - ] - return t("DG.BarChartModel.cellTipPlural", {vars: attrArray}) - } - })) - .views(self => ({ + + if (isHistogram) { + const allMatchingCases = primaryMatches.filter(aCaseID => { + if (topSplitAttrID) { + const topSplitVal = dataset?.getStrValue(aCaseID.__id__, topSplitAttrID) + if (topSplitVal !== caseTopSplitValue) return false + } + if (rightSplitAttrID) { + const rightSplitVal = dataset?.getStrValue(aCaseID.__id__, rightSplitAttrID) + if (rightSplitVal !== caseRightSplitValue) return false + } + return true + }) + const { binWidth, minBinEdge } = self.binDetails() + const binIndex = Math.floor((Number(casePrimaryValue) - minBinEdge) / binWidth) + const firstCount = allMatchingCases.length + const secondCount = casesInSubPlot.length + const percent = float(100 * firstCount / secondCount) + const minBinValue = minBinEdge + binIndex * binWidth + const maxBinValue = minBinEdge + (binIndex + 1) * binWidth + // " of (

%) are ≥ L and < U" + const attrArray = [ firstCount, secondCount, percent, minBinValue, maxBinValue ] + const translationKey = firstCount === 1 + ? "DG.HistogramView.barTipNoLegendSingular" + : "DG.HistogramView.barTipNoLegendPlural" + tipText = t(translationKey, {vars: attrArray}) + } else { + const topSplitMatches = self.matchingCasesForAttr(topSplitAttrID, caseTopSplitValue) + const rightSplitMatches = self.matchingCasesForAttr(rightSplitAttrID, caseRightSplitValue) + const bothSplitMatches = topSplitMatches.filter(aCase => rightSplitMatches.includes(aCase)) + const legendMatches = legendAttrID + ? self.matchingCasesForAttr(legendAttrID, caseLegendValue, primaryMatches) + : [] + const totalCases = [ + legendMatches.length, + bothSplitMatches.length, + topSplitMatches.length, + rightSplitMatches.length, + dataset?.cases.length ?? 0 + ].find(length => length > 0) ?? 0 + const legendMatchesInSubplot = legendAttrID + ? casesInSubPlot.filter(aCaseID => dataset?.getStrValue(aCaseID, legendAttrID) === caseLegendValue).length + : 0 + const caseCategoryString = caseLegendValue ? casePrimaryValue : "" + const caseLegendCategoryString = caseLegendValue || casePrimaryValue + const firstCount = legendAttrID ? legendMatchesInSubplot : casesInSubPlot.length + const secondCount = legendAttrID ? casesInSubPlot.length : totalCases + const percent = float(100 * firstCount / secondCount) + // of (

%) are + const attrArray = [ firstCount, secondCount, caseCategoryString, percent, caseLegendCategoryString ] + const translationKey = legendAttrID + ? firstCount === 1 ? "DG.BarChartModel.cellTipSingular" : "DG.BarChartModel.cellTipPlural" + : firstCount === 1 ? "DG.BarChartModel.cellTipNoLegendSingular" : "DG.BarChartModel.cellTipNoLegendPlural" + tipText = t(translationKey, {vars: attrArray}) + } + + return tipText + }, cellParams(primaryCellWidth: number, primaryHeight: number) { const pointDiameter = 2 * self.getPointRadius() const catMap: CatMapType = {} @@ -521,6 +591,10 @@ export const GraphContentModel = DataDisplayContentModel const { binWidth, binAlignment } = self.binDetails({ initialize: true }) self.setBinWidth(binWidth) self.setBinAlignment(binAlignment) + self.pointsAreBinned = true + } else if (configType !== "histogram") { + self.pointsFusedIntoBars = false + self.pointsAreBinned = false } }, setPlotBackgroundColor(color: string) { @@ -541,11 +615,14 @@ export const GraphContentModel = DataDisplayContentModel })) .actions(self => ({ setBarCountAxis() { - const { maxOverAllCells, primaryRole, secondaryRole } = self.dataConfiguration + const { maxOverAllCells, maxCellLength, primaryRole, secondaryRole } = self.dataConfiguration + const { binWidth, minValue, totalNumberOfBins } = self.binDetails() const secondaryPlace = secondaryRole === "y" ? "left" : "bottom" const extraPrimAttrRole = primaryRole === "x" ? "topSplit" : "rightSplit" const extraSecAttrRole = primaryRole === "x" ? "rightSplit" : "topSplit" - const maxCellCaseCount = maxOverAllCells(extraPrimAttrRole, extraSecAttrRole) + const maxCellCaseCount = self.pointDisplayType === "histogram" + ? maxCellLength(extraPrimAttrRole, extraSecAttrRole, binWidth, minValue, totalNumberOfBins) + : maxOverAllCells(extraPrimAttrRole, extraSecAttrRole) const countAxis = NumericAxisModel.create({ scale: "linear", place: secondaryPlace, @@ -566,16 +643,17 @@ export const GraphContentModel = DataDisplayContentModel })) .actions(self => ({ setPointsFusedIntoBars(fuseIntoBars: boolean) { - if (fuseIntoBars !== self.pointsFusedIntoBars) { - if (fuseIntoBars) { - self.setPointConfig("bars") - self.setBarCountAxis() - } else { - self.setPointConfig("points") - self.unsetBarCountAxis() - } - self.pointsFusedIntoBars = fuseIntoBars + if (fuseIntoBars === self.pointsFusedIntoBars) return + + if (fuseIntoBars) { + self.setPointConfig(self.plotType !== "dotPlot" ? "bars" : "histogram") + self.setBarCountAxis() + } else { + self.setPointConfig(self.pointsAreBinned ? "bins" : "points") + self.unsetBarCountAxis() } + + self.pointsFusedIntoBars = fuseIntoBars }, })) .views(self => ({ 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 eeecdaf832..651b4e928e 100644 --- a/v3/src/components/graph/models/graph-data-configuration-model.ts +++ b/v3/src/components/graph/models/graph-data-configuration-model.ts @@ -275,7 +275,10 @@ export const GraphDataConfigurationModel = DataConfigurationModel } })) .views(self => ({ - cellMap(extraPrimaryAttrRole: AttrRole, extraSecondaryAttrRole: AttrRole) { + cellMap( + extraPrimaryAttrRole: AttrRole, extraSecondaryAttrRole: AttrRole, + binWidth = 0, minValue = 0, totalNumberOfBins = 0 + ) { type BinMap = Record>>> const primAttrID = self.primaryRole ? self.attributeID(self.primaryRole) : "", secAttrID = self.secondaryRole ? self.attributeID(self.secondaryRole) : "", @@ -290,20 +293,24 @@ export const GraphDataConfigurationModel = DataConfigurationModel } }), bins: BinMap = {} + valueQuads?.forEach((aValue: any) => { - if (bins[aValue.primary] === undefined) { - bins[aValue.primary] = {} + const primaryValue = totalNumberOfBins > 0 + ? Math.floor((Number(aValue.primary) - minValue) / binWidth) + : aValue.primary + if (bins[primaryValue] === undefined) { + bins[primaryValue] = {} } - if (bins[aValue.primary][aValue.secondary] === undefined) { - bins[aValue.primary][aValue.secondary] = {} + if (bins[primaryValue][aValue.secondary] === undefined) { + bins[primaryValue][aValue.secondary] = {} } - if (bins[aValue.primary][aValue.secondary][aValue.extraPrimary] === undefined) { - bins[aValue.primary][aValue.secondary][aValue.extraPrimary] = {} + if (bins[primaryValue][aValue.secondary][aValue.extraPrimary] === undefined) { + bins[primaryValue][aValue.secondary][aValue.extraPrimary] = {} } - if (bins[aValue.primary][aValue.secondary][aValue.extraPrimary][aValue.extraSecondary] === undefined) { - bins[aValue.primary][aValue.secondary][aValue.extraPrimary][aValue.extraSecondary] = 0 + if (bins[primaryValue][aValue.secondary][aValue.extraPrimary][aValue.extraSecondary] === undefined) { + bins[primaryValue][aValue.secondary][aValue.extraPrimary][aValue.extraSecondary] = 0 } - bins[aValue.primary][aValue.secondary][aValue.extraPrimary][aValue.extraSecondary]++ + bins[primaryValue][aValue.secondary][aValue.extraPrimary][aValue.extraSecondary]++ }) return bins @@ -312,7 +319,7 @@ export const GraphDataConfigurationModel = DataConfigurationModel .views(self => ({ maxOverAllCells(extraPrimaryAttrRole: AttrRole, extraSecondaryAttrRole: AttrRole) { const bins = self.cellMap(extraPrimaryAttrRole, extraSecondaryAttrRole) - // Now find and return the maximum value in the bins + // Find and return the maximum value in the bins return Object.keys(bins).reduce((hMax, hKey) => { return Math.max(hMax, Object.keys(bins[hKey]).reduce((vMax, vKey) => { return Math.max(vMax, Object.keys(bins[hKey][vKey]).reduce((epMax, epKey) => { @@ -323,6 +330,28 @@ export const GraphDataConfigurationModel = DataConfigurationModel }, 0)) }, 0) }, + maxCellLength( + extraPrimaryAttrRole: AttrRole, extraSecondaryAttrRole: AttrRole, + binWidth: number, minValue: number, totalNumberOfBins: number + ) { + const bins = self.cellMap(extraPrimaryAttrRole, extraSecondaryAttrRole, binWidth, minValue, totalNumberOfBins) + // Find and return the length of the record in bins with the most elements + let maxInBin = 0 + for (const pKey in bins) { + const pBin = bins[pKey] + for (const sKey in pBin) { + const sBin = pBin[sKey] + for (const epKey in sBin) { + const epBin = sBin[epKey] + for (const esKey in epBin) { + const esBin = epBin[esKey] + maxInBin = Math.max(maxInBin, esBin) + } + } + } + } + return maxInBin + }, cellKey(index: number) { const { xAttrId, xCats, yAttrId, yCats, topAttrId, topCats, rightAttrId, rightCats } = self.getCategoriesOptions() const rightCatCount = rightCats.length || 1 diff --git a/v3/src/components/graph/utilities/bar-utils.ts b/v3/src/components/graph/utilities/bar-utils.ts new file mode 100644 index 0000000000..c57305a212 --- /dev/null +++ b/v3/src/components/graph/utilities/bar-utils.ts @@ -0,0 +1,75 @@ +import { select } from "d3" +import { handleClickOnBar } from "../../data-display/data-display-utils" +import { IDataConfigurationModel } from "../../data-display/models/data-configuration-model" +import { CellType, IBarCover } from "../graphing-types" +import { GraphLayout } from "../models/graph-layout" +import { SubPlotCells } from "../models/sub-plot-cells" + +interface IRenderBarCoverProps { + barCovers: IBarCover[] + barCoversRef: React.RefObject + dataConfig: IDataConfigurationModel + primaryAttrRole: "x" | "y" +} + +export interface IBarCoverDimensionsProps { + subPlotCells: SubPlotCells + cellIndices: CellType + layout: GraphLayout + maxInCell: number + minInCell?: number + primCatsCount: number +} + +export const barCoverDimensions = (props: IBarCoverDimensionsProps) => { + const { subPlotCells, cellIndices, maxInCell, minInCell = 0, primCatsCount } = props + const { numPrimarySplitBands, numSecondarySplitBands, primaryCellWidth, primaryIsBottom, primarySplitCellWidth, + secondaryCellHeight, secondaryNumericScale } = subPlotCells + const { p: primeCatIndex, ep: primeSplitCatIndex, es: secSplitCatIndex } = cellIndices + const adjustedPrimeSplitIndex = primaryIsBottom + ? primeSplitCatIndex + : numPrimarySplitBands - 1 - primeSplitCatIndex + const offsetPrimarySplit = adjustedPrimeSplitIndex * primarySplitCellWidth + const primaryInvertedIndex = primCatsCount - 1 - primeCatIndex + const offsetPrimary = primaryIsBottom + ? primeCatIndex * primaryCellWidth + offsetPrimarySplit + : primaryInvertedIndex * primaryCellWidth + offsetPrimarySplit + const secondaryCoord = secondaryNumericScale?.(maxInCell) ?? 0 + const secondaryBaseCoord = secondaryNumericScale?.(minInCell) ?? 0 + const secondaryIndex = primaryIsBottom + ? numSecondarySplitBands - 1 - secSplitCatIndex + : secSplitCatIndex + const offsetSecondary = secondaryIndex * secondaryCellHeight + const adjustedSecondaryCoord = primaryIsBottom + ? Math.abs(secondaryCoord / numSecondarySplitBands + offsetSecondary) + : secondaryBaseCoord / numSecondarySplitBands + offsetSecondary + const primaryDimension = primaryCellWidth / 2 + const secondaryDimension = Math.abs(secondaryCoord - secondaryBaseCoord) / numSecondarySplitBands + const barWidth = primaryIsBottom ? primaryDimension : secondaryDimension + const barHeight = primaryIsBottom ? secondaryDimension : primaryDimension + const primaryCoord = offsetPrimary + (primaryCellWidth / 2 - primaryDimension / 2) + const x = primaryIsBottom ? primaryCoord : adjustedSecondaryCoord + const y = primaryIsBottom ? adjustedSecondaryCoord : primaryCoord + + return { x, y, barWidth, barHeight } +} + +export const renderBarCovers = (props: IRenderBarCoverProps) => { + const { barCovers, barCoversRef, dataConfig } = props + select(barCoversRef.current).selectAll("rect").remove() + select(barCoversRef.current).selectAll("rect") + .data(barCovers) + .join((enter) => enter.append("rect") + .attr("class", (d) => d.class) + .attr("data-testid", "bar-cover") + .attr("x", (d) => d.x) + .attr("y", (d) => d.y) + .attr("width", (d) => d.width) + .attr("height", (d) => d.height) + .on("mouseover", function() { select(this).classed("active", true) }) + .on("mouseout", function() { select(this).classed("active", false) }) + .on("click", function(event, d) { + dataConfig && handleClickOnBar({event, dataConfig, barCover: d}) + }) + ) +} diff --git a/v3/src/components/graph/utilities/dot-plot-utils.ts b/v3/src/components/graph/utilities/dot-plot-utils.ts index b102a9ff0c..350d4762d8 100644 --- a/v3/src/components/graph/utilities/dot-plot-utils.ts +++ b/v3/src/components/graph/utilities/dot-plot-utils.ts @@ -8,6 +8,7 @@ import { IDataSet } from "../../../models/data/data-set" import { IGraphDataConfigurationModel } from "../models/graph-data-configuration-model" import { GraphLayout } from "../models/graph-layout" import { AxisPlace } from "../../axis/axis-types" +import { SubPlotCells } from "../models/sub-plot-cells" export interface IComputeBinPlacements { binWidth?: number @@ -50,10 +51,13 @@ export interface IComputePrimaryCoord { export interface IComputeSecondaryCoord { baseCoord: number + dataConfig?: IGraphDataConfigurationModel extraSecondaryAxisScale: ScaleBand extraSecondaryBandwidth: number extraSecondaryCat: string indexInBin: number + isHistogram?: boolean + layout?: GraphLayout numExtraSecondaryBands: number overlap: number pointDiameter: number @@ -78,8 +82,8 @@ export interface IAdjustCoordForStacks { } /* - * Returns a point's coordinate on the primary axis in a dot plot or binned dot plot. It takes into account the - * primary and extra primary axis scales, the extra primary bandwidth, and the number of bins (if any). + * Returns a point's coordinate on the primary axis in a dot plot, binned dot plot, or histogram. It takes into + * account the primary and extra primary axis scales, the extra primary bandwidth, and the number of bins (if any). */ export const computePrimaryCoord = (props: IComputePrimaryCoord) => { const { anID, binWidth = 0, dataset, extraPrimaryAttrID, extraPrimaryAxisScale, isBinned = false, @@ -96,14 +100,14 @@ export const computePrimaryCoord = (props: IComputePrimaryCoord) => { } /* - * Returns a point's coordinate on the secondary axis in a dot plot or binned dot plot. It takes into account the - * primary and secondary axis scales, the primary and secondary bandwidths, the number of extra secondary bands, the - * overlap between points, the point diameter, and the primaryIsBottom flag. + * Returns a point's coordinate on the secondary axis in a dot plot, binned dot plot, or histogram. It takes into + * account the primary and secondary axis scales, the primary and secondary bandwidths, the number of extra secondary + * bands, the overlap between points, the point diameter, and the primaryIsBottom flag. */ export const computeSecondaryCoord = (props: IComputeSecondaryCoord) => { - const { baseCoord, extraSecondaryAxisScale, extraSecondaryBandwidth, extraSecondaryCat, indexInBin, - numExtraSecondaryBands, overlap, pointDiameter, primaryIsBottom, secondaryAxisExtent, secondaryAxisScale, - secondaryBandwidth, secondaryCat, secondarySign } = props + const { baseCoord, dataConfig, extraSecondaryAxisScale, extraSecondaryBandwidth, extraSecondaryCat, indexInBin, + layout, numExtraSecondaryBands, overlap, pointDiameter, primaryIsBottom, secondaryAxisExtent, + secondaryAxisScale, secondaryBandwidth, secondaryCat, secondarySign, isHistogram = false } = props let catCoord = ( !!secondaryCat && secondaryCat !== "__main__" ? secondaryAxisScale(secondaryCat) ?? 0 @@ -111,14 +115,24 @@ export const computeSecondaryCoord = (props: IComputeSecondaryCoord) => { ) / numExtraSecondaryBands let extraCoord = !!extraSecondaryCat && extraSecondaryCat !== "__main__" ? (extraSecondaryAxisScale(extraSecondaryCat) ?? 0) : 0 + + const subPlotCells = layout && new SubPlotCells(layout, dataConfig) + const secondaryNumericUnitLength = subPlotCells ? subPlotCells.secondaryNumericUnitLength : 0 if (primaryIsBottom) { extraCoord = secondaryAxisExtent - extraSecondaryBandwidth - extraCoord catCoord = extraSecondaryBandwidth - secondaryBandwidth - catCoord - return baseCoord - catCoord - extraCoord - - pointDiameter / 2 - indexInBin * (pointDiameter - overlap) + const secondaryCoord = isHistogram + ? baseCoord - catCoord - extraCoord - secondaryNumericUnitLength / 2 - indexInBin * secondaryNumericUnitLength + : baseCoord - catCoord - extraCoord - pointDiameter / 2 - indexInBin * (pointDiameter - overlap) + + return secondaryCoord } else { - return baseCoord + extraCoord + secondarySign * (catCoord + pointDiameter / 2 + - indexInBin * (pointDiameter - overlap)) + const secondaryCoord = isHistogram && secondaryNumericUnitLength + ? baseCoord + extraCoord + secondarySign * + (catCoord + secondaryNumericUnitLength / 2 + indexInBin * secondaryNumericUnitLength) + : baseCoord + extraCoord + secondarySign * (catCoord + pointDiameter / 2 + indexInBin * (pointDiameter - overlap)) + + return secondaryCoord } } @@ -213,6 +227,8 @@ export const calculatePointStacking = (pointCount: number, pointDiameter: number export const adjustCoordForStacks = (props: IAdjustCoordForStacks) => { const { anID, axisType, binForCase, binMap, bins, pointDiameter, secondaryBandwidth, screenCoord, primaryIsBottom } = props + if (!binMap[anID]) return screenCoord + let adjustedCoord = screenCoord const { category, extraCategory, extraPrimaryCategory, indexInBin } = binMap[anID] const casesInBin = binMap[anID]