Skip to content

fix(charts): a scatter's extreme marks are drawn wholly inside the plot area (objectui#7396) - #9680

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-7396-scatter-extreme-mark-clipping
Sep 17, 2026
Merged

os-justin merged 1 commit into
mainfrom
claude/issue-7396-scatter-extreme-mark-clipping

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Part of #7396

The defect

Both axes of the scatter branch in AdvancedChartImpl are numeric and carry no explicit
domain, so recharts fits the domain to [dataMin, dataMax] and maps it across the whole
plot box. A row at either extreme is therefore centred on the boundary, and since a mark
has a radius, about half of each extreme symbol paints outside the plot area.

Before / after, measured in real Chromium

The DOM harness cannot fire a container-size effect (clientWidth 0), so the card's reading
was reproduced rather than quoted: the chart is rendered at the widget's real box (svg
510x350) in the preinstalled Chromium at viewport 1440, and the geometry is read off the live
SVG — the plot area from the chart's clip rect, each mark's centre and radius from its own
symbol path.

# harness: renders AdvancedChartImpl scatter with the Chart Gallery
# "Estimate vs Progress" rows in a 510px-wide box, served by vite
PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers node measure.mjs

Plot area is identical in both runs: x 53..505, y 5..296. Symbol radius 4.514px.

marks (cx) y-max row worst overhang past an edge
before (main 15b33ae) 53, 256.4, 301.6, 414.6, 459.8, 505 cy 5 +4.514px outside
after (this branch) 65, 257.6, 300.4, 407.4, 450.2, 493 cy 17 −7.486px, i.e. inside

Before, the first and last marks sit exactly on the x boundary and the y-max row exactly on
the top boundary, each overhanging by a full radius. After, every mark's centre ± radius is
strictly inside the plot area on both axes.

