From 069a97c02f858de84e00debd855ffcd38b059bb1 Mon Sep 17 00:00:00 2001 From: Evangeline Ireland Date: Tue, 29 Oct 2024 23:03:40 -0700 Subject: [PATCH] 183987088 graph color picker UI (#1568) * Adds color swatch palette and color picker input to categorical legend brush menu * Refactors point color setting into its own file * removes some positioning console.logs * cleanup * Refactors PointColorSetting to be more general Adds a way to set the categorical legend color in the data configuration Refactors the DisplayItemFormatControl to use the PointColorSetting component * Graph plot updates colors when category color is changed * Coordinates color picker coler with the currently selected color * Update color picker palette color to match V2 * Moves setcatlegendcolor to its own action * fix: use onAnyAction rather than onAction to fix undo/redo bug * Styles select checkmark depending on swatch color * Adds the non-standard swatch to palette as user selects a color in the color picker. * chore: code review tweaks * Changes hashStrinSets to a hashStringSet to ensure reordering categorical legend does not affect the hash. * fixes broken cypress test --------- Co-authored-by: Kirk Swenson --- v3/cypress/e2e/graph.spec.ts | 56 +++--- .../support/elements/color-picker-palette.ts | 53 ++++++ .../components/legend/categorical-legend.tsx | 32 +++- .../inspector/display-item-format-control.tsx | 128 +++++++------- .../inspector/inspector-panel.scss | 13 +- .../inspector/point-color-setting-shared.scss | 10 ++ .../point-color-setting-shared.scss.d.ts | 9 + .../inspector/point-color-setting.scss | 75 ++++++++ .../inspector/point-color-setting.tsx | 163 ++++++++++++++++++ .../models/data-configuration-model.ts | 6 + v3/src/components/graph/hooks/use-plot.ts | 8 + v3/src/components/inspector-panel.scss | 12 +- v3/src/hooks/use-outside-pointer-down.ts | 3 + v3/src/models/data/category-set.ts | 6 + v3/src/models/shared/shared-case-metadata.ts | 7 +- 15 files changed, 476 insertions(+), 105 deletions(-) create mode 100644 v3/cypress/support/elements/color-picker-palette.ts create mode 100644 v3/src/components/data-display/inspector/point-color-setting-shared.scss create mode 100644 v3/src/components/data-display/inspector/point-color-setting-shared.scss.d.ts create mode 100644 v3/src/components/data-display/inspector/point-color-setting.scss create mode 100644 v3/src/components/data-display/inspector/point-color-setting.tsx diff --git a/v3/cypress/e2e/graph.spec.ts b/v3/cypress/e2e/graph.spec.ts index 7bb7cf2d8b..2ca62fe80e 100644 --- a/v3/cypress/e2e/graph.spec.ts +++ b/v3/cypress/e2e/graph.spec.ts @@ -3,6 +3,7 @@ import { TableTileElements as table } from "../support/elements/table-tile" import { ComponentElements as c } from "../support/elements/component-elements" import { ToolbarElements as toolbar } from "../support/elements/toolbar-elements" import { CfmElements as cfm } from "../support/elements/cfm" +import { ColorPickerPaletteElements as cpp} from "../support/elements/color-picker-palette" import graphRules from '../fixtures/graph-rules.json' const collectionName = "Mammals" @@ -368,39 +369,36 @@ context("Graph UI", () => { .get('div[role="slider"]').should('have.attr', 'aria-valuenow', "0.98") cy.log("changes the stroke color value and verifies the change") + // Ensure stroke initially has the expected value - cy.get('input[type="color"].chakra-input.color-picker-thumb.css-12wa1y3') - .eq(0) - .should('have.value', '#ffffff') // Ensure stroke initially has the expected value + cy.get('.color-picker-row').eq(0).find('.color-picker-thumb-swatch') + .should('have.css', 'background-color', 'rgb(255, 255, 255)') .then((colorPicker) => { // Change the value of the color picker - cy.wrap(colorPicker) - .invoke('val', '#000000') - .trigger('input') - .trigger('change') + cy.wrap(colorPicker).click() + cpp.getColorSettingSwatchCell().eq(0).click() // Verify the value has been updated - cy.wrap(colorPicker).should('have.value', '#000000') + cy.wrap(colorPicker).should('have.css', 'background-color', 'rgb(0, 0, 0)') }) + cy.get('.codap-inspector-palette-header-title').click() //close the color palette cy.log("changes the point color value and verifies the change") - cy.get('input[type="color"].chakra-input.color-picker-thumb.css-12wa1y3') - .eq(1) - .should('have.value', '#e6805b') // Ensure point color initially has the expected value + cy.get('.color-picker-row').eq(1).find('.color-picker-thumb-swatch') + // Ensure point color initially has the expected value + .should('have.css', 'background-color', 'rgb(230, 128, 91)') .then((colorPicker) => { // Change the value of the color picker - cy.wrap(colorPicker) - .invoke('val', '#ff5733') - .trigger('input') - .trigger('change') + cy.wrap(colorPicker).click() + cpp.getColorSettingSwatchCell().eq(1).click() // Verify the value has been updated - cy.wrap(colorPicker).should('have.value', '#ff5733') + cy.wrap(colorPicker).should('have.css', 'background-color', 'rgb(169, 169, 169)') }) + cy.get('.codap-inspector-palette-header-title').click() //close the color palette cy.log("checks the box Stroke same color as fill and check it") - // Get the checkbox and check it cy.get('span.chakra-checkbox__control.css-4utxuo') .eq(0) @@ -411,35 +409,31 @@ context("Graph UI", () => { .should('be.checked') // Use Cypress commands to get the first and second color picker elements - cy.get('input[type="color"].chakra-input.color-picker-thumb.css-12wa1y3') - .eq(0) + cy.get('.color-picker-row').eq(0).find('.color-picker-thumb-swatch') .as('fillColorPicker') - cy.get('input[type="color"].chakra-input.color-picker-thumb.css-12wa1y3') - .eq(1) + cy.get('.color-picker-row').eq(1).find('.color-picker-thumb-swatch') .as('strokeColorPicker') // Get the fill color value - cy.get('@fillColorPicker').invoke('val').then((fillColor) => { + cy.get('@fillColorPicker').invoke('css', 'background-color').then((fillColor) => { // Get the stroke color value and compare it to the fill color cy.get('@strokeColorPicker') - .should('have.value', fillColor) + .should('have.css', 'background-color', fillColor) }) cy.log("changes the background color and verifies the change") // Use a more specific selector to find the background color input element - cy.get('input[type="color"].chakra-input.color-picker-thumb.css-12wa1y3') - .eq(2) - .should('have.value', '#ffffff') // Ensure background initially has the expected value + cy.get('.color-picker-row').eq(2).find('.color-picker-thumb-swatch') + // Ensure background initially has the expected value + .should('have.css', 'background-color', 'rgb(255, 255, 255)') .then((backgroundColorPicker) => { // Change the value of the background color picker - cy.wrap(backgroundColorPicker) - .invoke('val', '#ff5733') - .trigger('input') - .trigger('change') + cy.wrap(backgroundColorPicker).click() + cpp.getColorSettingSwatchCell().eq(4).click() // Verify the value has been updated - cy.wrap(backgroundColorPicker).should('have.value', '#ff5733') + cy.wrap(backgroundColorPicker).should('have.css', 'background-color', 'rgb(173, 35, 35)') }) cy.log("finds the Transparent checkbox and verifies it can be checked") diff --git a/v3/cypress/support/elements/color-picker-palette.ts b/v3/cypress/support/elements/color-picker-palette.ts new file mode 100644 index 0000000000..cb7da1e681 --- /dev/null +++ b/v3/cypress/support/elements/color-picker-palette.ts @@ -0,0 +1,53 @@ +export const ColorPickerPaletteElements = { + getColorPalette() { + return cy.get(".color-picker-palette") + }, + getCategoricalColorSettingsGroup() { + return cy.get(".cat-color-setting") + }, + getCategoricalColorSettingRow() { + return cy.get(".cat-color-setting .color-picker-row.cat-color-picker") + }, + getCategoricalColorSettingLabel() { + return cy.get(".cat-color-setting .form-label.color-picker") + }, + getCategoricalColorSettingButton() { + return cy.get(".cat-color-setting .color-picker-thumb") + }, + getCategoricalColorSettingSwatch() { + return cy.get(".cat-color-setting .color-picker-thumb-swatch") + }, + getColorSettingPalette() { + return cy.get(".color-picker-palette-container") + }, + getColorSettingSwatchGrid() { + return cy.get(".color-swatch-grid") + }, + getColorSettingSwatchCell() { + return cy.get(".color-swatch-cell") + }, + getColorSettingSwatchRow() { + return cy.get(".color-swatch-row") + }, + getSelectedSwatchCell() { + return cy.get(".selected") + }, + getColorPickerToggleButton() { + return cy.get(".color-swatch-footer [data-testid=toggle-show-color-picker-button]") + }, + getColorPicker() { + return cy.get(".color-picker-container") + }, + getColorPickerSaturation() { + return cy.get(".react-colorful__saturation .react-colorful__interactive") + }, + getColorPickerHue() { + return cy.get(".react-colorful__hue .react-colorful__interactive") + }, + getSetColorButton() { + return cy.get(".color-picker-footer .set-color-button") + }, + getCancelColorButton() { + return cy.get(".color-picker-footer .cancel-button") + } +} diff --git a/v3/src/components/data-display/components/legend/categorical-legend.tsx b/v3/src/components/data-display/components/legend/categorical-legend.tsx index 5ca798dc42..1d87f7fd69 100644 --- a/v3/src/components/data-display/components/legend/categorical-legend.tsx +++ b/v3/src/components/data-display/components/legend/categorical-legend.tsx @@ -91,13 +91,16 @@ export const CategoricalLegend = observer( const setCategoryData = useCallback(() => { if (categoriesRef.current) { - const newCategoryData = categoriesRef.current.map((cat: string, index) => ({ - category: cat, - color: dataConfiguration?.getLegendColorForCategory(cat) || missingColor, - column: index % layoutData.current.numColumns, - index, - row: Math.floor(index / layoutData.current.numColumns) - })) + const newCategoryData = categoriesRef.current.map((cat: string, index) => { + return ( + { + category: cat, + color: dataConfiguration?.getLegendColorForCategory(cat) || missingColor, + column: index % layoutData.current.numColumns, + index, + row: Math.floor(index / layoutData.current.numColumns) + }) + }) categoryData.current = newCategoryData } }, [dataConfiguration]) @@ -149,7 +152,8 @@ export const CategoricalLegend = observer( return dataConfiguration?.allCasesForCategoryAreSelected(catData[index].category) ?? false }) - .style('fill', (index: number) => catData[index].color || 'white') + .style('fill', (index: number) => + dataConfiguration?.getLegendColorForCategory(catData[index].category) || 'white') .transition().duration(duration.current) .on('end', () => { duration.current = 0 @@ -327,6 +331,18 @@ export const CategoricalLegend = observer( return () => disposer() }, [refreshKeys, computeDesiredExtent, dataConfiguration, setupKeys, setDesiredExtent, layerIndex]) + useEffect(function respondToLegendColorChange() { + const disposer = reaction( + () => { + return dataConfiguration?.categorySetForAttrRole('legend')?.colorHash + }, + () => { + refreshKeys() + }, {fireImmediately: true} + ) + return () => disposer() + }, [dataConfiguration, refreshKeys]) + useEffect(function setup() { if (keysElt.current && categoryData.current) { setupKeys() 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 68f59b0403..22b3915519 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,10 @@ -import React, {ReactElement, useRef} from "react" +import React, {useRef} from "react" import {observer} from "mobx-react-lite" -import { - Checkbox, Flex, FormControl, FormLabel, Input, Slider, SliderThumb, SliderTrack -} from "@chakra-ui/react" +import {Checkbox, Flex, FormControl, FormLabel, Slider, SliderThumb, SliderTrack + } from "@chakra-ui/react" +import { PointColorSetting } from "./point-color-setting" import {IDataConfigurationModel} from "../models/data-configuration-model" import {IDisplayItemDescriptionModel} from "../models/display-item-description-model" -import {missingColor} from "../../../utilities/color-utils" import {t} from "../../../utilities/translation/translate" import "./inspector-panel.scss" @@ -36,21 +35,33 @@ export const DisplayItemFormatControl = observer(function PointFormatControl(pro }, { undoStringKey: "DG.Undo.graph.changePointColor", redoStringKey: "DG.Redo.graph.changePointColor", - log: "Changed point color" + log: attrType === "categorical" ? "Changed categorical point color" : "Changed point color" }) } - const catPointColorSettingArr: ReactElement[] = [] - categoriesRef.current?.forEach(cat => { - catPointColorSettingArr.push( - - {cat} - handlePointColorChange(e.target.value)}/> - + const handleCatPointColorChange = (color: string, cat: string) => { + displayItemDescription.applyModelChange( + () => { + dataConfiguration.setLegendColorForCategory(cat, color) + }, + { + undoStringKey: "DG.Undo.graph.changePointColor", + redoStringKey: "DG.Redo.graph.changePointColor", + log: "Changed categorical point color" + } ) - }) + } + + const handlePointStrokeColorChange = (color: string) => { + displayItemDescription.applyModelChange( + () => displayItemDescription.setPointStrokeColor(color), + { + undoStringKey: "DG.Undo.graph.changeStrokeColor", + redoStringKey: "DG.Redo.graph.changeStrokeColor", + log: "Changed stroke color" + } + ) + } const renderPlotControlsIfAny = () => { if (onBackgroundTransparencyChange && onBackgroundColorChange) { @@ -59,8 +70,9 @@ export const DisplayItemFormatControl = observer(function PointFormatControl(pro {t("DG.Inspector.backgroundColor")} - onBackgroundColorChange(e.target.value)}/> + onBackgroundColorChange(color)} + swatchBackgroundColor={plotBackgroundColor ?? "#FFFFFF"}/> @@ -111,53 +123,47 @@ export const DisplayItemFormatControl = observer(function PointFormatControl(pro - {t("DG.Inspector.stroke")} - { - displayItemDescription.applyModelChange( - () => displayItemDescription.setPointStrokeColor(e.target.value), - { - undoStringKey: "DG.Undo.graph.changeStrokeColor", - redoStringKey: "DG.Redo.graph.changeStrokeColor", - log: "Changed stroke color" - } - ) - }}/> + {t("DG.Inspector.stroke")} + handlePointStrokeColorChange(color)} + swatchBackgroundColor={displayItemDescription.pointStrokeColor}/> <> - { /*todo: The legend color controls are not in place yet*/ } {dataConfiguration.attributeID("legend") && - attrType === "categorical" - ? {catPointColorSettingArr} - : attrType === "numeric" - ?( - + attrType === "categorical" + ? + {categoriesRef.current?.map(category => { + return ( + + {category} + handleCatPointColorChange(color, category)} + swatchBackgroundColor={dataConfiguration.getLegendColorForCategory(category)}/> + + ) + })} + + : attrType === "numeric" + ? + + {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}/> + + + :( - {/* Sets the min and max colors for numeric legend. Currently not implemented so - this sets the same color for all the points*/} - {t("DG.Inspector.legendColor")} - displayItemDescription.setPointColor(e.target.value)}/> - displayItemDescription.setPointColor(e.target.value)}/> - - ) - :( - - {t("DG.Inspector.color")} - { - displayItemDescription.applyModelChange( - () => displayItemDescription.setPointColor(e.target.value), - { - undoStringKey: "DG.Undo.graph.changePointColor", - redoStringKey: "DG.Redo.graph.changePointColor", - log: attrType === "categorical" ? "Changed categorical point color" : "Changed point color" - } - ) - }}/> - ) + {t("DG.Inspector.color")} + handlePointColorChange(color)} + swatchBackgroundColor={displayItemDescription.pointColor}/> + ) } diff --git a/v3/src/components/data-display/inspector/inspector-panel.scss b/v3/src/components/data-display/inspector/inspector-panel.scss index 44ba0a1946..2833898d6a 100644 --- a/v3/src/components/data-display/inspector/inspector-panel.scss +++ b/v3/src/components/data-display/inspector/inspector-panel.scss @@ -17,11 +17,18 @@ padding: 2px 5px !important; margin: 2px 1px; border: 1px solid #d0d0d0; - margin-left: 10px; + &.categorical { position: absolute; right: 2px; } + .color-picker-thumb-swatch { + width: 35px; + height: 18px; + position: relative; + left: 2px; + border: 1px solid var(--codap-colors-gray-500); + } } .cat-color-setting { @@ -49,6 +56,10 @@ margin: 6px 0; } } + + .num-color-setting { + margin: 3px 0; + } } .map-values-layer-label { diff --git a/v3/src/components/data-display/inspector/point-color-setting-shared.scss b/v3/src/components/data-display/inspector/point-color-setting-shared.scss new file mode 100644 index 0000000000..7d4a95bbeb --- /dev/null +++ b/v3/src/components/data-display/inspector/point-color-setting-shared.scss @@ -0,0 +1,10 @@ +$color-picker-popover-top: -8; +$color-picker-popover-top-px: #{$color-picker-popover-top}px; +$color-picker-popover-left: 100; +$color-picker-popover-left-px: #{$color-picker-popover-left}px; + +// https://mattferderer.com/use-sass-variables-in-typescript-and-javascript +:export { + colorPickerPopoverTop: $color-picker-popover-top; + colorPickerPopoverLeft: $color-picker-popover-left; +} diff --git a/v3/src/components/data-display/inspector/point-color-setting-shared.scss.d.ts b/v3/src/components/data-display/inspector/point-color-setting-shared.scss.d.ts new file mode 100644 index 0000000000..42d5d98beb --- /dev/null +++ b/v3/src/components/data-display/inspector/point-color-setting-shared.scss.d.ts @@ -0,0 +1,9 @@ +// https://mattferderer.com/use-sass-variables-in-typescript-and-javascript +export interface IPointColorSettingSharedShared { + colorPickerPopoverTop: string + colorPickerPopoverLeft: string +} + +export const styles: IPointColorSettingSharedShared + +export default styles diff --git a/v3/src/components/data-display/inspector/point-color-setting.scss b/v3/src/components/data-display/inspector/point-color-setting.scss new file mode 100644 index 0000000000..40cf036cd2 --- /dev/null +++ b/v3/src/components/data-display/inspector/point-color-setting.scss @@ -0,0 +1,75 @@ +@use "./point-color-setting-shared.scss" as shared; + +// Palette Color Picker +.color-picker-palette-container { + background: none !important; + border: none !important; + width: max-content; + height: max-content; + + .color-picker-palette { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 10px; + position: absolute; + background-color: white; + border: 1px solid #d0d0d0; + top: shared.$color-picker-popover-top-px; + left: shared.$color-picker-popover-left-px; + // top: -8px; + // left: 100px; + + .color-swatch-palette { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 10px; + min-height: 140px; + width: 101px; + + .color-swatch-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: repeat(4, 1fr); + grid-gap: 2px; + + .color-swatch-cell { + width: 18px; + height: 18px; + margin: 1px; + padding: 0; + border: solid 1px #d0d0d0; + &.selected { + border: 2px solid #000; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAMdJREFUOE+tkgsNwzAMRMugEAahEAahEAZhEAqlEAZhEAohEAYh81X2dIm8fKpEspLGvudPOsUYpxE2BIJCroJmEW9qJ+MKaBFhEMNabSy9oIcIPwrB+afvAUFoK4H0tMaQ3XtlrggDhOVVMuT4E5MMG0FBbCEYzjYT7OxLEvIHQLY2zWwQ3D+9luyOQTfKDiFD3iUIfPk8VqrKjgAiSfGFPecrg6HN6m/iBcwiDAo7WiBeawa+Kwh7tZoSCGLMqwlSAzVDhoK+6vH4G0P5wdkAAAAASUVORK5CYII=); + &.light { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAIVJREFUeNpiYBhsgJFMffxAXABlN5JruT4Q3wfi/0DsT64h8UD8HmpIPCWG/KemIfOJCUB+Aoacx6EGBZyHBqI+WsDCwuQ9mhxeg2A210Ntfo8klk9sOMijaURm7yc1UP2RNCMbKE9ODK1HM6iegYLkfx8pligC9lCD7KmRof0ZhjQACDAAceovrtpVBRkAAAAASUVORK5CYII=); + } + } + } + } + + .color-swatch-footer { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 30px; + margin-top: 10px; + } + } + + .color-picker-container { + .color-picker-footer { + padding: 5px; + justify-content: right; + border-top-width: 1px; + margin-top: 5px; + } + } + } +} diff --git a/v3/src/components/data-display/inspector/point-color-setting.tsx b/v3/src/components/data-display/inspector/point-color-setting.tsx new file mode 100644 index 0000000000..6d079d8181 --- /dev/null +++ b/v3/src/components/data-display/inspector/point-color-setting.tsx @@ -0,0 +1,163 @@ +import React, {useCallback, useEffect, useRef, useState} from "react" +import {observer} from "mobx-react-lite" +import { clsx } from "clsx" +import { colord } from "colord" +import {Button, ButtonGroup, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, + Portal} from "@chakra-ui/react" +import {missingColor} from "../../../utilities/color-utils" +import {t} from "../../../utilities/translation/translate" +import { ColorPicker } from "../../case-tile-common/color-picker" +import { useOutsidePointerDown } from "../../../hooks/use-outside-pointer-down" + +import styles from "./point-color-setting-shared.scss" +import "./point-color-setting.scss" + +interface ColorPickerIProps { + onColorChange: (color: string) => void + propertyLabel: string + swatchBackgroundColor: string +} + +export const PointColorSetting = observer(function PointColorSetting({onColorChange, + propertyLabel, swatchBackgroundColor}: ColorPickerIProps) { + const [showColorPicker, setShowColorPicker] = useState(false) + const [inputValue, setInputValue] = useState(missingColor) + const popoverRef = useRef(null) + const popoverContainerRef = useRef(null) + const [openPopover, setOpenPopover] = useState(null) + const pointColorSettingButtonRef = useRef(null) + const kGapSize = 10 + const [nonStandardColorSelected, setNonStandardColorSelected] = useState(false) + const paletteColors = ["#000000", "#a9a9a9", "#d3d3d3", "#FFFFFF", "#ad2323", "#ff9632", "#ffee33", "#1d6914", + "#2a4bd7", "#814a19", "#8126c0", "#29d0d0", "#e9debb", "#ffcdf3", "#9dafff", "#81c57a"] + + useOutsidePointerDown({ref: popoverContainerRef, handler: () => setOpenPopover?.(null)}) + + const handleSwatchClick = (cat: string) => { + setOpenPopover(openPopover === cat ? null : cat) + } + + const updateValue = useCallback((value: string) => { + setInputValue(value) + setNonStandardColorSelected(true) + onColorChange(value) + }, [onColorChange]) + + const rejectValue = useCallback(() => { + setShowColorPicker(false) + setOpenPopover(null) + setNonStandardColorSelected(false) + }, []) + + const acceptValue = useCallback(() => { + setShowColorPicker(false) + setNonStandardColorSelected(true) + updateValue(inputValue) + onColorChange(inputValue) + }, [inputValue, onColorChange, updateValue]) + + const handleShowColorPicker = (evt: React.MouseEvent) => { + evt.preventDefault() + evt.stopPropagation() + setShowColorPicker(!showColorPicker) + } + useEffect(() => { + const adjustPosition = () => { + const popover = popoverRef.current + const triggerButton = pointColorSettingButtonRef.current + + if (popover && triggerButton) { + const rect = popover.getBoundingClientRect() + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + let top = 0 + let left = 0 + + if (rect.right > viewportWidth) { + left = viewportWidth - rect.right - kGapSize + } + if (rect.bottom > viewportHeight) { + top = viewportHeight - rect.bottom - kGapSize + } + if (rect.left < 0) { + left = kGapSize + } + if (rect.top < 0) { + top = kGapSize + } + if (!showColorPicker) { + top = +styles.colorPickerPopoverTop + left = +styles.colorPickerPopoverLeft + } + + popover.style.top = `${top}px` + popover.style.left = `${left}px` + } + } + + adjustPosition() + window.addEventListener('resize', adjustPosition) + return () => { + window.removeEventListener('resize', adjustPosition) + } + }, [showColorPicker]) + + return ( + + + + + + + +
+
+ {paletteColors.map((color, index) => ( +
onColorChange(color)}/> + ))} + {nonStandardColorSelected && +
+
+
} +
+
+ +
+
+ {showColorPicker && +
+
+ +
+ + + + + + +
+ } + + + + + ) +}) + +PointColorSetting.displayName = "PointColorSetting" 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 c04bc97ce6..6bcf42ba80 100644 --- a/v3/src/components/data-display/models/data-configuration-model.ts +++ b/v3/src/components/data-display/models/data-configuration-model.ts @@ -682,6 +682,12 @@ export const DataConfigurationModel = types self.handlers.forEach(handler => handler(actionCall)) }, })) + .actions(self => ({ + setLegendColorForCategory(cat: string, color: string) { + const categorySet = self.categorySetForAttrRole('legend') + categorySet?.setColorForCategory(cat, color) + }, + })) .actions(self => ({ clearFilterFormula() { self.filterFormula = undefined diff --git a/v3/src/components/graph/hooks/use-plot.ts b/v3/src/components/graph/hooks/use-plot.ts index 75921f11cc..2d98266d19 100644 --- a/v3/src/components/graph/hooks/use-plot.ts +++ b/v3/src/components/graph/hooks/use-plot.ts @@ -269,6 +269,14 @@ export const usePlotResponders = (props: IPlotResponderProps) => { ) }, [callRefreshPointPositions, dataConfiguration, graphModel, instanceId, pixiPoints, startAnimation]) + useEffect(() => { + return mstReaction( + () => graphModel.dataConfiguration.categorySetForAttrRole('legend')?.colorHash, + () => { + callRefreshPointPositions(false) + }, {name: "usePlot [categorySetChange]"}, graphModel) + }, [graphModel, callRefreshPointPositions]) + // respond to pointsNeedUpdating becoming false; that is when the points have been updated // Happens when the number of plots has changed for now. Possibly other situations in the future. useEffect(() => { diff --git a/v3/src/components/inspector-panel.scss b/v3/src/components/inspector-panel.scss index 817c18f85e..35dacf0256 100644 --- a/v3/src/components/inspector-panel.scss +++ b/v3/src/components/inspector-panel.scss @@ -96,9 +96,14 @@ $inspector-panel-width: 50px; .palette-form { position: relative; left: 0; - margin: 5px 0 10px 10px; + padding: 5px 10px 10px 10px; background-color: #EEE; height: fit-content; + z-index: 20; + + .chakra-form-control { + margin: 3px 0; + } .palette-row { flex-direction: row; @@ -107,7 +112,12 @@ $inspector-panel-width: 50px; white-space: nowrap; &.color-picker-row { + display: flex; + flex-direction: row; height: 30px; + justify-content: space-between; + padding-right: 5px; + margin: 4px 0; } } diff --git a/v3/src/hooks/use-outside-pointer-down.ts b/v3/src/hooks/use-outside-pointer-down.ts index 1f17936ff6..c0791d9e92 100644 --- a/v3/src/hooks/use-outside-pointer-down.ts +++ b/v3/src/hooks/use-outside-pointer-down.ts @@ -52,6 +52,9 @@ function isValidEvent(event: any, ref: React.RefObject) { const doc = getOwnerDocument(target) if (!doc.contains(target)) return false } + // Ignore outside clicks if the target is a portal + // This is to prevent the inspector panel from closing when a user clicks on a color picker + if (target.closest('.chakra-portal')) return false return !ref.current?.contains(target) } diff --git a/v3/src/models/data/category-set.ts b/v3/src/models/data/category-set.ts index f96f6cc09c..e47a60a9f5 100644 --- a/v3/src/models/data/category-set.ts +++ b/v3/src/models/data/category-set.ts @@ -7,6 +7,7 @@ import { kellyColors } from "../../utilities/color-utils" import { onAnyAction } from "../../utilities/mst-utils" import { Attribute, IAttribute } from "./attribute" import { IDataSet } from "./data-set" +import { hashStringSet } from "../../utilities/js-utils" interface ICategoryMove { value: string // category value @@ -201,6 +202,11 @@ export const CategorySet = types.model("CategorySet", { return catIndex != null ? kellyColors[catIndex % kellyColors.length] : undefined } })) +.views(self => ({ + get colorHash() { + return hashStringSet(self.valuesArray.map(category => `${category}:${self.colorForCategory(category) ?? ''}`)) + } +})) .actions(self => ({ handleAttributeAction(action: ISerializedActionCall) { const actionsInvalidatingCategories = [ diff --git a/v3/src/models/shared/shared-case-metadata.ts b/v3/src/models/shared/shared-case-metadata.ts index cf5ad01a80..90f345fc26 100644 --- a/v3/src/models/shared/shared-case-metadata.ts +++ b/v3/src/models/shared/shared-case-metadata.ts @@ -1,5 +1,6 @@ import { observable } from "mobx" -import { getSnapshot, getType, Instance, ISerializedActionCall, onAction, types } from "mobx-state-tree" +import { getSnapshot, getType, Instance, ISerializedActionCall, types } from "mobx-state-tree" +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" @@ -121,12 +122,12 @@ export const SharedCaseMetadata = SharedModel self.removeCategorySet(invalidAttrId) }) const userActionNames = categorySet.userActionNames - onAction(categorySet, action => { + onAnyAction(categorySet, action => { // when a category set is changed by the user, it is promoted to a regular CategorySet if (categorySet && userActionNames.includes(action.name)) { self.promoteProvisionalCategorySet(categorySet) } - }, true) + }) } return categorySet }