From beafe6442108dbe5fb36c92f18eaaa95c769cb2a Mon Sep 17 00:00:00 2001 From: Teale Fristoe Date: Thu, 7 Nov 2024 16:53:17 -0800 Subject: [PATCH] 181910061 Numeric Legend Color Picker (#1594) * Change low and high attribute colors using pickers from eye menu. * Update legend and graph dots when attribute colors change. --- .../components/legend/numeric-legend.tsx | 18 +++-- .../inspector/display-item-format-control.tsx | 54 +++++++++---- .../models/data-configuration-model.ts | 37 +++++++-- v3/src/components/graph/hooks/use-plot.ts | 77 ++++++++----------- .../codap/create-codap-document.test.ts | 1 + .../shared/shared-case-metadata-constants.ts | 4 + v3/src/models/shared/shared-case-metadata.ts | 38 ++++++++- v3/src/utilities/color-utils.ts | 19 +++++ 8 files changed, 178 insertions(+), 70 deletions(-) create mode 100644 v3/src/models/shared/shared-case-metadata-constants.ts diff --git a/v3/src/components/data-display/components/legend/numeric-legend.tsx b/v3/src/components/data-display/components/legend/numeric-legend.tsx index dbcfdc0da3..94f00fd365 100644 --- a/v3/src/components/data-display/components/legend/numeric-legend.tsx +++ b/v3/src/components/data-display/components/legend/numeric-legend.tsx @@ -1,5 +1,5 @@ import {ScaleQuantile, scaleQuantile, schemeBlues} from "d3" -import {reaction} from "mobx" +import {comparer, reaction} from "mobx" import {observer} from "mobx-react-lite" import React, {useCallback, useEffect, useRef, useState} from "react" import { mstReaction } from "../../../../utilities/mst-reaction" @@ -22,12 +22,13 @@ export const NumericLegend = quantileScale = useRef>(scaleQuantile()), [choroplethElt, setChoroplethElt] = useState(null), valuesRef = useRef([]), + metadata = dataConfiguration?.metadata, + legendAttrID = dataConfiguration?.attributeID("legend") ?? "", getLabelHeight = useCallback(() => { - const labelFont = vars.labelFont, - legendAttrID = dataConfiguration?.attributeID('legend') ?? '' + const labelFont = vars.labelFont return getStringBounds(dataConfiguration?.dataset?.attrFromID(legendAttrID)?.name ?? '', labelFont).height - }, [dataConfiguration]), + }, [dataConfiguration, legendAttrID]), refreshScale = useCallback(() => { const numberHeight = getStringBounds('0').height @@ -63,7 +64,7 @@ export const NumericLegend = } setDesiredExtent(layerIndex, computeDesiredExtent()) - quantileScale.current.domain(valuesRef.current).range(schemeBlues[5]) + quantileScale.current.domain(valuesRef.current).range(dataConfiguration?.quantileScaleColors ?? schemeBlues[5]) choroplethLegend(quantileScale.current, choroplethElt, { isDate: dataConfiguration?.attributeType('legend') === 'date', @@ -115,6 +116,13 @@ export const NumericLegend = {name: "NumericLegend respondToHiddenCaseChange"}, dataConfiguration) }, [dataConfiguration, refreshScale]) + useEffect(function respondToColorChange() { + return mstReaction( + () => metadata?.getAttributeColorRange(legendAttrID), + refreshScale, { name: "NumericLegend respondToColorChange", equals: comparer.structural }, metadata + ) + }, [legendAttrID, metadata, refreshScale]) + // todo: This reaction is not being triggered when a legend attribute value is changed. // It should be. useEffect(function respondToNumericValuesChange() { diff --git a/v3/src/components/data-display/inspector/display-item-format-control.tsx b/v3/src/components/data-display/inspector/display-item-format-control.tsx index bb479a854f..c4866ffe05 100644 --- a/v3/src/components/data-display/inspector/display-item-format-control.tsx +++ b/v3/src/components/data-display/inspector/display-item-format-control.tsx @@ -1,11 +1,13 @@ -import React, {useRef} from "react" -import {observer} from "mobx-react-lite" -import {Checkbox, Flex, FormControl, FormLabel, Slider, SliderThumb, SliderTrack - } from "@chakra-ui/react" +import React, { useRef } from "react" +import { observer } from "mobx-react-lite" +import { Checkbox, Flex, FormControl, FormLabel, Slider, SliderThumb, SliderTrack } from "@chakra-ui/react" +import { + kDefaultHighAttributeColor, kDefaultLowAttributeColor +} from "../../../models/shared/shared-case-metadata-constants" +import { t } from "../../../utilities/translation/translate" +import { IDataConfigurationModel } from "../models/data-configuration-model" +import { IDisplayItemDescriptionModel } from "../models/display-item-description-model" import { PointColorSetting } from "./point-color-setting" -import {IDataConfigurationModel} from "../models/data-configuration-model" -import {IDisplayItemDescriptionModel} from "../models/display-item-description-model" -import {t} from "../../../utilities/translation/translate" import "./inspector-panel.scss" @@ -28,6 +30,7 @@ export const DisplayItemFormatControl = observer(function PointFormatControl(pro const attrType = dataConfiguration?.dataset?.attrFromID(legendAttrID ?? "")?.type const categoriesRef = useRef() categoriesRef.current = dataConfiguration?.categoryArrayForAttrRole('legend') + const metadata = dataConfiguration.metadata const handlePointColorChange = (color: string) => { displayItemDescription.applyModelChange(() => { @@ -39,6 +42,26 @@ export const DisplayItemFormatControl = observer(function PointFormatControl(pro }) } + const handleLowAttributeColorChange = (color: string) => { + metadata?.applyModelChange(() => { + metadata.setAttributeColor(legendAttrID, color, "low") + }, { + undoStringKey: "DG.Undo.graph.changeAttributeColor", + redoStringKey: "DG.Redo.graph.changeAttributeColor", + log: "Changed attribute color" + }) + } + + const handleHighAttributeColorChange = (color: string) => { + metadata?.applyModelChange(() => { + metadata.setAttributeColor(legendAttrID, color, "high") + }, { + undoStringKey: "DG.Undo.graph.changeAttributeColor", + redoStringKey: "DG.Redo.graph.changeAttributeColor", + log: "Changed attribute color" + }) + } + const handleCatPointColorChange = (color: string, cat: string) => { dataConfiguration.applyModelChange( () => { @@ -116,6 +139,7 @@ export const DisplayItemFormatControl = observer(function PointFormatControl(pro } } + const colorRange = metadata?.getAttributeColorRange(legendAttrID) return ( @@ -149,12 +173,16 @@ export const DisplayItemFormatControl = observer(function PointFormatControl(pro {t("DG.Inspector.legendColor")} {/* Sets the min and max colors for numeric legend. Currently not implemented so this sets the same color for all the points*/} - handlePointColorChange(color)} - swatchBackgroundColor={displayItemDescription.pointColor}/> - handlePointColorChange(color)} - swatchBackgroundColor={displayItemDescription.pointColor}/> + handleLowAttributeColorChange(color)} + swatchBackgroundColor={colorRange?.low ?? kDefaultLowAttributeColor} + /> + handleHighAttributeColorChange(color)} + swatchBackgroundColor={colorRange?.high ?? kDefaultHighAttributeColor} + /> :( diff --git a/v3/src/components/data-display/models/data-configuration-model.ts b/v3/src/components/data-display/models/data-configuration-model.ts index 73fcb03139..97b6f0dc65 100644 --- a/v3/src/components/data-display/models/data-configuration-model.ts +++ b/v3/src/components/data-display/models/data-configuration-model.ts @@ -1,4 +1,4 @@ -import {scaleQuantile, ScaleQuantile, schemeBlues} from "d3" +import {scaleQuantile, ScaleQuantile} from "d3" import {comparer, observable, reaction} from "mobx" import { addDisposer, getEnv, getSnapshot, hasEnv, IAnyStateTreeNode, Instance, ISerializedActionCall, @@ -17,12 +17,15 @@ import {ISharedCaseMetadata, SharedCaseMetadata} from "../../../models/shared/sh import {isSetCaseValuesAction} from "../../../models/data/data-set-actions" import {FilteredCases, IFilteredChangedCases} from "../../../models/data/filtered-cases" import {Formula, IFormula} from "../../../models/formula/formula" +import { + kDefaultHighAttributeColor, kDefaultLowAttributeColor +} from "../../../models/shared/shared-case-metadata-constants" import {hashStringSets, typedId, uniqueId} from "../../../utilities/js-utils" -import {missingColor} from "../../../utilities/color-utils" +import {getQuantileScale, missingColor} from "../../../utilities/color-utils" +import { numericSortComparator } from "../../../utilities/data-utils" +import {GraphPlace} from "../../axis-graph-shared" import {CaseData} from "../d3-types" import {AttrRole, TipAttrRoles, graphPlaceToAttrRole} from "../data-display-types" -import {GraphPlace} from "../../axis-graph-shared" -import { numericSortComparator } from "../../../utilities/data-utils" export const AttributeDescription = types .model('AttributeDescription', { @@ -398,23 +401,45 @@ export const DataConfigurationModel = types ) return joinedCaseData }, + get lowColor() { + const attrId = self.attributeID("legend") + return self.metadata?.getAttributeColorRange(attrId).low + }, + get highColor() { + const attrId = self.attributeID("legend") + return self.metadata?.getAttributeColorRange(attrId).high + } })) .views(self => ({ // observable hash of rendered case ids get caseDataHash() { return hashStringSets(self.filteredCases.map(cases => cases.caseIds)) + }, + get quantileScaleColors() { + return getQuantileScale( + self.lowColor ?? kDefaultLowAttributeColor, + self.highColor ?? kDefaultHighAttributeColor + ) } })) .extend(self => { // TODO: This is a hack to get around the fact that MST doesn't seem to cache this as expected // when implemented as simple view. let quantileScale: ScaleQuantile | undefined = undefined + let previousLowAttributeColor: string | undefined + let previousHighAttributeColor: string | undefined return { views: { get legendQuantileScale() { - if (!quantileScale) { - quantileScale = scaleQuantile(self.numericValuesForAttrRole('legend'), schemeBlues[5]) + if ( + !quantileScale || + previousLowAttributeColor !== self.lowColor || + previousHighAttributeColor !== self.highColor + ) { + previousLowAttributeColor = self.lowColor + previousHighAttributeColor = self.highColor + quantileScale = scaleQuantile(self.numericValuesForAttrRole('legend'), self.quantileScaleColors) } return quantileScale }, diff --git a/v3/src/components/graph/hooks/use-plot.ts b/v3/src/components/graph/hooks/use-plot.ts index fd78d61d90..24ef251779 100644 --- a/v3/src/components/graph/hooks/use-plot.ts +++ b/v3/src/components/graph/hooks/use-plot.ts @@ -1,4 +1,4 @@ -import {useEffect} from "react" +import { useCallback, useEffect } from "react" import { comparer, reaction } from "mobx" import {isAlive} from "mobx-state-tree" import {onAnyAction} from "../../../utilities/mst-utils" @@ -52,13 +52,27 @@ export const usePlotResponders = (props: IPlotResponderProps) => { startAnimation = graphModel.startAnimation, layout = useGraphLayoutContext(), dataConfiguration = graphModel.dataConfiguration, + legendAttrID = dataConfiguration?.attributeID("legend"), dataset = dataConfiguration?.dataset, + metadata = dataConfiguration?.metadata, instanceId = useInstanceIdContext() const callRefreshPointPositions = useDebouncedCallback((selectedOnly: boolean) => { refreshPointPositions(selectedOnly) }) + const callMatchCirclesToData = useCallback(() => { + pixiPoints && matchCirclesToData({ + dataConfiguration, + pointRadius: graphModel.getPointRadius(), + pointColor: graphModel.pointDescription.pointColor, + pointDisplayType: graphModel.pointDisplayType, + pointStrokeColor: graphModel.pointDescription.pointStrokeColor, + pixiPoints, + startAnimation, instanceId + }) + }, [dataConfiguration, graphModel, instanceId, pixiPoints, startAnimation]) + // Refresh point positions when pixiPoints become available to fix this bug: // https://www.pivotaltracker.com/story/show/188333898 // This might be a workaround for the fact that useDebouncedCallback may not be updated when pixiPoints @@ -140,30 +154,21 @@ export const usePlotResponders = (props: IPlotResponderProps) => { if (!pixiPoints) { return } - matchCirclesToData({ - dataConfiguration, - pointRadius: graphModel.getPointRadius(), - pointColor: graphModel.pointDescription.pointColor, - pointDisplayType: graphModel.pointDisplayType, - pointStrokeColor: graphModel.pointDescription.pointStrokeColor, - pixiPoints, - startAnimation, instanceId - }) + callMatchCirclesToData() callRefreshPointPositions(false) }, {name: "respondToHiddenCasesChange"}, dataConfiguration ) return () => disposer() - }, [callRefreshPointPositions, dataConfiguration, graphModel, instanceId, pixiPoints, startAnimation]) + }, [callMatchCirclesToData, callRefreshPointPositions, dataConfiguration, pixiPoints]) // respond to axis range changes (e.g. component resizing) useEffect(() => { - const disposer = reaction( + return reaction( () => [layout.getAxisLength('left'), layout.getAxisLength('bottom')], () => { callRefreshPointPositions(false) }, {name: "usePlot [axis range]"} ) - return () => disposer() }, [layout, callRefreshPointPositions]) // respond to selection changes @@ -179,15 +184,7 @@ export const usePlotResponders = (props: IPlotResponderProps) => { const allCases = dataset.items.map(c => c.__id__) const updatedHiddenCases = allCases.filter(caseID => !selectedCases.includes(caseID)) dataConfiguration?.setHiddenCases(updatedHiddenCases) - pixiPoints && matchCirclesToData({ - dataConfiguration, - pointRadius: graphModel.getPointRadius(), - pointColor: graphModel.pointDescription.pointColor, - pointDisplayType: graphModel.pointDisplayType, - pointStrokeColor: graphModel.pointDescription.pointStrokeColor, - pixiPoints, - startAnimation, instanceId - }) + callMatchCirclesToData() callRefreshPointPositions(false) } refreshPointSelection() @@ -195,8 +192,7 @@ export const usePlotResponders = (props: IPlotResponderProps) => { {name: "useSubAxis.respondToSelectionChanges"}, dataConfiguration ) } - }, [callRefreshPointPositions, dataConfiguration, dataset, graphModel, instanceId, pixiPoints, - refreshPointSelection, startAnimation]) + }, [callMatchCirclesToData, callRefreshPointPositions, dataConfiguration, dataset, refreshPointSelection]) // respond to value changes useEffect(() => { @@ -232,20 +228,12 @@ export const usePlotResponders = (props: IPlotResponderProps) => { graphModel?.setPointConfig("points") } - matchCirclesToData({ - dataConfiguration, - pointRadius: graphModel.getPointRadius(), - pointColor: graphModel.pointDescription.pointColor, - pointDisplayType: graphModel.pointDisplayType, - pointStrokeColor: graphModel.pointDescription.pointStrokeColor, - pixiPoints, - startAnimation, instanceId - }) + callMatchCirclesToData() callRefreshPointPositions(false) } }) || (() => true) return () => disposer() - }, [dataset, dataConfiguration, startAnimation, graphModel, callRefreshPointPositions, instanceId, pixiPoints]) + }, [callMatchCirclesToData, dataset, dataConfiguration, graphModel, callRefreshPointPositions, pixiPoints]) // respond to pointDisplayType changes useEffect(function respondToPointConfigChange() { @@ -254,19 +242,11 @@ export const usePlotResponders = (props: IPlotResponderProps) => { () => { if (!pixiPoints) return - matchCirclesToData({ - dataConfiguration, - pointRadius: graphModel.getPointRadius(), - pointColor: graphModel.pointDescription.pointColor, - pointDisplayType: graphModel.pointDisplayType, - pointStrokeColor: graphModel.pointDescription.pointStrokeColor, - pixiPoints, - startAnimation, instanceId - }) + callMatchCirclesToData() callRefreshPointPositions(false) }, {name: "usePlot [pointDisplayType]"}, graphModel ) - }, [callRefreshPointPositions, dataConfiguration, graphModel, instanceId, pixiPoints, startAnimation]) + }, [callMatchCirclesToData, callRefreshPointPositions, graphModel, pixiPoints]) useEffect(() => { return mstReaction( @@ -296,4 +276,13 @@ export const usePlotResponders = (props: IPlotResponderProps) => { {name: "respondToPointVisualChange", equals: comparer.structural}, graphModel ) }, [callRefreshPointPositions, graphModel]) + + // respond to attribute color change + useEffect(function respondToColorChange() { + return mstReaction( + () => metadata?.getAttributeColorRange(legendAttrID), + () => callRefreshPointPositions(false), + { name: "usePlotResponders respondToColorChange", equals: comparer.structural }, metadata + ) + }, [callRefreshPointPositions, legendAttrID, metadata]) } diff --git a/v3/src/models/codap/create-codap-document.test.ts b/v3/src/models/codap/create-codap-document.test.ts index 98b2a49171..8334b65407 100644 --- a/v3/src/models/codap/create-codap-document.test.ts +++ b/v3/src/models/codap/create-codap-document.test.ts @@ -107,6 +107,7 @@ describe("createCodapDocument", () => { }, [caseMetadata.id]: { sharedModel: { + attributeColorRanges: {}, categories: {}, collections: {}, data: "test-5", diff --git a/v3/src/models/shared/shared-case-metadata-constants.ts b/v3/src/models/shared/shared-case-metadata-constants.ts new file mode 100644 index 0000000000..2a0ebde24c --- /dev/null +++ b/v3/src/models/shared/shared-case-metadata-constants.ts @@ -0,0 +1,4 @@ +import { schemeBlues } from "d3" + +export const kDefaultLowAttributeColor = schemeBlues[5][0] +export const kDefaultHighAttributeColor = schemeBlues[5][4] diff --git a/v3/src/models/shared/shared-case-metadata.ts b/v3/src/models/shared/shared-case-metadata.ts index 90f345fc26..d0721bdec4 100644 --- a/v3/src/models/shared/shared-case-metadata.ts +++ b/v3/src/models/shared/shared-case-metadata.ts @@ -3,8 +3,9 @@ import { getSnapshot, getType, Instance, ISerializedActionCall, types } from "mo import { onAnyAction } from "../../utilities/mst-utils" import { CategorySet, createProvisionalCategorySet, ICategorySet } from "../data/category-set" import { DataSet, IDataSet } from "../data/data-set" -import { ISharedModel, SharedModel } from "./shared-model" import { applyModelChange } from "../history/apply-model-change" +import { kDefaultHighAttributeColor, kDefaultLowAttributeColor } from "./shared-case-metadata-constants" +import { ISharedModel, SharedModel } from "./shared-model" export const kSharedCaseMetadataType = "SharedCaseMetadata" @@ -13,6 +14,19 @@ export const CollectionTableMetadata = types.model("CollectionTable", { collapsed: types.map(types.boolean) }) +const ColorRangeModel = types.model("ColorRangeModel", { + lowColor: kDefaultLowAttributeColor, + highColor: kDefaultHighAttributeColor +}) +.actions(self => ({ + setLowColor(color: string) { + self.lowColor = color + }, + setHighColor(color: string) { + self.highColor = color + } +})) + export const SharedCaseMetadata = SharedModel .named(kSharedCaseMetadataType) .props({ @@ -26,7 +40,9 @@ export const SharedCaseMetadata = SharedModel hidden: types.map(types.boolean), caseTableTileId: types.maybe(types.string), caseCardTileId: types.maybe(types.string), - lastShownTableOrCardTileId: types.maybe(types.string) // used to restore the last shown tile both have been hidden + lastShownTableOrCardTileId: types.maybe(types.string), // used to restore the last shown tile both have been hidden + // key is attribute id + attributeColorRanges: types.map(ColorRangeModel) }) .volatile(self => ({ // CategorySets are generated whenever CODAP needs to treat an attribute categorically. @@ -46,6 +62,12 @@ export const SharedCaseMetadata = SharedModel // true if passed the id of a hidden attribute, false otherwise isHidden(attrId: string) { return self.hidden.get(attrId) ?? false + }, + getAttributeColorRange(attrId: string) { + return { + low: self.attributeColorRanges.get(attrId)?.lowColor ?? kDefaultLowAttributeColor, + high: self.attributeColorRanges.get(attrId)?.highColor ?? kDefaultHighAttributeColor + } } })) .actions(self => ({ @@ -87,6 +109,18 @@ export const SharedCaseMetadata = SharedModel }, showAllAttributes() { self.hidden.clear() + }, + setAttributeColor(attrId: string, color: string, selector: "low" | "high") { + let attributeColors = self.attributeColorRanges.get(attrId) + if (!attributeColors) { + attributeColors = ColorRangeModel.create() + self.attributeColorRanges.set(attrId, attributeColors) + } + if (selector === "high") { + attributeColors.setHighColor(color) + } else { + attributeColors.setLowColor(color) + } } })) .actions(self => ({ diff --git a/v3/src/utilities/color-utils.ts b/v3/src/utilities/color-utils.ts index fe1e6fbdfc..e51ed47de7 100644 --- a/v3/src/utilities/color-utils.ts +++ b/v3/src/utilities/color-utils.ts @@ -103,3 +103,22 @@ export class TinyColor { export function tinycolor(color: string) { return new TinyColor(color) } + +// Returns a color that is between color1 and color2 +export function interpolateColors(color1: string, color2: string, percentage: number) { + const rgb1 = colord(color1).toRgb() + const rgb2 = colord(color2).toRgb() + const rRange = rgb2.r - rgb1.r + const gRange = rgb2.g - rgb1.g + const bRange = rgb2.b - rgb1.b + const r = rgb1.r + percentage * rRange + const g = rgb1.g + percentage * gRange + const b = rgb1.b + percentage * bRange + return colord({ r, g, b }).toHex() +} + +// Returns an array of five colors transitioning between color1 and color2 +export function getQuantileScale(color1: string, color2: string) { + const midColor = (percentage: number) => interpolateColors(color1, color2, percentage) + return [color1, midColor(.25), midColor(.5), midColor(.75), color2] +}