Skip to content

Commit

Permalink
Merge pull request #859 from concord-consortium/185758373-add-percent…
Browse files Browse the repository at this point in the history
…-graph-adornment

feat: Add percent graph adornment (PT-185758373)
  • Loading branch information
emcelroy authored Aug 29, 2023
2 parents 55009fb + add983a commit 9b1effa
Show file tree
Hide file tree
Showing 23 changed files with 550 additions and 154 deletions.
27 changes: 26 additions & 1 deletion v3/cypress/e2e/adornments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ context("Graph adornments", () => {
graph.getDisplayValuesButton().click()
const inspectorPalette = graph.getInspectorPalette()
inspectorPalette.should("be.visible")
const countCheckbox = inspectorPalette.find("[data-testid=adornment-checkbox-count]")
const countCheckbox = inspectorPalette.find("[data-testid=adornment-checkbox-count-count]")
countCheckbox.should("be.visible")
countCheckbox.click()
cy.get("[data-testid=graph-adornments-grid]").should("exist")
Expand All @@ -35,6 +35,31 @@ context("Graph adornments", () => {
countCheckbox.click()
cy.get("[data-testid=adornment-wrapper]").should("have.class", "fadeOut")
})
it("adds a percent to the graph when the Percent checkbox is checked", () => {
c.selectTile("graph", 0)
cy.dragAttributeToTarget("table", "Diet", "x")
cy.dragAttributeToTarget("table", "Habitat", "y")
graph.getDisplayValuesButton().click()
const inspectorPalette = graph.getInspectorPalette()
inspectorPalette.should("be.visible")
const percentCheckbox = inspectorPalette.find("[data-testid=adornment-checkbox-count-percent]")
percentCheckbox.should("be.visible")
// percentOptions.should("be.visible")
// percentOptions.find("input").should("have.attr", "disabled")
percentCheckbox.click()
// const percentOptions = inspectorPalette.find("[data-testid=adornment-percent-type-options]")
// percentOptions.find("input").should("not.have.attr", "disabled")
cy.get("[data-testid=graph-adornments-grid]").should("exist")
cy.get("[data-testid=graph-adornments-grid]")
.find("[data-testid=graph-adornments-grid__cell]").should("have.length", 9)
cy.get("[data-testid=adornment-wrapper]").should("have.length", 9)
cy.get("[data-testid=adornment-wrapper]").should("have.class", "fadeIn")
cy.get("[data-testid=graph-adornments-grid]").find("*[data-testid^=graph-count]").should("exist")
cy.get("[data-testid=graph-adornments-grid]").find("*[data-testid^=graph-count]").first().should("have.text", "0%")
cy.wait(250)
percentCheckbox.click()
cy.get("[data-testid=adornment-wrapper]").should("have.class", "fadeOut")
})
it("adds a movable line to the graph when the Movable Line checkbox is checked", () => {
c.selectTile("graph", 0)
cy.dragAttributeToTarget("table", "Sleep", "x")
Expand Down
153 changes: 133 additions & 20 deletions v3/src/components/data-display/models/data-configuration-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,6 @@ export const DataConfigurationModel = types
placeShouldShowClickHereCue(place: GraphPlace, tileHasFocus: boolean) {
return this.placeAlwaysShowsClickHereCue(place) ||
(this.placeCanShowClickHereCue(place) && tileHasFocus)
},
isCaseInSubPlot(subPlotKey: Record<string, string>, caseData: Record<string, any>) {
const numOfKeys = Object.keys(subPlotKey).length
let matchedValCount = 0
Object.keys(subPlotKey).forEach(key => {
if (subPlotKey[key] === caseData[key]) matchedValCount++
})
return matchedValCount === numOfKeys
}
}))
.views(self => ({
Expand All @@ -187,18 +179,6 @@ export const DataConfigurationModel = types
}
})
return allGraphCaseIds
},
subPlotCases(subPlotKey: Record<string, string>) {
const casesInPlot = [] as ICase[]
self.filteredCases?.forEach(aFilteredCases => {
aFilteredCases.caseIds.forEach((id) => {
const caseData = self.dataset?.getCase(id)
if (caseData) {
self.isCaseInSubPlot(subPlotKey, caseData) && casesInPlot.push(caseData)
}
})
})
return casesInPlot
}
}))
.actions(self => ({
Expand Down Expand Up @@ -354,8 +334,141 @@ export const DataConfigurationModel = types
numRepetitions = Math.max(this.categoryArrayForAttrRole('topSplit').length, 1)
}
return numRepetitions
},
get attrTypes() {
return {
bottom: self.attributeType("x"),
left: self.attributeType("y"),
top: self.attributeType("topSplit"),
right: self.attributeType("rightSplit")
}
}
}))
.views(self => ({
/**
* For the purpose of computing percentages, we need to know the total number of cases we're counting.
* A "subplot" contains the cases being considered. Subplots are always defined by topSplit and/or rightSplit
* categorical attributes rather than any categorical attributes on the left or bottom.
* A "cell" is defined by zero or more categorical attributes within a subplot.
* A percentage is the percentage of cases within a subplot that are within a given cell.
*/
get categoricalAttrCount() {
const attrTypes = self.attrTypes
return Object.values(attrTypes).filter(a => a === "categorical").length
},
get hasExactlyTwoPerpendicularCategoricalAttrs() {
const attrTypes = self.attrTypes
const xHasCategorical = attrTypes.bottom === "categorical" || attrTypes.top === "categorical"
const yHasCategorical = attrTypes.left === "categorical" || attrTypes.right === "categorical"
const hasOnlyTwoCategorical = this.categoricalAttrCount === 2
return hasOnlyTwoCategorical && xHasCategorical && yHasCategorical
},
get hasSingleSubplot() {
// A graph has a single subplot if it has one or fewer categorical attributes, or if it has exactly two
// categorical attributes on axes that are perpendicular to each other.
return this.categoricalAttrCount <= 1 || this.hasExactlyTwoPerpendicularCategoricalAttrs
}
}))
.views(self => ({
isCaseInSubplot(cellKey: Record<string, string>, caseData: Record<string, any>) {
// Subplots are determined by categorical attributes on the top or right. When there is more than one subplot,
// a case is included if its value(s) for those attribute(s) match the keys for the subplot being considered.
if (self.hasSingleSubplot) return true

const topAttrID = self.attributeID("topSplit")
const rightAttrID = self.attributeID("rightSplit")
const isSubplotMatch = (!topAttrID || (topAttrID && cellKey[topAttrID] === caseData[topAttrID])) &&
(!rightAttrID || (rightAttrID && cellKey[rightAttrID] === caseData[rightAttrID]))

return isSubplotMatch
},
isCaseInCell(cellKey: Record<string, string>, caseData: Record<string, any>) {
const numOfKeys = Object.keys(cellKey).length
let matchedValCount = 0
Object.keys(cellKey).forEach(key => {
if (cellKey[key] === caseData[key]) matchedValCount++
})
return matchedValCount === numOfKeys
}
}))
.views(self => ({
subPlotCases(cellKey: Record<string, string>) {
const casesInPlot: ICase[] = []
self.filteredCases?.forEach(aFilteredCases => {
aFilteredCases.caseIds.forEach((id) => {
const caseData = self.dataset?.getCase(id)
const caseAlreadyMatched = casesInPlot.find(aCase => aCase.__id__ === id)
if (caseData && !caseAlreadyMatched) {
self.isCaseInCell(cellKey, caseData) && casesInPlot.push(caseData)
}
})
})
return casesInPlot
},
rowCases(cellKey: Record<string, string>) {
const casesInRow: ICase[] = []
const leftAttrID = self.attributeID("y")
const leftAttrType = self.attributeType("y")
const leftValue = leftAttrID ? cellKey[leftAttrID] : ""
const rightAttrID = self.attributeID("rightSplit")
const rightValue = rightAttrID ? cellKey[rightAttrID] : ""

self.filteredCases?.forEach(aFilteredCases => {
aFilteredCases.caseIds.forEach(id => {
const caseData = self.dataset?.getCase(id)
if (!caseData) return

const isLeftMatch = !leftAttrID || leftAttrType !== "categorical" ||
(leftAttrType === "categorical" && leftValue === caseData[leftAttrID])
const isRightMatch = !rightAttrID || rightValue === caseData[rightAttrID]

if (isLeftMatch && isRightMatch) {
casesInRow.push(caseData)
}
})
})
return casesInRow
},
columnCases(cellKey: Record<string, string>) {
const casesInCol: ICase[] = []
const bottomAttrID = self.attributeID("x")
const bottomAttrType = self.attributeType("x")
const bottomValue = bottomAttrID ? cellKey[bottomAttrID] : ""
const topAttrID = self.attributeID("topSplit")
const topValue = topAttrID ? cellKey[topAttrID] : ""

self.filteredCases?.forEach(aFilteredCases => {
aFilteredCases.caseIds.forEach(id => {
const caseData = self.dataset?.getCase(id)
if (!caseData) return

const isBottomMatch = !bottomAttrID || bottomAttrType !== "categorical" ||
(bottomAttrType === "categorical" && bottomValue === caseData[bottomAttrID])
const isTopMatch = !topAttrID || topValue === caseData[topAttrID]

if (isBottomMatch && isTopMatch) {
casesInCol.push(caseData)
}
})
})
return casesInCol
},
cellCases(cellKey: Record<string, string>) {
const casesInCell: ICase[] = []

self.filteredCases?.forEach(aFilteredCases => {
aFilteredCases.caseIds.forEach(id => {
const caseData = self.dataset?.getCase(id)
if (!caseData) return

if (self.isCaseInSubplot(cellKey, caseData)) {
casesInCell.push(caseData)
}
})
})
return casesInCell
}
}))
.views(self => ({
getUnsortedCaseDataArray(caseArrayNumber: number): CaseData[] {
return self.filteredCases
Expand Down
40 changes: 40 additions & 0 deletions v3/src/components/graph/adornments/adornment-checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from "react"
import { FormControl, Checkbox } from "@chakra-ui/react"
import t from "../../../utilities/translation/translate"
import { useGraphContentModelContext } from "../hooks/use-graph-content-model-context"
import { getAdornmentContentInfo } from "./adornment-content-info"

interface IProps {
classNameValue: string
labelKey: string
type: string
}

export const AdornmentCheckbox = ({classNameValue, labelKey, type}: IProps) => {
const graphModel = useGraphContentModelContext()
const existingAdornment = graphModel.adornments.find(a => a.type === type)

const handleSetting = (checked: boolean) => {
const componentContentInfo = getAdornmentContentInfo(type)
const adornment = existingAdornment ?? componentContentInfo.modelClass.create()
adornment.updateCategories(graphModel.getUpdateCategoriesOptions())
adornment.setVisibility(checked)
if (checked) {
graphModel.showAdornment(adornment, adornment.type)
} else {
graphModel.hideAdornment(adornment.type)
}
}

return (
<FormControl>
<Checkbox
data-testid={`adornment-checkbox-${classNameValue}`}
defaultChecked={existingAdornment?.isVisible}
onChange={e => handleSetting(e.target.checked)}
>
{t(labelKey)}
</Checkbox>
</FormControl>
)
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react"

export interface IAdornmentComponentInfo {
adornmentEltClass: string
Component: React.ComponentType<any>
Component: React.ComponentType<any> // TODO: Create and use IAdornmentComponentProps instead of any?
Controls: React.ComponentType<any> // TODO: Create and use IAdornmentControlsProps instead of any?
labelKey: string
order: number
type: string
Expand Down
16 changes: 8 additions & 8 deletions v3/src/components/graph/adornments/adornment-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe("AdornmentModel", () => {
adornment.setVisibility(false)
expect(adornment.isVisible).toBe(false)
})
it("will create a sub plot key from given values", () => {
it("will create a cell key from given values", () => {
const options = {
xAttrId: "abc123",
xCats: ["pizza", "pasta", "salad"],
Expand All @@ -52,23 +52,23 @@ describe("AdornmentModel", () => {
rightCats: ["new", "used"]
}
const adornment = AdornmentModel.create({type: "Movable Line"})
const subPlotKey = adornment.setSubPlotKey(options, 0)
expect(subPlotKey).toEqual({abc123: "pizza", def456: "red", ghi789: "small", jkl012: "new"})
const cellKey = adornment.setCellKey(options, 0)
expect(cellKey).toEqual({abc123: "pizza", def456: "red", ghi789: "small", jkl012: "new"})
})
it("will create an instance key value from given category values", () => {
const adornment = AdornmentModel.create({type: "Movable Line"})
const xCategories = ["pizza", "pasta", "salad"]
const yCategories = ["red", "green", "blue"]
const subPlotKey = {abc123: xCategories[0], def456: yCategories[0]}
const cellKey = {abc123: xCategories[0], def456: yCategories[0]}
expect(adornment.instanceKey({})).toEqual("{}")
expect(adornment.instanceKey(subPlotKey)).toEqual("{\"abc123\":\"pizza\",\"def456\":\"red\"}")
expect(adornment.instanceKey(cellKey)).toEqual("{\"abc123\":\"pizza\",\"def456\":\"red\"}")
})
it("will create a class name from a given subplot key", () => {
it("will create a class name from a given cell key", () => {
const adornment = AdornmentModel.create({type: "Movable Line"})
const xCategories = ["pizza", "pasta", "salad"]
const yCategories = ["red", "green", "blue"]
const subPlotKey = {abc123: xCategories[0], def456: yCategories[0]}
expect(adornment.classNameFromKey(subPlotKey)).toEqual("abc123-pizza-def456-red")
const cellKey = {abc123: xCategories[0], def456: yCategories[0]}
expect(adornment.classNameFromKey(cellKey)).toEqual("abc123-pizza-def456-red")
})
})

Expand Down
26 changes: 13 additions & 13 deletions v3/src/components/graph/adornments/adornment-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import {Instance, types} from "mobx-state-tree"
import { IAxisModel } from "../../axis/models/axis-model"
import {typedId} from "../../../utilities/js-utils"
import {safeDomIdentifier, typedId} from "../../../utilities/js-utils"
import {Point} from "../../data-display/data-display-types"

export const PointModel = types.model("Point", {
Expand Down Expand Up @@ -48,13 +48,13 @@ export const AdornmentModel = types.model("AdornmentModel", {
isVisible: true
})
.views(self => ({
instanceKey(subPlotKey: Record<string, string>) {
return JSON.stringify(subPlotKey)
instanceKey(cellKey: Record<string, string>) {
return JSON.stringify(cellKey)
},
classNameFromKey(subPlotKey: Record<string, string>) {
classNameFromKey(cellKey: Record<string, string>) {
let className = ""
Object.entries(subPlotKey).forEach(([key, value]) => {
const valueNoSpaces = value.replace(/\s+/g, "-")
Object.entries(cellKey).forEach(([key, value]) => {
const valueNoSpaces = safeDomIdentifier(value)
className += `${className ? "-" : ""}${key}-${valueNoSpaces}`
})
return className
Expand All @@ -67,14 +67,14 @@ export const AdornmentModel = types.model("AdornmentModel", {
updateCategories(options: IUpdateCategoriesOptions) {
// derived models should override to update their models when categories change
},
setSubPlotKey(options: IUpdateCategoriesOptions, index: number) {
setCellKey(options: IUpdateCategoriesOptions, index: number) {
const { xAttrId, xCats, yAttrId, yCats, topAttrId, topCats, rightAttrId, rightCats } = options
const subPlotKey: Record<string, string> = {}
if (topAttrId) subPlotKey[topAttrId] = topCats?.[index % topCats.length]
if (rightAttrId) subPlotKey[rightAttrId] = rightCats?.[Math.floor(index / topCats.length)]
if (yAttrId && yCats[0]) subPlotKey[yAttrId] = yCats?.[index % yCats.length]
if (xAttrId && xCats[0]) subPlotKey[xAttrId] = xCats?.[index % xCats.length]
return subPlotKey
const cellKey: Record<string, string> = {}
if (topAttrId) cellKey[topAttrId] = topCats?.[index % topCats.length]
if (rightAttrId) cellKey[rightAttrId] = rightCats?.[Math.floor(index / topCats.length)]
if (yAttrId && yCats[0]) cellKey[yAttrId] = yCats?.[index % yCats.length]
if (xAttrId && xCats[0]) cellKey[xAttrId] = xCats?.[index % xCats.length]
return cellKey
}
}))
export interface IAdornmentModel extends Instance<typeof AdornmentModel> {}
Expand Down
Loading

0 comments on commit 9b1effa

Please sign in to comment.