CODAP-1530: explain a legend attribute the display cannot honor - #2701
Conversation
Dropping an attribute from a collection more childmost than the plotted cases was accepted and then never mentioned again. The legend returned null, which took the attribute's name and the remove action with it, so the choice became invisible and there was no way back from the graph. Meanwhile the Format palette went on offering a color and a shape per category, because GraphDataConfigurationModel's attributeDescriptionForRole override keeps the description the base filters out. The graph disowned the legend in one place and honored it in another. The legend now renders in that state, carrying the attribute label and a sentence where the keys would be. assignedLegendAttributeID reads the assignment off the description rather than through attributeID, which answers differently by display, and legendAttributeIsInoperable pairs it with the existing allowed-check. The palette drops the per-category rows and keeps the display-wide shape control, which unlike color does still reach these points: getLegendShapeForCase falls back to the display's own shape while getLegendColorForCase overrides with the missing-value color. Its glyph is drawn in that color, so the palette shows what is plotted rather than promising a color the points will not take. The gray points are unchanged. They are the signal that the attribute was accepted but cannot be resolved per point, and the message explains them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
codap-v3
|
||||||||||||||||||||||||||||
| Project |
codap-v3
|
| Branch Review |
leads-point-shapes
|
| Run status |
|
| Run duration | 03m 21s |
| Commit |
|
| Committer | Kirk Swenson |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
4
|
| View all changes introduced in this branch ↗︎ | |
There was a problem hiding this comment.
🟡 Changes recommended
Stale legend assignments can be misclassified and rendered as a blank inoperable legend.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds clear feedback when a graph cannot apply an assigned legend attribute to individual points.
Changes:
- Detects and explains inoperable legend assignments.
- Updates legend layout and palette behavior.
- Adds model, wrapping, and palette tests.
File summaries
| File | Description |
|---|---|
en-US.json5 |
Adds the explanatory message. |
graph-data-configuration-model.test.ts |
Tests inoperable legend detection. |
data-configuration-model.ts |
Exposes assigned legend state. |
legend-color-controls.tsx |
Hides unusable category controls. |
legend-color-controls.test.tsx |
Tests palette behavior. |
legend.tsx |
Renders the explanatory state. |
legend.scss |
Styles the message. |
legend-attribute-label.tsx |
Preserves the assigned attribute label. |
inoperable-legend-message.tsx |
Implements wrapping and sizing. |
inoperable-legend-message.test.ts |
Tests wrapping behavior. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## leads-point-shapes #2701 +/- ##
=======================================================
+ Coverage 73.31% 88.02% +14.70%
=======================================================
Files 821 822 +1
Lines 47595 47657 +62
Branches 12142 11717 -425
=======================================================
+ Hits 34893 41948 +7055
+ Misses 12687 5695 -6992
+ Partials 15 14 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
A deleted attribute leaves its ID in the legend description, and no collection holds it, so the allowed-check said no and the state read as inoperable. The legend would then have explained itself over a name that is no longer there -- where before this branch it correctly showed nothing at all. legendAttributeIsInoperable now requires the attribute to still exist, which is the check getLegendColorForCase already makes for the same reason. Key the wrapped lines by position. Two of them can hold the same text once an attribute name repeats a word or the tile narrows, and a line has no identity apart from where it sits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Numeric inoperable legends still expose ineffective controls and display a misleading gradient shape preview.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
v3/src/components/data-display/inspector/legend-color-controls.tsx:182
- For an inoperable numeric legend,
hasFillGradientis still true, sofillGradientIdoverridesglyphColorand the shape preview remains multicolored instead of usingmissingColor. Suppress the gradient in the inoperable state so the glyph matches the plotted points.
v3/src/components/data-display/inspector/legend-color-controls.tsx:194 - This return does not hide all ineffective color controls for a numeric inoperable legend.
DisplayItemFormatControlstill renders the binning selector and the flagged bin-count/range inputs wheneverattributeType("legend") === "numeric", even though every plotted point is forced tomissingColor; those controls therefore remain visible but cannot affect the display. Gate those sibling controls onlegendAttributeIsInoperableas well, and cover the numeric state.
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The palette was fixed for a categorical legend it cannot honor and left as it was for a numeric one, which is reachable by the same route. A numeric legend supplies a band of colors for the shape glyph's fill, and the fill wins over the color prop, so the glyph showed the legend's colors while every point was drawn in the missing-value color. The band is now empty in that state, which also drops the gradient definition it fed. The binning selector and the flagged bin-count and range inputs are siblings of the palette rather than part of it, gated on the legend being numeric alone, so they stayed visible with nothing to act on. They now carry the same condition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The shared operability flag incorrectly hides map color controls even though affected map points still use their display color.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Balanced
legendAttributeIsInoperable answers "the assignment cannot be honored", which is what the legend area needs: the label and the remove action go missing on a map too, so it renders the message there as well. The palette was asking a narrower question and using the same answer. A map layer uses the base configuration, which filters the unusable assignment out of attributeID, so nothing consults the legend and its points keep the display's own color -- while the palette hid the color control and drew the glyph gray. On a polygon layer there is no shape control to fall back on, so the section lost its only control. allPointsTakeMissingColor adds the missing condition: the display has to have retained the legend ID, which only the graph does. The palette reads that; the legend keeps the broader one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The map parent filter prevents the new inoperable legend state from rendering for base configurations.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
v3/src/components/data-display/components/legend/legend.tsx:48
- The inoperable branch is unreachable for map layers.
MultiLegendfilters layers usingdataConfiguration.attributeID('legend'), while the base configuration intentionally returns""for this state, so a hierarchy change still removes the map legend and its remove action beforeLegendcan render this message. Include visible layers whoselegendAttributeIsInoperableis true (or whoseassignedLegendAttributeIDis set) inMultiLegend's filter.
- Files reviewed: 13/13 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Fixed, and thanks for catching it — my previous reply on this PR was wrong. I wrote that the legend renders the message on maps too. It didn't: The filter is now a named predicate, That makes the commit message on |
MultiLegend filtered layers on attributeID("legend"), which the base
configuration returns "" for when the assignment cannot be honored -- so a
map layer was dropped before Legend ran, and the message, the attribute's
name and the remove action never appeared there. Only the graph was getting
them, contrary to what the previous commit message claimed.
The filter is now a named predicate that also admits such a layer, with the
visibility test unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The map drew normally colored points under a legend saying the attribute could not distinguish them, which is the map ignoring the assignment rather than acknowledging it. Both displays now answer the same way. getLegendColorForCase decides the inoperable case before the checks that read attributeID, since that is what differs between them: the base configuration filters an unusable assignment out, a graph's override keeps it. stylePoint follows, so a selection change repaints map points in the missing-value color rather than sending them down the no-legend path. That also removes the reason allPointsTakeMissingColor existed. Every consumer is back on legendAttributeIsInoperable, and the palette drops the color control on maps too, where it now sets something nothing reads. setLegendAttribute takes an attribute more childmost than a layer's position attributes instead of discarding it silently. Refusing never covered the case, since moving a position attribute to a parent collection reaches the same state after the assignment is made. Candidates sort by distance from the legend's collection, so the closest layer still wins. Polygon layers deliberately keep their own color; the reasoning is recorded at the call site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Map legends can remain unremovable, select an inoperable layer over an operable one, or hide heatmap points entirely.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 19/19 changed files
- Comments generated: 3
- Review effort level: Balanced
A heatmap needs a legend attribute that resolves per case, and the layer drew nothing at all without one: the heatmap gate required the attribute and the point gate required displayType to say "points". It now draws points when it cannot draw a heatmap, and the visibility reaction reads the legend attribute so a legend becoming unusable re-fires it -- refreshPoints cannot, since it returns early while the renderer is hidden. The legend's attribute menu resolved the attribute itself, reading "" for one the display cannot honor and offering no way to remove it. It takes the assigned ID as an override, so the map legend is removable as the graph's is. setLegendAttribute ranks a layer that can honor the attribute above one that cannot, and uses closeness only to choose within a rank. Closeness alone could hand the legend to a nearer layer that cannot use it while a usable one waited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
emcelroy
left a comment
There was a problem hiding this comment.
Looks good 👍 I left a couple inline comments/questions, but they're non-blocking. The review below generated with Claude raises some issues you may want to address.
The design holds up. Splitting assignedLegendAttributeID from legendAttributeIsInoperable is the right call, and the comment at getLegendColorForCase explaining why the check must come first will save the next reader real time. Findings below, most serious first.
1. A polygon layer with an unusable legend loses its color control
Make a map from a dataset with one boundary attribute and one ordinary attribute. Move the boundary attribute to a parent collection, so the ordinary attribute is childmost. Assign the ordinary attribute as the legend. The polygon layer is the only candidate, so it now takes the assignment:
layers: [ 'mapPolygonLayer' ]
assigned: pop inoperable: true attrType: undefined isPolygon: true
Now follow the palette. legend-color-controls.tsx:43 sets showShape to false for a polygon, so displayShapeRow is null. Then legend-color-controls.tsx:199 returns that null. The Layers palette shows no color control for the layer.
But the polygon is still drawn in displayItemDescription.itemColor — the deliberate decision recorded at map-polygon-layer.tsx:51. So the color that is still doing the work becomes unreachable. Before this PR, attrType was undefined for that layer and the code fell through to the last branch, which does render the color swatch.
Suggestion. Keep the color control whenever the layer still uses its own color, or gate the early return on "this display grays its items" rather than on legendAttributeIsInoperable alone.
Related test gap. Every new test in legend-color-controls.test.tsx sets pointShapes: "on". That flag is off by default, and with it off showShape is false on a graph too, so the same early return empties the palette section there. That case is defensible — the point color really is inert on a graph — but it is the shipping configuration and nothing covers it.
2. Connecting lines still disagree between the map and the graph
map-point-layer.tsx:136:
const getLegendColor = legendAttribute ? dataConfiguration?.getLegendColorForCase : undefinedThis feeds renderConnectingLines. On a map with an unusable legend it is undefined, because attributeID('legend') reads "". So use-connecting-lines.ts:124 falls back to pointColorAtIndex — the parent colors. The lines come out colored while the points beside them are gray.
The graph does the opposite. At scatter-plot.tsx:247, legendAttrID is still truthy, so a real getter is passed, getLegendColorForCase returns the missing-value color, and the lines go gray with the points.
This is the same "map contradicts graph" problem the stylePoint fix addresses, and it needs the same treatment.
3. The message says "point" on a layer that has no points
en-US.json5:1677 reads "Each point can span multiple %@ values…". The state in finding 1 puts that sentence under a boundary-only map. Either add a second string for boundaries, or word it so it covers both.
4. A now-unreachable branch in getLegendColorForCase
data-configuration-model.ts:988 still tests legendCollectionIsMoreChildmost. I do not think anything can reach it now.
To get there you need a truthy attributeID('legend'), a live attribute, and legendAttributeIsInoperable false. The last one means isAttributeAllowedForNonAxisRole returned true, which means the legend's collection index is at or above the childmost index. legendCollectionIsMoreChildmost asks the opposite question against the same childmost index, from the same axisAttributeIDs. The two are exact opposites, so the new check at the top of the function has already returned.
The sibling check at data-configuration-model.ts:1019 in getLegendShapeForCase is still live, because that function returns early on !legendID first. That asymmetry is what will confuse someone later. Either drop the dead branch, or leave a note saying why one stays and one goes.
5. One call site missed in the assignedLegendAttributeID sweep
map-content-model.ts:533 still compares the dragged attribute against attributeID('legend'). When the legend is unusable that reads "", so the map reports it will accept a drop of the attribute it already holds. Harmless, but it is the same trap the rest of the PR fixes.
A legend the display cannot honor does not reach everything it draws. Points take the missing-value color, so controls setting that color are inert and the palette drops them. Boundaries keep their own color, deliberately, so the control that sets it is live -- and dropping it left a polygon layer's section with nothing in it, since a polygon has no shape control to fall back on. The palette now asks whether the items it describes actually take the missing color rather than whether the legend is honorable. Connecting lines on a map went on taking the plot color while the points they joined were drawn gray, because the getter feeding them tested the filtered attribute ID. It accounts for the inoperable state, as stylePoint does. The message says "item" rather than "point", since a boundary layer reaches this state too. getLegendColorForCase no longer tests legendCollectionIsMoreChildmost: the inoperable check above it answers the same question first, and the two are exact opposites over the same childmost index. Its counterpart in getLegendShapeForCase stays live, which is now said there. placeCanAcceptAttributeIDDrop compares the assigned attribute rather than the filtered ID, so the map no longer offers to accept the attribute it holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All five taken, plus both inline suggestions. Pushed as 1. Polygon color control. Fixed, and your trace was exact — The fix follows from the polygon decision itself. The palette was asking "is the legend honorable" when the question it actually needs is "do the items I describe take the missing-value color" — true for points, false for boundaries, which keep Your test-gap point is the one I'd have missed on my own. Every palette test I wrote sets 2. Connecting lines. Real, and the same class as the 3. Wording. Now "Each item can span multiple %@ values…", since a boundary layer reaches this state. 4. Dead branch. Agreed and removed — The asymmetry you pointed at was the more useful half. 5. Drop gate. Fixed — compares On coverage, so it's not discovered later: findings 2, 3, 5 and the heatmap tweak have no tests. They're React render paths and a drop predicate with no harness in these directories, and building one is more than this PR should carry. Finding 1, the flag-off case and the model changes are covered and mutation-checked. The uncovered ones plus the four from earlier in the PR were all verified by hand in the browser. |
Makes a display acknowledge a legend attribute it can't resolve per point, instead of accepting the drop and then going silent. Branches off
leads-point-shapes; last piece of the LEADS Point Shapes epic.Fixes CODAP-1530.
The state
When the legend attribute lives in a collection more childmost than the plotted cases, each point stands for several of its values at once, so no one color applies. Reach it either way round:
Orderon a graph's x-axis withOrderas a parent collection, then dropHabitaton the plot.The points draw gray, and that is correct — it says the attribute was accepted but cannot be resolved per point. Everything else disagreed with it.
What changed
The legend explains itself. It renders the attribute label and a wrapped sentence where the keys would be, instead of returning
nulland taking the attribute's name and its remove action with it.MultiLegendincludes such a layer rather than filtering it out, and the attribute menu takes the assigned ID as an override so the attribute is actually removable.The palette stops offering controls that do nothing. No per-category rows, no numeric binning or range inputs, and no color control — every point is gray whatever it is set to. The display-wide shape control stays, because shape does still reach these points:
getLegendShapeForCasefalls back to the display's own shape whilegetLegendColorForCaseoverrides with the missing-value color. Its glyph draws in that color, so the palette shows what is plotted.The map behaves like the graph. Three things were needed:
getLegendColorForCasedecides the inoperable case before the checks that readattributeID, since that is what differs between displays — the base configuration filters an unusable assignment out,GraphDataConfigurationModel's override keeps it.stylePointfollows, so a selection change does not repaint map points down the no-legend path.setLegendAttributetakes an attribute more childmost than a layer's position attributes instead of discarding it silently. Refusing never covered the case anyway, since moving a position attribute to a parent collection reaches the same state after the assignment is made. Layers that can honor the attribute rank ahead of those that cannot, with closeness choosing within a rank.displayTypeto say"points", so between them the layer rendered neither.Polygon layers deliberately keep their own color. A map carries one legend with no way to assign one per layer, so it reads as belonging to the points. Gray points on gray boundaries would say less than gray points on colored ones. The reasoning is recorded at the call site.
Scope, for a reviewer
This started as a legend-rendering change and grew. It now also changes what maps plot, which legend drops a map accepts, which layer a map assigns a legend to, and what a heatmap does when it cannot draw. The map changes are what it took to stop the map contradicting itself, but they are real behavior changes and worth reviewing as such.
Testing
Unit tests cover the model views, the word wrap, the palette in every legend type, the
MultiLegendfilter, and the map's layer selection — each checked by breaking the code and confirming the right test fails.What is not covered: the React render and reaction paths, because no harness for them exists in these directories. That is
legend.tsxchoosing the message overnull, the attribute-menu override, the heatmap fallback, and the visibility reaction that re-fires it. All four were verified by hand in the browser, on both a graph and a map, but a reviewer should know they rest on manual testing rather than on tests.🤖 Generated with Claude Code