diff --git a/v3/src/assets/icons/point-shapes/point-circle.nosvgo.svg b/v3/src/assets/icons/point-shapes/point-circle.nosvgo.svg new file mode 100644 index 0000000000..0c011fe39c --- /dev/null +++ b/v3/src/assets/icons/point-shapes/point-circle.nosvgo.svg @@ -0,0 +1,3 @@ + + + diff --git a/v3/src/assets/icons/point-shapes/point-diamond.nosvgo.svg b/v3/src/assets/icons/point-shapes/point-diamond.nosvgo.svg new file mode 100644 index 0000000000..70623ca756 --- /dev/null +++ b/v3/src/assets/icons/point-shapes/point-diamond.nosvgo.svg @@ -0,0 +1,3 @@ + + + diff --git a/v3/src/assets/icons/point-shapes/point-plus.nosvgo.svg b/v3/src/assets/icons/point-shapes/point-plus.nosvgo.svg new file mode 100644 index 0000000000..f9d23bfff7 --- /dev/null +++ b/v3/src/assets/icons/point-shapes/point-plus.nosvgo.svg @@ -0,0 +1,3 @@ + + + diff --git a/v3/src/assets/icons/point-shapes/point-square.nosvgo.svg b/v3/src/assets/icons/point-shapes/point-square.nosvgo.svg new file mode 100644 index 0000000000..765942cc53 --- /dev/null +++ b/v3/src/assets/icons/point-shapes/point-square.nosvgo.svg @@ -0,0 +1,3 @@ + + + diff --git a/v3/src/assets/icons/point-shapes/point-star.nosvgo.svg b/v3/src/assets/icons/point-shapes/point-star.nosvgo.svg new file mode 100644 index 0000000000..da1d73fcd4 --- /dev/null +++ b/v3/src/assets/icons/point-shapes/point-star.nosvgo.svg @@ -0,0 +1,3 @@ + + + diff --git a/v3/src/assets/icons/point-shapes/point-triangle.nosvgo.svg b/v3/src/assets/icons/point-shapes/point-triangle.nosvgo.svg new file mode 100644 index 0000000000..3399422622 --- /dev/null +++ b/v3/src/assets/icons/point-shapes/point-triangle.nosvgo.svg @@ -0,0 +1,3 @@ + + + diff --git a/v3/src/assets/icons/point-shapes/point-x.nosvgo.svg b/v3/src/assets/icons/point-shapes/point-x.nosvgo.svg new file mode 100644 index 0000000000..ae05034b13 --- /dev/null +++ b/v3/src/assets/icons/point-shapes/point-x.nosvgo.svg @@ -0,0 +1,3 @@ + + + diff --git a/v3/src/components/data-display/inspector/display-item-format-control.scss b/v3/src/components/data-display/inspector/display-item-format-control.scss index 1ef96e8c55..01d4268ddb 100644 --- a/v3/src/components/data-display/inspector/display-item-format-control.scss +++ b/v3/src/components/data-display/inspector/display-item-format-control.scss @@ -1,12 +1,15 @@ @use "../../vars"; -.color-picker-thumb { +/* + * The geometry that makes the two controls in a palette row the same size. Height is left to the + * content rather than fixed, so they cannot drift apart. + */ +@mixin palette-thumb { display: flex; flex-direction: row; align-items: center; gap: 4px; - height: auto !important; - padding: 3px !important; + padding: 3px; margin: 2px 1px; border: none; background-color: vars.$palette-hover-bg; @@ -17,11 +20,33 @@ outline: 2px solid vars.$focus-outline-color; outline-offset: 2px; } +} - &.categorical { - position: absolute; - right: 2px; - } +// A 24px box holding a glyph, which is pinned to its edges rather than laid out, so nothing about +// how an svg participates in flex layout can displace it. +@mixin glyph-box { + position: relative; + display: block; + width: 24px; + height: 24px; + flex: 0 0 auto; +} + +@mixin pinned-glyph { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.color-picker-thumb { + @include palette-thumb; + // This one is a react-aria Button, so the shared `.react-aria-Select .react-aria-Button` rules in + // inspector-panel.scss set its height and padding and have to be overridden. The shape control's + // trigger passes a plain className, which replaces that class, so it needs no override. + height: auto !important; + padding: 3px !important; .color-picker-thumb-swatch { width: 24px; @@ -68,6 +93,99 @@ } } +// Note the selector: a plain className on a react-aria component REPLACES its default class, so +// this trigger carries `point-shape-thumb` and NOT `react-aria-Button`. Adding `.react-aria-Button` +// here would match nothing and leave the glyph unsized, which renders it at container size. That +// replacement is also why the trigger escapes the shared `.react-aria-Select .react-aria-Button` +// styling in inspector-panel.scss, whose min-width suits a text-bearing select rather than an +// icon-only one. +.codap-inspector-palette .palette-form .react-aria-Select { + .point-shape-thumb { + @include palette-thumb; + // contains the absolutely-positioned visually-hidden value, so it cannot escape to the palette + position: relative; + + .point-shape-thumb-value { + @include glyph-box; + } + + .point-shape-thumb-glyph { + @include pinned-glyph; + } + + // Matches the color picker's disclosure arrow, so the two controls in a row read as a pair. + // Sized by border rather than width/height, so box-sizing is set explicitly -- under + // border-box a zero-size bordered box collapses. + .point-shape-arrow { + box-sizing: content-box; + display: block; + margin-left: auto; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid vars.$palette-icon-color; + flex: 0 0 auto; + align-self: center; + transition: transform 0.15s ease; + } + + &.open .point-shape-arrow { + transform: rotate(180deg); + } + } +} + +.point-shape-popover { + background: vars.$palette-bg; + border: 1px solid vars.$charcoal-light-1; + border-radius: 3px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22); + padding: 6px 2px; + // Caps its own height so a menu that cannot flip still fits, scrolling rather than running off + // the bottom of the window. + max-height: 60vh; + overflow-y: auto; + + .react-aria-ListBox { + font-size: 14px; + outline: none; + } + + .react-aria-ListBoxItem { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + padding: 5px 10px; + cursor: pointer; + outline: none; + white-space: nowrap; + + &[data-focused], &[data-hovered] { + background-color: vars.$charcoal-light-5; + } + + // the current shape is the one the trigger already shows; bold marks it in the list + &[data-selected] { + font-weight: bold; + } + + &[data-focus-visible] { + outline: 2px solid vars.$focus-outline-color; + outline-offset: -2px; + } + + .point-shape-item-glyph { + @include glyph-box; + } + + .point-shape-item-svg { + @include pinned-glyph; + } + } +} + .codap-inspector-palette { .palette-form { .palette-section { @@ -215,6 +333,22 @@ .cat-color-picker { flex-shrink: 0; // rows keep their height, so the list scrolls rather than compressing margin: vars.$palette-category-row-margin 0; + gap: 4px; + + // Only the label may shrink; the controls keep their size. + .react-aria-Select { + flex: 0 0 auto; + } + + // Absorbs the free space so the controls align across rows rather than each starting + // wherever its category name happens to end. Truncates rather than pushing them out. + .form-label.color-picker { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } } @@ -260,3 +394,14 @@ } } } + +// Lets a caller paint a glyph with a gradient. The fill is a presentation attribute on the path +// itself, so an inherited value would lose to it and only a rule on the path can win; and it is +// `currentColor`, which cannot hold a gradient, so the value comes from a custom property instead. +// The trigger and the menu are listed together because the menu is rendered in a portal, outside +// the palette these are otherwise scoped to. Unset by default, so the fallback leaves every glyph +// on `currentColor`. +.point-shape-thumb-glyph path, +.point-shape-item-svg path { + fill: var(--point-shape-fill, currentColor); +} diff --git a/v3/src/components/data-display/inspector/display-item-format-control.test.tsx b/v3/src/components/data-display/inspector/display-item-format-control.test.tsx index a9bb8beb5b..9d3e1f8e30 100644 --- a/v3/src/components/data-display/inspector/display-item-format-control.test.tsx +++ b/v3/src/components/data-display/inspector/display-item-format-control.test.tsx @@ -51,7 +51,9 @@ const createMockDescription = (overrides?: Record) => ({ setPointStrokeColor: jest.fn(), setPointStrokeSameAsFill: jest.fn(), applyModelChange: jest.fn((fn: () => void) => fn()), - ...overrides + ...overrides, + // derived as it is on the real model, so a test that sets a negative size gets a polygon + get isPolygon(): boolean { return this.pointSizeMultiplier < 0 } }) const createMockDataConfig = (overrides?: Record) => ({ 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 f8f01e5acc..fa5206c73e 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 @@ -126,7 +126,7 @@ export const DisplayItemFormatControl = observer(function DisplayItemFormatContr - = 0}> + ({ const createMockDescription = (overrides?: Record) => ({ pointColor: "#0000FF", + pointSizeMultiplier: 1, setPointColor: jest.fn(), + setPointShape: jest.fn(), + pointShape: "circle", applyModelChange: jest.fn((fn: () => void) => fn()), - ...overrides + ...overrides, + // derived as it is on the real model, so a test that sets a negative size gets a polygon + get isPolygon(): boolean { return this.pointSizeMultiplier < 0 } }) const createMockDataConfig = (overrides?: Record) => ({ @@ -53,6 +58,8 @@ const createMockDataConfig = (overrides?: Record) => ({ }, getLegendColorForCategory: jest.fn((cat: string) => cat === "cat-a" ? "#FF0000" : "#00FF00"), setLegendColorForCategory: jest.fn(), + getLegendShapeForCategory: jest.fn(() => "circle"), + setLegendShapeForCategory: jest.fn(), legendQuantilesAreLocked: false, // 4 distinct values in numericValuesForAttrRole -> cap 4, so the default of 5 clamps to 4 legendBinCount: 4, @@ -528,4 +535,358 @@ describe("LegendBinCountInput", () => { expect(input).toBeDisabled() expect(input).toHaveValue("1") }) + +}) + +describe("point shape controls", () => { + afterEach(() => { + act(() => featureFlagManager.setServerConfig({})) + }) + + const categoricalConfig = (overrides?: Record) => createMockDataConfig({ + attributeType: jest.fn(() => "categorical"), + categoryArrayForAttrRole: jest.fn(() => ["cat-a", "cat-b"]), + ...overrides + }) + + it("renders no shape control when the flag is off", () => { + const desc = createMockDescription() + const config = categoricalConfig() + render() + + expect(screen.queryByTestId("point-shape-select")).not.toBeInTheDocument() + // the color controls are untouched by the gate + expect(screen.getByTestId("color-swatch-cat-a")).toBeInTheDocument() + }) + + it("renders one shape control per category when the flag is on", () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const desc = createMockDescription() + const config = categoricalConfig() + render() + + expect(screen.getAllByTestId("point-shape-select")).toHaveLength(2) + }) + + it("commits a chosen shape to the category", async () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const user = userEvent.setup() + const desc = createMockDescription() + const config = categoricalConfig() + render() + + // the row control is icon only, so it is named for its category; the open menu carries labels + await user.click( + within(screen.getAllByTestId("point-shape-select")[0]).getByRole("button")) + await user.click(screen.getByRole("option", { name: "V3.Inspector.pointShape.star" })) + + expect(config.setLegendShapeForCategory).toHaveBeenCalledWith("cat-a", "star") + expect(config.applyModelChange).toHaveBeenCalled() + }) + + it("offers all seven shapes in the open menu", async () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const user = userEvent.setup() + const desc = createMockDescription() + const config = categoricalConfig({ categoryArrayForAttrRole: jest.fn(() => ["cat-a"]) }) + render() + + await user.click( + within(screen.getAllByTestId("point-shape-select")[0]).getByRole("button")) + expect(screen.getAllByRole("option")).toHaveLength(7) + }) + + it("names the control for assistive technology without showing a label", () => { + // Icon only visually, but a screen reader needs both the current shape and what it applies to. + // getByRole computes the full accessible name, so this also proves the aria-labelledby that + // react-aria puts on the trigger resolves: if it dangled, the name would fall back to the + // aria-label alone and the shape would be missing from it. + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const desc = createMockDescription() + const config = createMockDataConfig({ + attributeType: jest.fn(() => "categorical"), + categoryArrayForAttrRole: jest.fn(() => ["cat-a"]) + }) + render() + + const shapeControl = within(screen.getAllByTestId("point-shape-select")[0]) + expect(shapeControl.getByRole("button", { name: /cat-a/ })).toBeInTheDocument() + expect(shapeControl.getByRole("button", { name: /V3\.Inspector\.pointShape\.circle/ })) + .toBeInTheDocument() + }) + + it("renders exactly one glyph in the trigger", () => { + /* + * SelectValue renders the selected item's children by default, which would put a second copy + * of the glyph inside the trigger. That copy is positioned absolutely (visually-hidden), so it + * escapes the button and stacks over the palette. The value renders as text only for this + * reason. + */ + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const desc = createMockDescription() + const config = createMockDataConfig({ + attributeType: jest.fn(() => "categorical"), + categoryArrayForAttrRole: jest.fn(() => ["cat-a"]) + }) + render() + + const trigger = within(screen.getAllByTestId("point-shape-select")[0]).getByRole("button") + expect(within(trigger).getAllByTestId("point-shape-glyph")).toHaveLength(1) + }) + + describe("legends with no categories", () => { + /* + * A numeric or color legend has nothing to attach a per-category shape to, but the display's + * own shape still governs every point. Without the control the property goes on applying with + * no way to reach it -- a shape chosen before the legend was added gets stuck. + */ + it("offers the display shape control for a numeric legend", () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const config = createMockDataConfig({ attributeType: jest.fn(() => "numeric") }) + render( + + ) + + expect(screen.getByTestId("point-shape-select")).toBeInTheDocument() + // the legend's own colour controls are still there + expect(screen.getByTestId("color-swatch-DG.Inspector.legendColorLow")).toBeInTheDocument() + }) + + it("offers the display shape control for a color legend", () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const config = createMockDataConfig({ attributeType: jest.fn(() => "color") }) + render( + + ) + + expect(screen.getByTestId("point-shape-select")).toBeInTheDocument() + }) + + it("commits a shape chosen against a numeric legend to the display", async () => { + const user = userEvent.setup() + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const desc = createMockDescription() + const config = createMockDataConfig({ attributeType: jest.fn(() => "numeric") }) + render() + + await user.click(screen.getByRole("button", { name: /V3.Inspector.pointShape/i })) + await user.click(screen.getByRole("option", { name: "V3.Inspector.pointShape.square" })) + + expect(desc.setPointShape).toHaveBeenCalledWith("square") + }) + + it("offers no shape control for a numeric legend when the flag is off", () => { + featureFlagManager.setServerConfig({ pointShapes: "off" }) + const config = createMockDataConfig({ attributeType: jest.fn(() => "numeric") }) + render( + + ) + + expect(screen.queryByTestId("point-shape-select")).not.toBeInTheDocument() + }) + }) + + /* eslint-disable testing-library/no-node-access, testing-library/no-container */ + // A gradient definition is aria-hidden and has no role to query by: it exists only as a paint + // server for the glyph, so the accessible queries these rules steer toward have nothing to find. + describe("numeric legend gradient", () => { + const scaleOf = (...colors: string[]) => ({ legendNumericColorScale: { range: () => colors } }) + + it("paints the trigger with the colors the legend actually uses", () => { + /* + * Hard stops from the scale's own range rather than a ramp interpolated between the low and + * high swatches: the scale is quantized, so points only ever take these discrete colors and + * a smooth ramp would show shades nothing in the plot has. + */ + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const config = createMockDataConfig({ + attributeType: jest.fn(() => "numeric"), + ...scaleOf("#111111", "#222222") + }) + const { container } = render( + + ) + + const stops = Array.from(container.querySelectorAll("linearGradient stop")) + // two stops per color, so each band ends where the next begins + expect(stops.map(stop => stop.getAttribute("stop-color"))) + .toEqual(["#111111", "#111111", "#222222", "#222222"]) + expect(stops.map(stop => stop.getAttribute("offset"))) + .toEqual(["0%", "50%", "50%", "100%"]) + }) + + it("points the trigger's glyph at that gradient", () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const config = createMockDataConfig({ + attributeType: jest.fn(() => "numeric"), + ...scaleOf("#111111", "#222222") + }) + const { container } = render( + + ) + + const gradientId = container.querySelector("linearGradient")?.getAttribute("id") + expect(gradientId).toBeTruthy() + const glyph = screen.getByTestId("point-shape-glyph") + expect(glyph.style.getPropertyValue("--point-shape-fill")).toBe(`url(#${gradientId})`) + }) + + it("paints the menu's options with the gradient too", async () => { + /* + * Each option previews the points that choosing it would produce. With a numeric legend that + * preview is the range, and painting them the display's own color would show a color no point + * on the plot has -- whatever the color had been before the legend was applied. + */ + const user = userEvent.setup() + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const config = createMockDataConfig({ + attributeType: jest.fn(() => "numeric"), + ...scaleOf("#111111", "#222222") + }) + const { container } = render( + + ) + const gradientId = container.querySelector("linearGradient")?.getAttribute("id") + + await user.click(within(screen.getByTestId("point-shape-select")).getByRole("button")) + + const optionGlyphs = screen.getAllByRole("option").map(o => within(o).getByTestId("point-shape-glyph")) + expect(optionGlyphs).toHaveLength(7) + optionGlyphs.forEach(glyph => { + expect(glyph.style.getPropertyValue("--point-shape-fill")).toBe(`url(#${gradientId})`) + }) + }) + + it("leaves the glyph on a solid color when the legend has no scale", () => { + // nothing to build a gradient from, so the custom property stays unset and the fill falls + // back to currentColor + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const config = createMockDataConfig({ + attributeType: jest.fn(() => "numeric"), + legendNumericColorScale: undefined + }) + const { container } = render( + + ) + + expect(container.querySelector("linearGradient")).toBeNull() + expect(screen.getByTestId("point-shape-glyph").style.getPropertyValue("--point-shape-fill")).toBe("") + }) + + it("does not gradient the per-category glyphs, which have colors of their own", () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const config = createMockDataConfig({ + attributeType: jest.fn(() => "categorical"), + categoryArrayForAttrRole: jest.fn(() => ["cat-a"]), + ...scaleOf("#111111", "#222222") + }) + const { container } = render( + + ) + + expect(container.querySelector("linearGradient")).toBeNull() + }) + }) + + /* eslint-enable testing-library/no-node-access, testing-library/no-container */ + + describe("polygon layers", () => { + // The map mounts these controls for polygon layers, which mark themselves with a negative + // point size -- the sentinel that already hides the Point Size slider. A polygon has no point + // to shape. + const polygonDescription = () => createMockDescription({ pointSizeMultiplier: -1 }) + + it("offers no shape control for a polygon layer with no legend", () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const config = createMockDataConfig() + render( + + ) + + expect(screen.queryByTestId("point-shape-select")).not.toBeInTheDocument() + // and the row keeps its own label rather than being relabelled "Points" + expect(screen.getByText("DG.Inspector.color", { selector: "label" })).toBeInTheDocument() + // the color control is unaffected -- a polygon still has a fill + expect(screen.getByTestId("color-swatch-DG.Inspector.color")).toBeInTheDocument() + }) + + it("offers no shape control in a polygon layer's category rows", () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const config = createMockDataConfig({ + attributeType: jest.fn(() => "categorical"), + categoryArrayForAttrRole: jest.fn(() => ["cat-a", "cat-b"]) + }) + render( + + ) + + expect(screen.queryByTestId("point-shape-select")).not.toBeInTheDocument() + expect(screen.getByTestId("color-swatch-cat-a")).toBeInTheDocument() + }) + }) + + describe("with no legend attribute", () => { + it("keeps a single color row when the flag is off", () => { + const desc = createMockDescription() + const config = createMockDataConfig() + render() + + expect(screen.queryByTestId("point-shape-select")).not.toBeInTheDocument() + expect(screen.getByText("DG.Inspector.color", { selector: "label" })).toBeInTheDocument() + }) + + it("collapses to a single Points row with both controls when the flag is on", () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const desc = createMockDescription() + const config = createMockDataConfig() + render() + + expect(screen.getByText("V3.Inspector.points")).toBeInTheDocument() + expect(screen.getByTestId("point-shape-select")).toBeInTheDocument() + expect(screen.getByTestId("color-swatch-DG.Inspector.color")).toBeInTheDocument() + }) + + it("commits a chosen shape to the display description", async () => { + featureFlagManager.setServerConfig({ pointShapes: "on" }) + const user = userEvent.setup() + const desc = createMockDescription() + const config = createMockDataConfig() + render() + + await user.click(screen.getByRole("button", { name: /V3.Inspector.pointShape/i })) + await user.click(screen.getByRole("option", { name: "V3.Inspector.pointShape.diamond" })) + + expect(desc.setPointShape).toHaveBeenCalledWith("diamond") + }) + }) }) diff --git a/v3/src/components/data-display/inspector/legend-color-controls.tsx b/v3/src/components/data-display/inspector/legend-color-controls.tsx index f2aed3cfd6..8f6a7229ca 100644 --- a/v3/src/components/data-display/inspector/legend-color-controls.tsx +++ b/v3/src/components/data-display/inspector/legend-color-controls.tsx @@ -12,16 +12,20 @@ import { kDefaultHighAttributeColor, kDefaultLowAttributeColor } from "../../../models/shared/data-set-metadata-constants" import { binBoundaryDecimalPlaces } from "../../../utilities/math-utils" +import { PointShape } from "../../../utilities/point-shape-utils" import { t } from "../../../utilities/translation/translate" +import { If } from "../../common/if" import { PaletteCheckbox } from "../../palette-checkbox" import { getScaleThresholds } from "../components/legend/choropleth-legend/choropleth-legend" import { changeAttributeColorNotification, changeLegendBinCountNotification, changeLegendBinsTypeNotification, - changeLegendRangeNotification, changePointColorAndAlphaNotification, changePointColorNotification + changeLegendRangeNotification, changePointColorAndAlphaNotification, changePointColorNotification, + changePointShapeNotification } from "../data-display-notifications" import { IDataConfigurationModel, kDefaultLegendBinCount } from "../models/data-configuration-model" import { IDisplayItemDescriptionModel } from "../models/display-item-description-model" import { PointColorSetting } from "./point-color-setting" +import { PointShapeSetting } from "./point-shape-setting" interface ILegendColorControlsProps { dataConfiguration: IDataConfigurationModel @@ -34,6 +38,8 @@ export const LegendColorControls = observer(function LegendColorControls( const { tile } = useTileModelContext() const legendAttrID = dataConfiguration.attributeID("legend") const attrType = dataConfiguration.attributeType("legend") + // The map mounts these controls for its polygon layers too, and a polygon has no point to shape. + const showShape = isFeatureEnabled("pointShapes") && !displayItemDescription.isPolygon const categoriesRef = useRef() categoriesRef.current = dataConfiguration?.categoryArrayForAttrRole("legend") const metadata = dataConfiguration.metadata @@ -50,6 +56,30 @@ export const LegendColorControls = observer(function LegendColorControls( }) } + const handlePointShapeChange = (shape: PointShape) => { + displayItemDescription.applyModelChange( + () => displayItemDescription.setPointShape(shape), + { + notify: () => changePointShapeNotification(tile, shape), + undoStringKey: "V3.Undo.graph.changePointShape", + redoStringKey: "V3.Redo.graph.changePointShape", + log: "Changed point shape" + } + ) + } + + const handleCatPointShapeChange = (shape: PointShape, cat: string) => { + dataConfiguration.applyModelChange( + () => dataConfiguration.setLegendShapeForCategory(cat, shape), + { + notify: () => changePointShapeNotification(tile, shape, cat), + undoStringKey: "V3.Undo.graph.changePointShape", + redoStringKey: "V3.Redo.graph.changePointShape", + log: "Changed category point shape" + } + ) + } + const handleCatPointColorChange = (color: string, cat: string) => { dataConfiguration.applyModelChange( () => dataConfiguration.setLegendColorForCategory(cat, color), @@ -96,12 +126,64 @@ export const LegendColorControls = observer(function LegendColorControls( ) } + /* + * The colors the legend actually paints points with, as hard-stopped gradient bands. Taken from + * the scale rather than interpolated between the low and high swatches: the scale is quantized or + * quantiled, so points only ever take these discrete colors and a smooth ramp would show shades + * nothing in the plot has. Few bins therefore read as visible bands, which is honest. + */ + const legendBandColors: string[] = attrType === "numeric" + ? (dataConfiguration.legendNumericColorScale?.range() ?? []) + : [] + const legendBandStops = legendBandColors.flatMap((bandColor, i) => [ + { color: bandColor, offset: i / legendBandColors.length }, + { color: bandColor, offset: (i + 1) / legendBandColors.length } + ]) + const hasFillGradient = legendBandStops.length > 0 + + // Unique per control instance, so two graphs with different legends do not share one definition. + const gradientId = `point-shape-legend-${useId()}` + + /* + * Shape is a display-level property whenever the legend cannot assign one per category: a numeric + * or color legend has no categories to attach a shape to, so a single shape applies to every + * point, exactly as it does with no legend at all. The control has to stay in the palette for + * those, or a shape chosen before the legend was added becomes unreachable while it goes on + * governing what is drawn. + */ + const displayShapeRow = showShape + ? ( +
+ + {/* A gradient has to live in an svg in the same document; this one only carries it. */} + + + + +
+ ) + : null + if (attrType === "categorical") { return ( ) } @@ -109,6 +191,7 @@ export const LegendColorControls = observer(function LegendColorControls( if (attrType === "numeric") { return ( <> + {displayShapeRow}
@@ -135,11 +218,20 @@ export const LegendColorControls = observer(function LegendColorControls( ) } - if (attrType === "color") return null + if (attrType === "color") return displayShapeRow + // With no legend attribute there are no categories to list, so the same two controls apply to + // every point and sit in a single row. + const singleRowLabel = showShape ? t("V3.Inspector.points") : t("DG.Inspector.color") return (
- + + + + handlePointColorChange(color)} swatchBackgroundColor={displayItemDescription.pointColor}/> @@ -150,11 +242,14 @@ export const LegendColorControls = observer(function LegendColorControls( interface ICategoricalColorControlsProps { categories?: string[] dataConfiguration: IDataConfigurationModel + showShape: boolean onCatPointColorChange: (color: string, cat: string) => void + onCatPointShapeChange: (shape: PointShape, cat: string) => void } const CategoricalColorControls = observer(function CategoricalColorControls( - { categories, dataConfiguration, onCatPointColorChange }: ICategoricalColorControlsProps + { categories, dataConfiguration, showShape, onCatPointColorChange, onCatPointShapeChange }: + ICategoricalColorControlsProps ) { const [scrollVersion, setScrollVersion] = useState(0) @@ -167,6 +262,13 @@ const CategoricalColorControls = observer(function CategoricalColorControls( {categories?.map(category => (
+ + onCatPointShapeChange(shape, category)}/> + onCatPointColorChange(color, category)} diff --git a/v3/src/components/data-display/inspector/point-shape-setting.tsx b/v3/src/components/data-display/inspector/point-shape-setting.tsx new file mode 100644 index 0000000000..87a300de33 --- /dev/null +++ b/v3/src/components/data-display/inspector/point-shape-setting.tsx @@ -0,0 +1,141 @@ +import { clsx } from "clsx" +import { observer } from "mobx-react-lite" +import React, { useEffect, useRef, useState } from "react" +import { Button, ListBox, ListBoxItem, Popover, Select, SelectValue } from "react-aria-components" +import CircleIcon from "../../../assets/icons/point-shapes/point-circle.nosvgo.svg" +import DiamondIcon from "../../../assets/icons/point-shapes/point-diamond.nosvgo.svg" +import PlusIcon from "../../../assets/icons/point-shapes/point-plus.nosvgo.svg" +import SquareIcon from "../../../assets/icons/point-shapes/point-square.nosvgo.svg" +import StarIcon from "../../../assets/icons/point-shapes/point-star.nosvgo.svg" +import TriangleIcon from "../../../assets/icons/point-shapes/point-triangle.nosvgo.svg" +import XIcon from "../../../assets/icons/point-shapes/point-x.nosvgo.svg" +import { PointShape, PointShapes } from "../../../utilities/point-shape-utils" +import { t } from "../../../utilities/translation/translate" + +const kShapeIcons: Record>> = { + circle: CircleIcon, + square: SquareIcon, + triangle: TriangleIcon, + diamond: DiamondIcon, + star: StarIcon, + plus: PlusIcon, + x: XIcon +} + +export function shapeLabel(shape: PointShape) { + return t(`V3.Inspector.pointShape.${shape}`) +} + +type IShapeIconProps = React.SVGProps & { + shape: PointShape +} + +// The glyph is decorative: the control that renders it supplies the accessible name. Its fill is +// currentColor, so whatever sets `color` on it — the row passes the category's color — tints it. +function ShapeIcon({ shape, ...svgProps }: IShapeIconProps) { + const Icon = kShapeIcons[shape] + return