Skip to content

Commit

Permalink
Merge pull request #875 from concord-consortium/181914428-movable-val…
Browse files Browse the repository at this point in the history
…ues-can-be-placed-on-a-dot-plot

feat: Movable values can be placed on a dot plot (PT-185758373)
  • Loading branch information
emcelroy authored Sep 15, 2023
2 parents 0712c47 + 36e5455 commit 8e5adce
Show file tree
Hide file tree
Showing 19 changed files with 644 additions and 128 deletions.
38 changes: 37 additions & 1 deletion v3/cypress/e2e/adornments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,5 +107,41 @@ context("Graph adornments", () => {
movablePointCheckbox.click()
cy.get("[data-testid=adornment-wrapper]").should("have.class", "hidden")
})

it("adds a movable value to the graph when the Movable Value button is clicked", () => {
c.selectTile("graph", 0)
cy.dragAttributeToTarget("table", "Sleep", "x")
graph.getDisplayValuesButton().click()
const inspectorPalette = graph.getInspectorPalette()
inspectorPalette.should("be.visible")
const movableValueButton = inspectorPalette.find("[data-testid=adornment-button-movable-value]")
movableValueButton.should("be.visible")
movableValueButton.click()
cy.get("[data-testid=adornment-button-movable-value--add]").should("be.visible")
cy.get("[data-testid=adornment-button-movable-value--add]").click()
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", 1)
cy.get("[data-testid=adornment-wrapper]").should("have.length", 1)
cy.get("[data-testid=adornment-wrapper]").should("have.class", "visible")
cy.get("[data-testid=graph-adornments-grid]").find("*[data-testid^=movable-value]").should("exist")
cy.get(".movable-value-label").should("have.length", 1)
cy.get(".movable-value-fill").should("have.length", 0)
movableValueButton.click()
cy.get("[data-testid=adornment-button-movable-value--add]").click()
cy.get(".movable-value-label").should("have.length", 2)
cy.get(".movable-value-fill").should("have.length", 1)
// TODO: Also test the above after attributes are added to top and right axes (i.e. when there are multiple values)
// TODO: Test dragging of value
cy.wait(250)
movableValueButton.click()
cy.get("[data-testid=adornment-button-movable-value--remove]").click()
cy.get(".movable-value-label").should("have.length", 1)
cy.get(".movable-value-fill").should("have.length", 0)
cy.wait(250)
movableValueButton.click()
cy.get("[data-testid=adornment-button-movable-value--remove]").click()
cy.get("[data-testid=adornment-wrapper]").should("have.class", "hidden")
cy.get(".movable-value-label").should("have.length", 0)
cy.get(".movable-value-fill").should("have.length", 0)
})
})
5 changes: 3 additions & 2 deletions v3/src/components/graph/adornments/adornment-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,13 @@ describe("Deserialization", () => {
const testModel3 = M.create(snap2)
expect(isMovablePoint(testModel3.adornment) && testModel3.adornment.points).toBeDefined()

const movableValue = MovableValueModel.create({ type: "Movable Value", value: 1 })
const movableValue = MovableValueModel.create()
movableValue.setInitialValue()
testModel.setAdornment(movableValue)
expect(isMovablePoint(testModel.adornment) && testModel.adornment.points).toBeDefined()
const snap3 = getSnapshot(testModel)
const testModel4 = M.create(snap3)
expect(isMovableValue(testModel4.adornment) && testModel4.adornment.value).toBeDefined()
expect(isMovableValue(testModel4.adornment) && testModel4.adornment.values).toBeDefined()

const unknownAdornment = UnknownAdornmentModel.create()
testModel.setAdornment(unknownAdornment)
Expand Down
22 changes: 18 additions & 4 deletions v3/src/components/graph/adornments/adornment-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,25 @@ export const AdornmentModel = types.model("AdornmentModel", {
},
setCellKey(options: IUpdateCategoriesOptions, index: number) {
const { xAttrId, xCats, yAttrId, yCats, topAttrId, topCats, rightAttrId, rightCats } = options
const topCatCount = topCats.length || 1
const rightCatCount = rightCats.length || 1
const xCatCount = xCats.length || 1
const yCatCount = yCats.length || 1
const columnCount = topCatCount * xCatCount
const rowCount = rightCatCount * yCatCount
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]
const topCat = topCats[index % topCats.length]
const rightCat = rightCats[index % rightCats.length]
const yCat = topCats.length > 0
? yCats[Math.floor(index / columnCount) % yCatCount]
: yCats[index % yCats.length]
const xCat = rightCats.length > 0
? xCats[Math.floor(index / rowCount) % xCatCount]
: xCats[index % xCats.length]
if (topAttrId) cellKey[topAttrId] = topCat
if (rightAttrId) cellKey[rightAttrId] = rightCat
if (yAttrId && yCats[0]) cellKey[yAttrId] = yCat
if (xAttrId && xCats[0]) cellKey[xAttrId] = xCat
return cellKey
}
}))
Expand Down
3 changes: 2 additions & 1 deletion v3/src/components/graph/adornments/adornments.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
grid-auto-flow: row;
font-size: 12px;
height: auto;
padding-right: 3px;
padding-right: 0;
pointer-events: none;
text-align: right;
width: auto;
Expand All @@ -22,6 +22,7 @@
}

.graph-adornments-grid__cell {
overflow: hidden;
position: relative;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ export const MovableLine = observer(function MovableLine(props: IProps) {
return (
<svg
className={`line-${model.classNameFromKey(cellKey)}`}
style={{height: `${plotHeight}px`, width: `${plotWidth}px`}}
style={{height: "100%", width: "100%"}}
x={0}
y={0}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export const MovablePoint = observer(function MovablePoint(props: IProps) {
return (
<svg
className={`point-${classFromKey}`}
style={{height: `${plotHeight}px`, width: `${plotWidth}px`}}
style={{height: "100%", width: "100%"}}
x={0}
y={0}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,68 @@
import { MovableValueModel } from "./movable-value-model"

describe("MovableValueModel", () => {
it("can be created with a value", () => {
const movableValue = MovableValueModel.create({value: 1})
expect(movableValue.value).toEqual(1)
})
it("can have its value changed", () => {
const movableValue = MovableValueModel.create({value: 1})
movableValue.setValue(2)
expect(movableValue.value).toEqual(2)
it("can be created with a value and have that value changed", () => {
const movableValue = MovableValueModel.create()
movableValue.setInitialValue(5, "{}")
expect(movableValue.values.get("{}")).toEqual([5])
movableValue.replaceValue(10, "{}")
expect(movableValue.values.get("{}")).toEqual([10])
})
it("can be created with a value and have another value added", () => {
const movableValue = MovableValueModel.create()
movableValue.setInitialValue(5, "{}")
expect(movableValue.values.get("{}")).toEqual([5])
movableValue.addValue(10)
expect(movableValue.values.get("{}")).toEqual([5, 10])
})
it("can delete a value in a set given that value's index number in the array of values", () => {
const movableValue = MovableValueModel.create()
movableValue.setInitialValue(5, "{}")
movableValue.addValue(10)
expect(movableValue.values.get("{}")).toEqual([5, 10])
movableValue.deleteValue()
expect(movableValue.values.get("{}")).toEqual([5])
})
it("can delete a value set", () => {
const movableValue = MovableValueModel.create()
movableValue.setInitialValue(5, "{}")
expect(movableValue.values.get("{}")).toEqual([5])
movableValue.deleteAllValues()
expect(movableValue.values.get("{}")).toEqual([])
})
it("can provide a sorted list of values", () => {
const movableValue = MovableValueModel.create()
movableValue.setInitialValue(5, "{}")
movableValue.addValue(10)
movableValue.addValue(2)
expect(movableValue.sortedValues("{}")).toEqual([2, 5, 10])
})
it("can provide a list of values that has been updated with a current drag value", () => {
const movableValue = MovableValueModel.create()
movableValue.setInitialValue(5, "{}")
movableValue.addValue(10)
movableValue.addValue(2)
movableValue.updateDrag(7, "{}", 0)
expect(movableValue.valuesForKey("{}")).toEqual([7, 10, 2])
movableValue.endDrag(7, "{}", 0)
expect(movableValue.valuesForKey("{}")).toEqual([7, 10, 2])
})
it("can provide a sorted list of values that has been updated with a current drag value", () => {
const movableValue = MovableValueModel.create()
movableValue.setInitialValue(5, "{}")
movableValue.addValue(10)
movableValue.addValue(2)
movableValue.updateDrag(7, "{}", 0)
expect(movableValue.sortedValues("{}")).toEqual([2, 7, 10])
movableValue.endDrag(7, "{}", 0)
expect(movableValue.sortedValues("{}")).toEqual([2, 7, 10])
})
it("can provide a new value that is 1/3 into the largest gap between values", () => {
const movableValue = MovableValueModel.create()
movableValue.setAxisMin(0)
movableValue.setAxisMax(20)
movableValue.setInitialValue(10, "{}")
movableValue.addValue(15)
expect(movableValue.newValue("{}")).toEqual(3.3333333333333335)
})
})
Original file line number Diff line number Diff line change
@@ -1,18 +1,151 @@
import { Instance, types } from "mobx-state-tree"
import { AdornmentModel, IAdornmentModel } from "../adornment-models"
import { AdornmentModel, IAdornmentModel, IUpdateCategoriesOptions } from "../adornment-models"
import { kMovableValueType } from "./movable-value-types"
import { INumericAxisModel } from "../../../axis/models/axis-model"

export const MovableValueModel = AdornmentModel
.named('MovableValueModel')
.props({
type: 'Movable Value',
value: types.number,
values: types.map(types.array(types.number)),
})
.volatile(() => ({
axisMin: 0,
axisMax: 0,
dragIndex: -1,
dragKey: "",
dragValue: 0
}))
.views(self => ({
get isDragging() {
return self.dragIndex >= 0
},
}))
.views(self => ({
get hasValues() {
return [...self.values.values()].some(valueArray => valueArray.length > 0)
},
get firstValueArray() {
return self.values.values().next().value
},
valuesForKey(key="{}") {
const values = self.values.get(key) || []
if (!self.isDragging || key !== self.dragKey) return values
const latestValues = [...values]
latestValues[self.dragIndex] = self.dragValue
return latestValues
}
}))
.views(self => ({
sortedValues(key?: string) {
const values = self.valuesForKey(key) ?? self.firstValueArray
return [...values].sort((a, b) => a - b)
}
}))
.views(self => ({
newValue(key="{}") {
// New movable values are always placed within the largest gap existing between the
// axis min, any existing movable values, and the axis max. The exact placement is
// 1/3 of the way into the gap from the lower bound.
const sortedValues = self.sortedValues(key)
const validValues = sortedValues.filter(value => value >= self.axisMin && value <= self.axisMax)
const allValues = [self.axisMin, ...validValues, self.axisMax]
const gaps = allValues.map((value, index) => {
const size = index < allValues.length - 1 ? Math.abs(value - allValues[index + 1]) : 0
return { start: value, size }
})
const largestGap = gaps.reduce((prev, curr) => prev.size > curr.size ? prev : curr)
return largestGap.start + largestGap.size / 3
}
}))
.actions(self => ({
setValue(aValue: number) {
self.value = aValue
addValue(aValue?: number) {
self.values.forEach((values, key) => {
const newValue = !aValue ? self.newValue(key) : aValue
const newValues = [...values]
newValues.push(newValue)
self.values.set(key, newValues)
})
},
replaceValue(aValue: number, key="{}", index=0) {
const newValues = [...self.valuesForKey(key)]
newValues[index] = aValue
self.values.set(key, newValues)
},
deleteValue() {
self.values.forEach((values, key) => {
const newValues = [...values]
const lastValueIndex = newValues.length > 0 ? newValues.length - 1 : 0
newValues.splice(lastValueIndex, 1)
self.values.set(key, newValues)
if (lastValueIndex <= 0) {
self.values.set(key, [])
}
})
},
deleteAllValues() {
self.values.forEach((value, key) => {
self.values.set(key, [])
})
}
}))
.actions(self => ({
setAxisMin(aValue: number) {
self.axisMin = aValue
},
setAxisMax(aValue: number) {
self.axisMax = aValue
},
setInitialValue(aValue=10, key="{}") {
self.deleteAllValues()
self.values.set(key, [])
self.addValue(aValue)
},
updateDrag(value: number, instanceKey: string, index: number) {
self.dragIndex = index
self.dragKey = instanceKey
self.dragValue = value
},
endDrag(value: number, instanceKey: string, index: number) {
self.replaceValue(value, instanceKey, index)
self.dragIndex = -1
self.dragKey = ""
self.dragValue = 0
}
}))
.actions(self => ({
updateCategories(options: IUpdateCategoriesOptions) {
const { xAxis, xCats, yAxis, yCats, topCats, rightCats, resetPoints } = options
const topCatCount = topCats.length || 1
const rightCatCount = rightCats.length || 1
const xCatCount = xCats.length || 1
const yCatCount = yCats.length || 1
const columnCount = topCatCount * xCatCount
const rowCount = rightCatCount * yCatCount
const totalCount = rowCount * columnCount
const axisMin = xAxis?.isNumeric ? (xAxis as INumericAxisModel).min : (yAxis as INumericAxisModel).min
const axisMax = xAxis?.isNumeric ? (xAxis as INumericAxisModel).max : (yAxis as INumericAxisModel).max

self.setAxisMin(axisMin)
self.setAxisMax(axisMax)

for (let i = 0; i < totalCount; ++i) {
const subPlotKey = self.setCellKey(options, i)
const instanceKey = self.instanceKey(subPlotKey)
// Each array in the model's values map should have the same length as all the others. If there are no existing
// values for the current instance key, check if there is at least one array in the map. If there is, copy those
// values. Otherwise, set the array to []. We will add any new values to the array after the loop.
const existingValues = self.values.get(instanceKey) || self.firstValueArray || []
self.values.set(instanceKey, [...existingValues])
}

// If this action was triggered by the attributes changing (i.e., resetPoints is true), do not add a new value.
if (resetPoints) return

self.addValue()
}
}))

export interface IMovableValueModel extends Instance<typeof MovableValueModel> {}
export function isMovableValue(adornment: IAdornmentModel): adornment is IMovableValueModel {
return adornment.type === kMovableValueType
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getAdornmentComponentInfo } from "../adornment-component-info"
import { getAdornmentContentInfo } from "../adornment-content-info"
import { kMovableValueClass, kMovableValuePrefix, kMovableValueType } from "./movable-value-types"
import "./movable-value-registration"

describe("Movable value registration", () => {
it("Registers content and component info", () => {
const movableValueContentInfo = getAdornmentContentInfo(kMovableValueType)
expect(movableValueContentInfo).toBeDefined()
expect(movableValueContentInfo?.type).toBe(kMovableValueType)
expect(movableValueContentInfo?.modelClass).toBeDefined()
expect(movableValueContentInfo?.prefix).toBe(kMovableValuePrefix)
const movableValueComponentInfo = getAdornmentComponentInfo(kMovableValueType)
expect(movableValueComponentInfo).toBeDefined()
expect(movableValueComponentInfo?.adornmentEltClass).toBe(kMovableValueClass)
expect(movableValueComponentInfo?.Component).toBeDefined()
expect(movableValueComponentInfo?.type).toBe(kMovableValueType)
})
})
Loading

0 comments on commit 8e5adce

Please sign in to comment.