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. */}
+
+
+
+
+
@@ -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 (
+
+ 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
+}
+
+interface IPointShapeSettingProps {
+ // changes to this value close an open menu, so a menu cannot be left floating over a scrolled row
+ closeTrigger?: number
+ color?: string
+ /*
+ * Paints the glyphs with the gradient of this id instead of a solid color, for a legend whose
+ * points span a range rather than sharing one. The menu takes it as well as the trigger: each
+ * option previews the points that choosing it would produce, and with such a legend that preview
+ * is the range, not any one color of it.
+ */
+ fillGradientId?: string
+ disabled?: boolean
+ onShapeChange: (shape: PointShape) => void
+ propertyLabel: string
+ shape: PointShape
+}
+
+export const PointShapeSetting = observer(function PointShapeSetting({
+ closeTrigger, color, disabled, fillGradientId, onShapeChange, propertyLabel, shape
+}: IPointShapeSettingProps) {
+ /*
+ * The glyph's fill is `currentColor`, which cannot hold a gradient, so the stylesheet points the
+ * path at a custom property instead. Setting it here overrides the solid color; leaving it unset
+ * falls back to `currentColor`, which is what every glyph outside a numeric legend uses.
+ */
+ const glyphStyle = { color, ...(fillGradientId ? { "--point-shape-fill": `url(#${fillGradientId})` } : {}) }
+ const [isOpen, setIsOpen] = useState(false)
+ const lastCloseTrigger = useRef(closeTrigger)
+
+ // Closes the menu when the surrounding list scrolls, so it cannot be left floating away from
+ // its row. Compares against the previous value rather than firing on every render, so mounting
+ // does not count as a scroll.
+ useEffect(() => {
+ if (closeTrigger !== lastCloseTrigger.current) {
+ lastCloseTrigger.current = closeTrigger
+ setIsOpen(false)
+ }
+ }, [closeTrigger])
+
+ const handleChange = (key: React.Key | null) => {
+ if (key == null) return
+ onShapeChange(key as PointShape)
+ }
+
+ return (
+
+ )
+})
+
+PointShapeSetting.displayName = "PointShapeSetting"
diff --git a/v3/src/components/data-display/models/display-item-description-model.test.ts b/v3/src/components/data-display/models/display-item-description-model.test.ts
index 391f28f372..b714455668 100644
--- a/v3/src/components/data-display/models/display-item-description-model.test.ts
+++ b/v3/src/components/data-display/models/display-item-description-model.test.ts
@@ -53,6 +53,22 @@ describe("DisplayItemDescriptionModel point shape", () => {
expect(description.pointShape).toBe("circle")
})
+ it("reports a polygon by its negative point size", () => {
+ /*
+ * A polygon layer has no point to size, so it sets a negative size to say so, and the inspector
+ * reads that to hide the controls a polygon cannot use. Named here rather than compared at each
+ * of those, which is how one of them came to offer a shape a polygon could not take.
+ */
+ const description = DisplayItemDescriptionModel.create()
+ expect(description.isPolygon).toBe(false)
+
+ description.setPointSizeMultiplier(-1)
+ expect(description.isPolygon).toBe(true)
+
+ description.setPointSizeMultiplier(1)
+ expect(description.isPolygon).toBe(false)
+ })
+
it("is independent of point color", () => {
// shape is a second encoding channel, so setting one must not disturb the other
const description = DisplayItemDescriptionModel.create()
diff --git a/v3/src/components/data-display/models/display-item-description-model.ts b/v3/src/components/data-display/models/display-item-description-model.ts
index 2a1c7fbe57..78cee753df 100644
--- a/v3/src/components/data-display/models/display-item-description-model.ts
+++ b/v3/src/components/data-display/models/display-item-description-model.ts
@@ -67,6 +67,10 @@ export const DisplayItemDescriptionModel = types
get itemStrokeSameAsFill() {
return self._itemStrokeSameAsFill
},
+ // A polygon layer marks itself by setting a negative point size, since it has no point to size.
+ get isPolygon() {
+ return this.pointSizeMultiplier < 0
+ },
}))
.views(self => ({
// Convenience methods referring to points, especially for use by graphs
diff --git a/v3/src/components/map/models/map-polygon-layer-model.ts b/v3/src/components/map/models/map-polygon-layer-model.ts
index 9e7cafc361..9997a5b4f5 100644
--- a/v3/src/components/map/models/map-polygon-layer-model.ts
+++ b/v3/src/components/map/models/map-polygon-layer-model.ts
@@ -23,7 +23,7 @@ export const MapPolygonLayerModel = MapLayerModel
}))
.actions(self => ({
afterCreate() {
- // Set pointSizeMultiplier to -1 so that DisplayItemFormatControlPanel knows it's a polygon
+ // a negative point size is what makes displayItemDescription.isPolygon true
self.displayItemDescription.setPointSizeMultiplier(-1)
},
setBoundaryAttribute(dataSet: IDataSet, boundaryAttrId: string) {
diff --git a/v3/src/models/feature-flags/feature-flag-registry.ts b/v3/src/models/feature-flags/feature-flag-registry.ts
index 2d11fd887c..fef733db49 100644
--- a/v3/src/models/feature-flags/feature-flag-registry.ts
+++ b/v3/src/models/feature-flags/feature-flag-registry.ts
@@ -44,6 +44,12 @@ export const kFeatureFlags = {
added: "2026-07-24",
expires: "2027-07-31"
},
+ pointShapes: {
+ description: "Per-legend-category point shapes on the graph and map",
+ owner: "LEADS",
+ added: "2026-09-04",
+ expires: "2027-09-30"
+ },
/*
* DEPRECATED — gates nothing. The residual plot shipped enabled, and its gate was removed
* rather than switched on by server config, so that the feature cannot silently fail off
diff --git a/v3/src/utilities/translation/lang/en-US.json5 b/v3/src/utilities/translation/lang/en-US.json5
index 0294f94909..f52e44ae20 100644
--- a/v3/src/utilities/translation/lang/en-US.json5
+++ b/v3/src/utilities/translation/lang/en-US.json5
@@ -1005,6 +1005,15 @@
"V3.Inspector.colorPicker.color.lightGreen": "Light green",
// Graph/Map Inspector
+ "V3.Inspector.pointShape": "Shape",
+ "V3.Inspector.pointShape.circle": "Circle",
+ "V3.Inspector.pointShape.square": "Square",
+ "V3.Inspector.pointShape.triangle": "Triangle",
+ "V3.Inspector.pointShape.diamond": "Diamond",
+ "V3.Inspector.pointShape.star": "Star",
+ "V3.Inspector.pointShape.plus": "Plus",
+ "V3.Inspector.pointShape.x": "X",
+ "V3.Inspector.points": "Points",
"V3.Inspector.section.dataPoints": "Data Points",
"V3.Inspector.section.graph": "Graph",
"DG.Inspector.strokeSameAsFill": "Border color same as fill",