Two readings differ from the card, both reported rather than papered over:

  • It is both axes, not just x. The card measured x only; the y-max row was centred on the
    plot's top edge in the same run. Fixed with it, and pinned.
  • The mark radius is a constant here, and it is not the one triage predicted. Triage
    warned the symbol is variable (area 60..400 from the branch's ZAxis, radius 4.4..11.3px).
    Measured: every mark is 4.514px, which is sqrt(64 / PI) — recharts' own implicit default
    area. Its selectZAxisWithScale returns nothing for a ZAxis with no dataKey, so the
    declared range={[60, 400]} never reaches the mark and the scatter falls back to
    implicitZAxis.range. That inert declaration is noted below, not touched.

What changed

Two props, on the scatter's own XAxis and YAxis, plus the constants they read.

The margin is reserved as recharts' axis padding, not as a domain. Padding insets the
pixel range the scale maps into and leaves the domain alone, so every tick value is
unchanged — the axes still read 0/25/50/75/100 and 0/15/30/45/60 — and only the mapping moves.
Moving the domain would invent unround tick endpoints, and it would write the same recharts
prop a spec-declared min/max needs, where whichever landed second would shadow the other.
objectui#9675 is not addressed here, and reserving the margin leaves that prop free for it.

The padding value is ceil(sqrt(area / PI)) over the symbol-area envelope the branch declares,
i.e. the largest radius that declaration admits — deliberately the envelope's upper bound and
not the 4.514px drawn today, so neither a change in recharts' implicit default size nor a
dataKey arriving on that ZAxis can reopen the defect. The two padding objects are module
constants because recharts memoises each axis's settings on its props and re-registers the axis
when that object changes; a fresh literal per render would defeat that memo.

minTickGap is untouched, allowDataOverflow is not set on either axis, and no authorable
schema key was added.

The pin, verified in both directions

AdvancedChartImpl.scatterEdgeClipping-7396.test.tsx asserts the invariant triage asked for —
every mark wholly inside the plot area — and never a coordinate or a domain value: each mark is
compared against the plot rect read from the same render.

It measures something here for a reason worth stating: with ResponsiveContainer mocked to a
fixed box (the shape the existing objectui#7248 pin already uses), recharts computes the plot
rect, every centre and the symbol radius arithmetically, with no DOM measurement in the path.
The x coordinates agree with the Chromium run to the digit. What is not portable is any
absolute coordinate that depends on text metrics — the legend is shorter there, so the plot
rect is taller — which is why nothing absolute is pinned.

Ablation, run from the committed fix: removing the two padding props leaves the two axes
byte-identical to main's and turns the pin red.

padding= occurrences: 2 -> 0      (mutation proven on disk)
diff vs origin/main on the axis props: none — the axes match main
 Test Files  1 failed (1)
      Tests  5 failed (5)
AssertionError: mark at cx 53 pokes past the left edge: expected 4.513516668382053 to be less than 0
AssertionError: mark at cy 5 pokes past the top edge: expected 4.51351666838205 to be less than 0

Restored with git checkout HEAD -- PATH; the file's blob hash is back to its HEAD value
and git diff HEAD is empty. Re-run on the restored tree: 5 passed.

Verification

check result
pnpm exec vitest run packages/plugin-charts/ 61 files, 548 tests passed
pnpm --filter @object-ui/plugin-charts type-check exit 0
pnpm --filter @object-ui/plugin-charts lint 0 errors
node scripts/check-changeset-presence.mjs exit 0, 1 changeset declared
pnpm check:new-line-citations 0 new citations
pnpm check:control-bytes OK
pnpm check:vi-mock-specifiers / -inherit / -override-shape OK
pnpm check:test-path-roots, check:changeset-claims, check:pending-changeset-literals, check:unreferenced-sources exit 0
node scripts/check-governed-queue-guard.mjs --test THE-THREE-PATHS NOT GOVERNED

The repo-wide pnpm lint sweep is CI's run, not reproduced locally.

Acceptance notes

Noted while measuring, not filed and not touched here:

  • The scatter's ZAxis declaration is inert. With no dataKey on it, recharts drops the axis
    before the mark ever reads it, so the declared symbol-area range has no effect and every mark
    is drawn at recharts' implicit default size. It is a dead prop, not a wrong answer: nothing is
    mis-rendered and no metadata is dropped. Whoever gives that ZAxis a dataKey — the same
    file, most likely alongside objectui#9675 — will meet it; the margin here is already sized for
    that case, so nothing breaks when they do.

Generated by Claude Code

…ot area

Both scatter axes are numeric and carry no explicit domain, so recharts fits the
domain to `[dataMin, dataMax]` and maps it across the whole plot box. A row at
either extreme is therefore CENTRED on the boundary, and since a mark has a
radius, about half of each extreme symbol paints outside the plot area.

Measured on the Chart Gallery scatter ("Estimate vs Progress") in real Chromium
— viewport 1440, widget svg 510x350, plot area x 53..505 / y 5..296:

  before: cx 53, 256.4, 301.6, 414.6, 459.8, 505 and the y-max row at cy 5,
          radius 4.514px — first and last ON the x boundary, y-max ON the top
          boundary, each overhanging its edge by a full radius.
  after:  cx 65, 257.6, 300.4, 407.4, 450.2, 493 and the y-max row at cy 17 —
          worst clearance 7.486px inside the plot area, on both axes.

The card reported the x axis only; the y axis clipped the same way.

The margin is reserved as recharts' axis `padding`, which insets the pixel range
the scale maps into and leaves the domain alone, so every tick VALUE is
unchanged and only the mapping moves. Moving the domain instead would invent
unround tick endpoints and would write the same recharts prop a spec-declared
`min`/`max` needs, where whichever landed second would shadow the other.

Its size is the largest radius the scatter's declared symbol-area envelope
admits, not the radius drawn today: with no `dataKey` on that ZAxis recharts
ignores the declaration and paints every mark at its own implicit default area,
so sizing to what is painted would tie the fix to a third-party default and
would reopen the defect the day the declared envelope goes live.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EpHzwH4wTy5sd7ibkD2yq
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 1 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/7546-chart-series-keys-declared.md

  • names AdvancedChartImpl.tsxpackages/plugin-charts/src/AdvancedChartImpl.tsx — edited by this change

    Every one of the six is read by normalizeSeries (@object-ui/plugin-charts, normalizeChartSchema.ts:242-255) and does real work in AdvancedChartImpl.tsxlabel names the legend entry, variant === 'comparison' selects the muted overlay, opacity / dashArray set stroke and fill, stack becomes Recharts' stackId, yAxis binds the secondary axis. Any consumer of the parse output — objectui check / objectui validate via safeValidateSchema, a JSON schema derived from the mirror, or any pipeline that keeps parse()'s result — lost them outright.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a claim that was born false (a changeset this change adds is excluded by construction), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 72f55c9ec (merge-base with origin/main): 2 file(s) changed outside .changeset/, read against 1119 pending declaration(s) that publish a body (1624 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3048.9 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-DbLzpmc-.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.84KB 130.66KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 215.98KB 59.97KB
fields (index.js) 249.27KB 62.92KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.49KB 19.99KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.29KB 65.88KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.64KB 57.91KB
plugin-kanban (index.js) 46.41KB 14.49KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.04KB 21.01KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 104.82KB 34.67KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-justin
os-justin marked this pull request as ready for review September 17, 2026 11:24
@os-justin
os-justin added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit e859ad0 Sep 17, 2026
38 checks passed
@os-justin
os-justin deleted the claude/issue-7396-scatter-extreme-mark-clipping branch September 17, 2026 11:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants