Skip to content

CODAP-1505: store point shape per legend category - #2692

Open
kswenson wants to merge 3 commits into
CODAP-1506-format-panel-restructurefrom
CODAP-1505-per-category-point-shape
Open

CODAP-1505: store point shape per legend category#2692
kswenson wants to merge 3 commits into
CODAP-1506-format-panel-restructurefrom
CODAP-1505-per-category-point-shape

Conversation

@kswenson

@kswenson kswenson commented Sep 4, 2026

Copy link
Copy Markdown
Member

Part of the LEADS point-shapes feature. This is the model layer only — nothing
reads these values yet. The controls come with CODAP-1506 and the rendering with
CODAP-1504.

Stacked on #2691. Base is CODAP-1506-format-panel-restructure, so review
that one first. Once it merges I'll retarget this to leads-point-shapes and
the diff will shrink to just this story.

Where shape lives

Per-category shape goes on the attribute's CategorySet, next to per-category
color, so two displays using the same legend attribute agree by construction.
The single no-legend shape goes on DisplayItemDescriptionModel, next to point
color. That mirrors how color already works.

The default is stored as absence — assigning circle removes the entry rather
than recording it. A document carries only the shapes a user chose, and one that
never used the feature is byte-for-byte unaffected.

The v2 decision worth reviewing

V2 has no field for point shape, so both halves ride v3 extension namespaces:
per-category on the attribute, the single shape on the graph component. Both
survive a v3 → v2 → v3 round trip; a v2 re-save drops them, which is lossy but
harmless.

They deliberately do not ride in _categoryMap, which looks like the
obvious home next to colors. I checked V2 on master before ruling it out —
attribute_model.js:486:

var ignorable = ['__order', 'stroke-color', 'stroke-transparency'];

updateCategoryMap treats every other key as a category: it appends unknown
keys to __order, then toArchive writes the result back. A shapes key there
would surface as a phantom category in the user's legend and be persisted by
v2 itself
. By contrast an unknown key in the attribute's v3 namespace is
merely mixed onto the SproutCore model by DG.Attribute.create and dropped by
toArchive's allowlist, so v2 is unaffected.

There's a test pinning this, because the harmful change is the one that looks
safe.

Imported shapes are not filtered, unlike colors

Colors are filtered to categories present in the data; shapes are not. The
difference is that every shape entry is a deliberate user assignment, whereas
some paths assign colors automatically by category position — which is why the
color import keeps only a color differing from the one that position would have
produced. Shapes have no generated noise to age out, so a category whose cases
are deleted and later restored keeps its shape. V2 does the same, for the reason
its own comment gives.

Provisional promotion

A category set stays provisional until the user changes something that must
persist. The predicate watched moves and colors; shapes join it, since assigning
a shape can be the only thing a user does.

This also removes CategorySet.userActionNames, which named the promoting
changes but was read by nothing. Its test asserted against the list rather than
the behavior; equivalent coverage now runs against the promotion predicate
itself, where moves, colors and shapes are checked together.

Promotion rebuilds the set from a snapshot and so replaces the instance — a
reference held across a modification goes stale and later writes are silently
lost. That's now documented at both getCategorySet and
promoteProvisionalCategorySet; the per-category setters re-resolve for this
reason.

Two deviations from the ACs

  • Undo strings use V3. not DG. The AC specifies
    DG.Undo.graph.changePointShape, but DG.* is for strings inherited from v2
    and shape is new in v3, so these are V3.Undo/Redo.graph.changePointShape.
  • getLegendShapeForCase is not here. The per-case resolver mirroring
    getLegendColorForCase is a rendering concern; CODAP-1504 adds it where it's
    used.

Testing

29 new tests. The full-document round-trip tests are the ones that earned their
keep — they caught the promotion gap that every unit test missed, because unit
tests read back through the same in-memory object.

Mutation-tested; each of these fails at least one test: writing shapes into
_categoryMap, filtering imported shapes to present categories, accepting
unrecognized shape values, storing the default instead of omitting it, dropping
shapes from the promotion predicate, dropping moves from it, and having the
graph importer ignore v3.pointShape.

npm run build:tsc, npm run lint, and the full suite (3805 tests / 357
suites) all pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ctmv18kjkPWnUq5bek5nrH

Add the model layer for point shapes: each legend category can carry its own
shape, and a display with no legend attribute carries a single one. Nothing
reads these yet -- the controls come with CODAP-1506 and the rendering with
CODAP-1504.

Per-category shape lives on the attribute's CategorySet, next to per-category
color, so two displays using the same legend attribute agree by construction.
The single shape lives on DisplayItemDescriptionModel next to point color. That
pairing mirrors color exactly, which is what the existing per-category color
setter already established.

The default is stored as absence: assigning circle removes the entry rather than
recording it. A document therefore carries only the shapes a user chose, and one
that never used the feature is byte-for-byte unaffected.

V2 round trip
-------------

V2 has no field for point shape, so both halves travel in v3 extension
namespaces: per-category shapes on the attribute, the single shape on the graph
component. Both survive a v3 -> v2 -> v3 round trip. A v2 re-save drops them,
which is lossy but harmless.

They deliberately do NOT ride in _categoryMap, which would be the obvious home
next to colors. V2's updateCategoryMap treats every key of that map except
__order, stroke-color and stroke-transparency as a category: it appends unknown
keys to __order and writes the result back out. A shapes key there would appear
as a phantom category in the user's legend and be persisted by v2 itself. An
unknown key in the attribute's v3 namespace is merely mixed onto the SproutCore
model and dropped by its toArchive allowlist, so v2 is unaffected. A test pins
this, since the safe-looking change is the harmful one.

Imported shapes are not filtered against the categories present in the data, and
so differ from colors, which are. Every shape entry is a deliberate user
assignment, whereas some paths assign colors automatically by category position
-- which is why the color import keeps only a color differing from the one that
position would have produced. Shapes have no generated noise to age out, so a
category whose cases are deleted and later restored keeps its shape. V2 does the
same, for the reason its own comment gives.

Provisional promotion
---------------------

A category set stays provisional until the user changes something that has to
persist, at which point it is promoted into the document. Shapes join moves and
colors as such a change: assigning a shape can be the only thing a user does, so
the promotion predicate has to count it for the shape to be saved at all.

Also remove CategorySet.userActionNames, which named the changes that promote a
set but was read by nothing. Its test asserted against the list rather than the
behavior; the equivalent coverage now runs against the promotion predicate
itself, where moves, colors and shapes are checked together.

Document that promotion replaces the category-set instance, so a reference held
across a modification goes stale and later writes are silently lost. The
per-category setters re-resolve for this reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ctmv18kjkPWnUq5bek5nrH
@kswenson kswenson added the v3 CODAP v3 label Sep 4, 2026
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.85%. Comparing base (b582357) to head (256bc48).

Additional details and impacted files
@@                           Coverage Diff                           @@
##           CODAP-1506-format-panel-restructure    #2692      +/-   ##
=======================================================================
- Coverage                                87.89%   87.85%   -0.05%     
=======================================================================
  Files                                      818      819       +1     
  Lines                                    47231    47279      +48     
  Branches                                 11948    12085     +137     
=======================================================================
+ Hits                                     41513    41536      +23     
- Misses                                    5702     5729      +27     
+ Partials                                    16       14       -2     
Flag Coverage Δ
cypress 69.68% <49.09%> (-0.07%) ⬇️
jest 63.05% <100.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cypress

cypress Bot commented Sep 4, 2026

Copy link
Copy Markdown

codap-v3    Run #12190

Run Properties:  status check passed Passed #12190  •  git commit 256bc489ee: null
Project codap-v3
Branch Review CODAP-1505-per-category-point-shape
Run status status check passed Passed #12190
Run duration 08m 28s
Commit git commit 256bc489ee: null
Committer null
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 82
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 384
View all changes introduced in this branch ↗︎

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Default shape fields alter untouched V3 snapshots, and reserved category names can lose shapes during V2 round trips.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds model-layer storage and V2 round-trip support for point shapes.

Changes:

  • Adds point-shape types, defaults, and validation.
  • Stores shapes globally or per legend category.
  • Adds V2 extensions, notifications, undo strings, and tests.
