Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// jsdom does no text layout, so width is stubbed per character the way the sibling legend tests do.
jest.mock("../../../../hooks/use-measure-text", () => ({
measureText: (text: string) => text.length * 10,
// also stubbed because labelHeight, pulled in transitively, measures at module load
measureTextExtent: (text: string) => ({ width: text.length * 10, height: 12 })
}))

import { wrapTextToWidth } from "./inoperable-legend-message"

describe("wrapTextToWidth", () => {
it("leaves a string that fits on one line", () => {
expect(wrapTextToWidth("one two", 1000)).toEqual(["one two"])
})

it("breaks between words at the available width", () => {
// 10px per character, so 100px holds ten characters
expect(wrapTextToWidth("aaaa bbbb cccc dddd", 100)).toEqual(["aaaa bbbb", "cccc dddd"])
})

it("rewraps as the width changes", () => {
const text = "aaaa bbbb cccc dddd"
expect(wrapTextToWidth(text, 50)).toEqual(["aaaa", "bbbb", "cccc", "dddd"])
expect(wrapTextToWidth(text, 150)).toEqual(["aaaa bbbb cccc", "dddd"])
})

it("keeps a word too long to fit rather than dropping or splitting it", () => {
// the legend can be narrower than a single word; overflowing one line beats losing the word
expect(wrapTextToWidth("aaaaaaaaaa bb", 30)).toEqual(["aaaaaaaaaa", "bb"])
})

it("collapses runs of whitespace", () => {
expect(wrapTextToWidth(" one two ", 1000)).toEqual(["one two"])
})

it("returns nothing for an empty string", () => {
expect(wrapTextToWidth("", 1000)).toEqual([])
expect(wrapTextToWidth(" ", 1000)).toEqual([])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { observer } from "mobx-react-lite"
import { useEffect } from "react"
import { measureText } from "../../../../hooks/use-measure-text"
import { t } from "../../../../utilities/translation/translate"
import { axisGap } from "../../../axis/axis-types"
import { kDataDisplayFont } from "../../data-display-types"
import { useDataConfigurationContext } from "../../hooks/use-data-configuration-context"
import { useDataDisplayLayout } from "../../hooks/use-data-display-layout"
import { labelHeight, padding } from "./categorical-legend-model"
import { IBaseLegendProps } from "./legend-common"

import "./legend.scss"

const kLineHeight = 16

// Greedy word wrap. SVG text does not wrap, and this message is a sentence rather than a label, so
// it is broken into lines here and drawn as one tspan each.
export function wrapTextToWidth(text: string, maxWidth: number, font = kDataDisplayFont): string[] {
const words = text.split(/\s+/).filter(word => !!word)
if (!words.length) return []

const lines: string[] = []
let line = words[0]
for (const word of words.slice(1)) {
const candidate = `${line} ${word}`
if (measureText(candidate, font) <= maxWidth) {
line = candidate
} else {
lines.push(line)
line = word
}
}
lines.push(line)
return lines
}

/*
* Stands in for the keys when the assigned legend attribute is one this display cannot honor.
*
* The keys are the wrong thing to draw -- there is no per-category encoding to key -- but drawing
* nothing was worse: the legend collapsed entirely, taking the attribute's name and its remove
* action with it, so the assignment the user made became invisible and unreachable.
*/
export const InoperableLegendMessage = observer(function InoperableLegendMessage(
{ layerIndex, setDesiredExtent }: IBaseLegendProps
) {
const dataConfiguration = useDataConfigurationContext()
const dataDisplayLayout = useDataDisplayLayout()
const attrID = dataConfiguration?.assignedLegendAttributeID
const attrName = (attrID && dataConfiguration?.dataset?.attrFromID(attrID)?.name) || ""
// Both gaps, so a long word cannot push the text past the edge the keys stop at.
const maxWidth = Math.max(dataDisplayLayout.tileWidth - 2 * axisGap, 1)
const lines = wrapTextToWidth(t("V3.Legend.attributeNotOperable", { vars: [attrName] }), maxWidth)

useEffect(() => {
setDesiredExtent(layerIndex, labelHeight + lines.length * kLineHeight + padding + axisGap)
return () => setDesiredExtent(layerIndex, 0)
}, [layerIndex, lines.length, setDesiredExtent])

return (
<text className="legend-inoperable-message" data-testid="legend-inoperable-message"
x={axisGap} y={labelHeight + padding}>
{lines.map((line, i) => (
// Keyed by position: a wrapped line has no identity apart from where it sits, and two of
// them can hold the same text once an attribute name repeats a word or the tile narrows.
<tspan key={i} x={axisGap} dy={i === 0 ? 0 : kLineHeight}>{line}</tspan>
))}
</text>
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export const LegendAttributeLabel =

const refreshLegendTitle = useCallback(() => {
const dataset = dataConfiguration?.dataset,
attributeID = dataConfiguration?.attributeID('legend'),
// The assigned attribute rather than attributeID('legend'), which reports "" for one this
// display cannot honor -- the label has to name it in order to offer removing it.
attributeID = dataConfiguration?.assignedLegendAttributeID,
Comment thread
kswenson marked this conversation as resolved.
attributeName = (attributeID ? dataset?.attrFromID(attributeID)?.name : '') ?? '',
attributeUnits = (attributeID ? dataset?.attrFromID(attributeID)?.units : '') ?? '',
labelFont = vars.labelFont,
Expand Down Expand Up @@ -112,6 +114,9 @@ export const LegendAttributeLabel =
onChangeAttribute={onChangeAttribute}
onRemoveAttribute={handleRemoveAttribute}
onTreatAttributeAs={handleTreatAttributeAs}
// The menu resolves the attribute itself otherwise, and reads "" for one this display
// cannot honor -- which reads as no attribute, so it offers no way to remove it.
attrIdOverride={dataConfiguration.assignedLegendAttributeID}
/>
)
}
6 changes: 6 additions & 0 deletions v3/src/components/data-display/components/legend/legend.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
font: 12px sans-serif;
}

// Hanging, because the message is positioned by its top edge the way the keys are.
.legend-inoperable-message {
fill: #555555;
dominant-baseline: hanging;
}

// The key shape is listed alongside the rects because this is a type selector: a categorical key
// is drawn as a path, and would otherwise lose the border and the softening every other legend
// swatch has.
Expand Down
13 changes: 10 additions & 3 deletions v3/src/components/data-display/components/legend/legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IDataConfigurationModel } from "../../models/data-configuration-model"
import { LegendAttributeLabel } from "./legend-attribute-label"
import { CategoricalLegend } from "./categorical-legend"
import { ColorLegend } from "./color-legend"
import { InoperableLegendMessage } from "./inoperable-legend-message"
import { IBaseLegendProps } from "./legend-common"
import { NumericLegend } from "./numeric-legend"

Expand Down Expand Up @@ -38,18 +39,24 @@ export const Legend = observer(function Legend({
const dataConfiguration = useDataConfigurationContext(),
legendID = dataConfiguration?.attributeID("legend"),
legendRef = useRef() as React.RefObject<SVGSVGElement>
if (!dataConfiguration?.isAttributeAllowedForNonAxisRole(legendID)) return null
// Show a legend when this display can use the attribute, and also when it cannot but the user
// assigned one anyway, since that case still needs the label, the remove action, and the message.
const isInoperable = !!dataConfiguration?.legendAttributeIsInoperable
const canShowLegend = isInoperable || !!dataConfiguration?.isAttributeAllowedForNonAxisRole(legendID)
if (!canShowLegend) return null
const attrType = dataConfiguration?.attributeType('legend'),
LegendComponent = dataConfiguration && legendComponentManager.getLegendComponent(dataConfiguration)

// Only show the legend if there is a legend role specified in the dataConfiguration
return attrType ? (
return attrType || isInoperable ? (
<>
<svg ref={legendRef} className='legend-component' data-testid='legend-component'>
<LegendAttributeLabel
onChangeAttribute={onDropAttribute}
/>
{LegendComponent && <LegendComponent layerIndex={layerIndex} setDesiredExtent={setDesiredExtent} />}
{isInoperable
? <InoperableLegendMessage layerIndex={layerIndex} setDesiredExtent={setDesiredExtent} />
: LegendComponent && <LegendComponent layerIndex={layerIndex} setDesiredExtent={setDesiredExtent} />}
</svg>
</>
) : null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { IBaseLayerModel } from "../../models/base-data-display-content-model"
import { layerHasLegendToShow } from "./multi-legend"

const layer = (
{ legendID = "", inoperable = false, isVisible = true } = {}
) => ({
id: "layer-1",
layerIndex: 0,
isVisible,
dataConfiguration: {
attributeID: () => legendID,
legendAttributeIsInoperable: inoperable
}
} as unknown as IBaseLayerModel)

describe("layerHasLegendToShow", () => {
it("shows a layer with a legend attribute", () => {
expect(layerHasLegendToShow(layer({ legendID: "legId" }))).toBe(true)
})

it("skips a layer with no legend attribute", () => {
expect(layerHasLegendToShow(layer())).toBe(false)
})

it("skips a hidden layer", () => {
expect(layerHasLegendToShow(layer({ legendID: "legId", isVisible: false }))).toBe(false)
})

it("shows a layer whose legend attribute cannot be honored", () => {
/*
* The base configuration reports "" for an assignment it cannot honor, so this layer would
* otherwise be dropped here -- before Legend could render the label, the remove action, and the
* message explaining why the points are not colored by it.
*/
expect(layerHasLegendToShow(layer({ inoperable: true }))).toBe(true)
})

it("still skips that layer when it is hidden", () => {
expect(layerHasLegendToShow(layer({ inoperable: true, isVisible: false }))).toBe(false)
})
})
23 changes: 18 additions & 5 deletions v3/src/components/data-display/components/legend/multi-legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,28 @@ import {useInstanceIdContext} from "../../../../hooks/use-instance-id-context"
import {IDataSet} from "../../../../models/data/data-set"
import {GraphPlace} from "../../../axis-graph-shared"
import {GraphAttrRole} from "../../data-display-types"
import {IBaseLayerModel} from "../../models/base-data-display-content-model"
import {DataConfigurationContext} from "../../hooks/use-data-configuration-context"
import {useDataDisplayLayout} from "../../hooks/use-data-display-layout"
import {DroppableSvg} from "../droppable-svg"
import {Legend} from "./legend"

/*
* Whether a layer should be given a legend area.
*
* Graphs have one layer and it is always visible. Maps have several, and only the visible ones get
* a legend -- with one addition: a layer whose assigned legend attribute this display cannot honor
* counts as having one. Its `attributeID` reads "" in that state, because the base configuration
* filters an unusable assignment out, so testing that alone drops the layer before Legend runs and
* leaves the attribute the user assigned invisible and unremovable. That is the situation Legend
* now renders a message for, and it can only do so if the layer gets here.
*/
export function layerHasLegendToShow(layer: IBaseLayerModel): boolean {
const { dataConfiguration } = layer
return !!(dataConfiguration.attributeID("legend") || dataConfiguration.legendAttributeIsInoperable) &&
layer.isVisible
}

interface IMultiLegendProps {
divElt: HTMLDivElement | null
onDropAttribute: (place: GraphPlace, dataSet: IDataSet, attrId: string) => void
Expand All @@ -32,11 +49,7 @@ export const MultiLegend = observer(function MultiLegend({divElt, onDropAttribut
extentsRef = useRef([] as number[])

const legendBoundsTop = layout?.computedBounds?.legend?.top ?? 0
// Graphs have only one layer and it's always visible. Maps have multiple layers and we only want to display legends
// for map layers that are visible.
const layersWithLegendsArray = Array.from(dataDisplayModel.layers).filter(layer =>
layer.dataConfiguration.attributeID('legend') && layer.isVisible
)
const layersWithLegendsArray = Array.from(dataDisplayModel.layers).filter(layerHasLegendToShow)
const handleIsActive = (active: Active) => {
const {dataSet, attributeId: droppedAttrId} = getDragAttributeInfo(active) || {}
return isDropAllowed('legend', dataSet, droppedAttrId)
Expand Down
12 changes: 10 additions & 2 deletions v3/src/components/data-display/data-display-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,14 @@ export function setPointSelection(
pointColor, pointStrokeColor, pointShape, getPointColorAtIndex } = props
const dataset = dataConfiguration.dataset
const legendID = dataConfiguration.attributeID('legend')
/*
* A legend the display cannot honor counts as having one here. Its ID reads "" on a map, because
* the base configuration filters an unusable assignment out, so testing the ID alone would send
* map points down the no-legend path and paint them the display's color -- while the map's own
* refresh painted them the missing-value color, and the legend said the attribute cannot
* distinguish them. The two paths have to agree.
*/
const hasLegendInEffect = !!legendID || dataConfiguration.legendAttributeIsInoperable
if (!renderer) {
return
}
Expand All @@ -160,13 +168,13 @@ export function setPointSelection(
const isSelected = !!dataset?.isCaseSelected(caseID)
// Determine fill color based on legend or plotNum; no-legend selected points override to blue below
let fill: string
if (legendID) {
if (hasLegendInEffect) {
fill = dataConfiguration?.getLegendColorForCase(caseID)
} else {
fill = plotNum && getPointColorAtIndex ? getPointColorAtIndex(plotNum) : pointColor
}
// When there's no legend, use blue fill for selection instead of a colored stroke
const useSelectionFill = isSelected && !legendID
const useSelectionFill = isSelected && !hasLegendInEffect
const style: Partial<IPointStyle> = {
shape: dataConfiguration.getLegendShapeForCase(caseID, pointShape),
fill: useSelectionFill ? defaultSelectedColor : fill,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ describe("DisplayItemFormatControl", () => {
expect(screen.getByTestId("legend-range-inputs")).toBeInTheDocument()
})

it("hides the numeric legend controls for a legend the display cannot honor", () => {
/*
* Every point is drawn in the missing-value color in that state, so binning and range have
* nothing to act on. The palette drops its own rows for it; these are its siblings and need
* the same gate.
*/
featureFlagManager.setServerConfig({ legendBinCount: "on", legendRange: "on" })
const desc = createMockDescription()
const config = createMockDataConfig({
attributeType: jest.fn(() => "numeric"),
legendAttributeIsInoperable: true
})
render(
<DisplayItemFormatControl
dataConfiguration={config as any}
displayItemDescription={desc as any}
/>
)

expect(screen.queryByTestId("legend-bins-select")).not.toBeInTheDocument()
expect(screen.queryByTestId("legend-bin-count-input")).not.toBeInTheDocument()
expect(screen.queryByTestId("legend-range-inputs")).not.toBeInTheDocument()
})

it("hides the flagged numeric legend controls when their flags are off", () => {
const desc = createMockDescription()
const config = createMockDataConfig({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ export const DisplayItemFormatControl = observer(function DisplayItemFormatContr
displayItemDescription={displayItemDescription}
/>

<If condition={attrType === "numeric"}>
{/* Not for a legend this display cannot honor: every point is drawn in the missing-value
color, so binning and range have nothing to act on. */}
<If condition={attrType === "numeric" && !dataConfiguration.legendAttributeIsInoperable}>
<LegendBinsSelect dataConfiguration={dataConfiguration} />
<If condition={isFeatureEnabled("legendBinCount")}>
<LegendBinCountInput dataConfiguration={dataConfiguration} />
Expand Down
Loading
Loading