File summaries
File Description
v3/src/v2/v2-document-round-trip.test.ts Tests category-shape round trips.
v3/src/v2/codap-v2-types.ts Adds graph shape extension.
v3/src/v2/codap-v2-type-utils.ts Exports non-default graph shapes.
v3/src/v2/codap-v2-data-set-importer.ts Imports attribute shape maps.
v3/src/v2/codap-v2-data-context-types.ts Types category-shape extensions.
v3/src/utilities/translation/lang/en-US.json5 Adds undo/redo strings.
v3/src/utilities/point-shape-utils.ts Defines supported shapes.
v3/src/utilities/point-shape-utils.test.ts Tests shape utilities.
v3/src/models/shared/data-set-metadata.ts Promotes shape-modified category sets.
v3/src/models/shared/data-set-metadata.test.ts Tests provisional promotion.
v3/src/models/data/v2-category-set-importer.ts Restores validated category shapes.
v3/src/models/data/v2-category-set-importer.test.ts Tests shape imports.
v3/src/models/data/category-set.ts Stores per-category shapes.
v3/src/models/data/category-set.test.ts Tests category shape behavior.
v3/src/data-interactive/point-shape-v2-export.test.ts Tests attribute V2 export.
v3/src/data-interactive/data-interactive-type-utils.ts Exports attribute shape maps.
v3/src/components/graph/v2-graph-importer.ts Imports graph point shapes.
v3/src/components/graph/v2-graph-exporter.ts Exports graph point shapes.
v3/src/components/graph/v2-graph-exporter.test.ts Tests graph shape round trips.
v3/src/components/graph/models/graph-data-configuration-model.test.ts Tests legend shape accessors.
v3/src/components/data-display/models/display-item-description-model.ts Stores global point shape.
v3/src/components/data-display/models/display-item-description-model.test.ts Tests global shape storage.
v3/src/components/data-display/models/data-configuration-model.ts Adds legend shape accessors.
v3/src/components/data-display/data-display-notifications.ts Adds shape-change notifications.
Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread v3/src/components/data-display/models/display-item-description-model.ts Outdated
Comment thread v3/src/models/data/category-set.ts
Comment thread v3/src/models/data/category-set.ts
Comment thread v3/src/models/data/v2-category-set-importer.ts Outdated
Comment thread v3/src/components/data-display/data-display-notifications.ts
Store the display's shape as absence at the default. types.optional with a
default materializes the field in every snapshot, so opening and saving a
document that never used shapes would change it. maybe leaves it absent, the
getter already resolves that to circle, and the setter now clears it when the
default is chosen -- the same absence-means-default rule the per-category
shapes already follow.

Build the exported and imported shape records with Object.fromEntries rather
than by assignment. A category value of `__proto__` assigned with `map[key] =`
sets the prototype instead of defining an own property, so that category's shape
vanished in both directions. Category values come from the data and can be any
string. Note colorMap has the same pattern and the same latent problem; left
alone as pre-existing rather than widened into this story.

Add tests for changePointShapeNotification, which the ten adjacent notification
helpers already have and this one did not.

The remaining comment is declined: the shapes map does materialize as `{}` in
every CategorySet snapshot, but so does the colors map beside it, so the
snapshot was never unaffected by an empty map. Omitting it needs a
snapshotProcessor and would make shapes behave differently from its sibling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ctmv18kjkPWnUq5bek5nrH
Category values come from the data and can be any string, including ones that
mean something to a plain JavaScript object. Two faults, both pre-existing,
found while fixing the same class of bug for shapes.

Writing: `map["__proto__"] = color` sets the prototype rather than defining an
own property, so that category's color vanished from colorMap and from the v2
export built out of it.

Reading: for a category named `constructor` or `toString` with no entry, a bare
lookup returns the inherited member. That is truthy and is not a color, so the
v2 importer dereferenced `.colorString` to undefined and handed it to colord --
inventing a color for a category the document said nothing about.

colorMap is now null-prototyped and built from entries, which closes both. The
importer guards its lookup with hasOwnProperty and collects entries rather than
assigning into an object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ctmv18kjkPWnUq5bek5nrH
@kswenson
kswenson marked this pull request as ready for review September 9, 2026 00:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants