Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis - #7197
Conversation
…of drawing an empty axis Scatter came out of the objectui#7147 sweep NOT MEASURED: its all-positive control drew zero marks, because scatter takes two measures and that fixture supplied one. A control that returns zero is no control, so none of its zeros carried information. Re-swept with a two-measure fixture whose control DRAWS (3 of 3 marks). Rows scatter cannot place rendered byte-identically to a scatter handed no rows at all, six authoring failures shared one image, and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. The predicate is derived for a POSITIONAL chart, not copied from the magnitude families: zero and negative coordinates are ordinary scatter data and keep drawing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
os-warren
commented
Sep 1, 2026
os-dev-report {
"issue": 7171,
"status": "done",
"branch": "claude/issue-7171-scatter-degenerate-sweep",
"pr": "https://github.com/objectstack-ai/objectui/pull/7197",
"premise_still_valid": true,
"summary": "THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired; every reading below is therefore a measurement, not a zero. A2.1 confirmed: scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes. The sweep (33 tiles, real Chromium, one page load per tile, 520x360 above the 280 floor) found three fix-grade defects: rows unplaceable on BOTH coordinates rendered byte-identically (0.000%) to a scatter handed no rows at all; six distinct authoring failures shared one image; and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. Shipped a positional predicate — no-plottable-points refusal and unplotted-points footnote, scatter only — DERIVED not copied: #7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts. Zero variance, constant-x and constant-y are measured-and-DECLINED (A2.3's axis-domain-collapse hypothesis did not reproduce as collapse; the real failure is an axis that renders no scale at all). 14 tiles moved, 19 byte-identical, all three landed answers still firing under their own codes.",
"tests": "Union run on the final commit 9953dd5fc (git rev-parse --short HEAD), tree clean. `pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx` -> `Test Files 1 passed (1)` / `Tests 40 passed (40)`; `pnpm exec vitest run packages/plugin-charts/` -> `Test Files 40 passed (40)` / `Tests 371 passed (371)`. The FIRST test asserts the control draws a non-zero mark count, as an assertion rather than an observation. ABLATION, direction and counts predicted in writing before running (leg A 17 red, leg B 7 red, disjoint): leg A neutered the refusal guard and gave 17 failed / 354 passed, split 15 refusal block + 1 seam + 1 unprojectedSeriesDimension; leg B inverted the note guard and gave 7 failed / 364 passed, all in the note block. Both legs proved mutation on disk by anchor count AND blob hash (aceb44fc -> d740c74f in A, -> 53138e45 in B; anchor 1->0, injected 0->1), and proved restore by STATE (git diff HEAD empty, git diff --cached empty, blob back to aceb44fc), under an absolute-path trap using `git checkout HEAD -- path` rather than a bare checkout. The browser instrument was rebuilt on each tree and the marker strings verified present in the bundle the browser actually reads before every reading. GATES on 9953dd5fc: type-check exit 0 with tsc --listFiles confirming all four edited files in the compiled set; package lint 0 errors; the four changeset gates, control-bytes, both vi-mock gates, both i18n gates, self-import, phantom-deps, esm-specifiers, side-effects-array, shell-escape-residue, lint-coverage and type-check-coverage all exit 0. NOT MEASURED and reported as such rather than as failures: check-eager-closure exit 2 (needs a pull_request merge ref; CI owns it), and the probe's first vite build (unbuilt dependency closure, rebuilt and re-run to a real verdict). DECLARED NARROWING: repo-wide lint not run locally — eslint's own config selected 53 files in this package, all four edited files in that set with 0 errors, and no rule is type-aware (zero matches for parserOptions.project or projectService), so this diff cannot move a verdict in a file it does not touch.",
"mcp_calls": "10",
"open_questions": [],
"out_of_scope_findings": [
"filed as #7194: a multi-series scatter draws every series at the FIRST series' y values — measured, two series over `ym` and `zm` produced 4 symbols at only 2 distinct positions, each drawn twice, and `zm`'s values appear nowhere on the plot",
"filed as #7195: an all-boolean scatter coordinate draws nothing and says nothing — deliberately left unguarded here, because a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false 'not drawn' count over visible points"
]
}Generated by Claude Code |
✅ Console Performance Budget
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
Size Limits
|
os-warren
commented
Sep 1, 2026
PM review — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7171
Re-derived on
origin/main899730e, which already carries PR #7169'sno-positive-magnitude, PR #7146'sno-positive-flowand PR #7161'sChartFootnote. Gates and numbers below are quoted at the committed head 9953dd5.The one line that makes the rest readable: THE CONTROL DREW
#7147's sweep reported
markN: 0on every scatter tile including its all-positive control, so scatter came out NOT MEASURED — a control that returns zero is no control, and none of those zeros carried information.A two-measure fixture was built and its control drew 3 of 3 marks (245px of mark area), on a run whose live console control also fired. Everything below is therefore a reading, not a zero.
The cause is confirmed exactly as PM assumed (A2.1): scatter's
XAxisandYAxisare BOTHtype="number", bound toxAxisKeyandseries[0].dataKey. #7147's fixture put a category column (stage) on the numeric X axis. Reproduced here as its own tile:scatter--fixture7147, 0 marks, and byte-identical (51957063d9c2) to five other broken shapes.Rendered it and looked first
33 tiles in real Chromium (
/opt/pw-browsers/chromium), one page load per tile so console output is attributable to exactly one of them. Each tile screenshotted, MD5'd, decoded and pixel-diffed against a literally emptydivof the same box — 520x360, above the chart's ownCHART_MIN_HEIGHTfloor of 280, because PR #7169 measured that no footnote is visible at all below it.measured-and-declinedwas genuinely on the table and it SURVIVED for three of the classes swept. What it did not survive for:null, or both keys absent0.000%) to a scatter handed NO ROWS AT ALL. The empty-result picture, for a query that returned rowsnullx · absent x ·'n/a'x ·'Infinity'x · boolean x · #7147's category column51957063d9c2): a confident y scale, an x axis with no scale at all, and no marks. The y-side twin is a second four-way collision (5a67d6ab8800)0.250%); the y-side twin0.293%. Exactly the collision that decided pie in #7147 — two rows vanish and the picture says "one point"0.017%from a one-row scatterThe predicate is DERIVED for a positional chart, and every clause was forced by a measurement
⛔
no-positive-magnitude'sNumber.isFinite(v) && v > 0does not transfer. Pie, funnel and treemap size a mark BY its measure; scatter plots POSITION, and a chart of temperatures or profit deltas is supposed to show zeros and negatives. Measured rather than argued: that predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles above — three correct charts.What survived is
isPlottableCoord, andNumber()alone gets three of its cases wrong in both directions:Number(v)saysnull0, finite — plottable''0, finite — plottable0,1,2,3,4'10'10, finite'n/a'/'Infinity'/ absentNaN/InfinityThe
''row is the trap: it LOOKS like a null and reads like one. A predicate copied from the magnitude families, or written fromNumber()alone, gets it backwards.The clause that was pinned, went red, and was removed
A boolean coordinate was rejected in the first draft — the browser sweep had it drawing 0 of 2 marks, byte-identical to the
nulltile. Pinning that turned the mixed case red, and the mixed case is the real reading: a boolean beside a genuinely numeric row draws every mark (3 of 3). Recharts needs one real number to build the scale and then coerces the booleans onto it.So rejecting booleans would put a footnote reading "2 of 3 rows ... are not drawn" over three points visibly on screen — a false statement about the picture, which is worse than the silence this card is about. The predicate accepts booleans, the all-boolean tile stays exactly as silent as it is today (
51957063d9c2, unchanged), and that residual hole is filed as #7195 with the reasoning, not hidden.What changed
Three helpers and one wiring, all scatter-only:
isPlottableCoord(v)/countPlottablePoints(rows, xKey, yKey)— a point exists only if both coordinates are numbers.data-chart-error="no-plottable-points", naming both keys because a point needs both.ChartFootnote,data-chart-note="unplotted-points", carrying the count.The y key is bound once (
const scatterYKey = series[0]?.dataKey || 'value') and handed to both the predicate and theYAxis, so the two cannot drift.Copy names the predicate, not a cause — five shapes reach it and naming any one is false for the other four. Gated on
total > 0, so every no-rows tile is untouched: that is the empty-RESULT question (#7130), answered upstream inObjectChart. No console warning, matching all three landed answers. No i18n, matching the file.The one place this note differs from #7169's
unsized-rowsdeliberately does not say the rows are undrawn, because a mixed-sign pie paints a sector for every row. Scatter is the opposite case and it was measured: a 3-row dataset with one placeable pair emitted exactly onepath.recharts-symbols. The rows really are absent from the picture, so saying so is true — and the count is the half a reader cannot recover from it.Before / after: 14 tiles moved, 19 are byte-identical
The 19 are what make the 14 discriminating.
'','10'— and no wrapper element added to any of themscatter--emptyRowsunchanged (dce4d0dde6df) — the finding(plugin-charts):ObjectCharthas NO empty branch at all — an empty result draws a bare chart frame, the fourth distinct answer on this surface to "is it broken or is it young" #7130 gate holdspie--positive,pie--allZero,sankey--allZero,sankey--posNull,bar--allZeroall byte-identical, all three landed answers still firing under their own codesThe two collisions are broken: both-coordinates-degenerate versus no-rows went
0.000%to2.651%, and one-placeable-row versus one-row went0.250%to6.368%.Seam map, pinned from both sides
no-positive-flow(#7146)no-positive-magnitude(#7169)no-plottable-points(this PR)omitted-rows(#7161)unsized-rows(#7169)unplotted-points(this PR)#7169's mutual-exclusivity test was extended, not duplicated: scatter and both new codes join that same assertion, over six codes and a scatter-specific dataset pair, so a sixth answer cannot be added later without it having an opinion.
Assumptions falsified
0,3,6,9,12, identical to a one-row scatter's). What the sweep found instead is an axis that renders no scale whatsoever when nothing on it is placeable, beside a confident scale on the other axis — a different failure, and the one now answered. Zero variance is measured and declined: three coincident points ARE one dot, which is overplotting, a property of the form, and a TRUE picture.null— see above. This was caught by a pin going red, not by reading.AdvancedChartImpl.unprojectedSeriesDimension.test.tsxasserts aseries: []scatter "paints an svg" — using a category-column dataset that was never a valid scatter dataset (0 marks, measured). Updated, not deleted: its real subject (thevaluefallback must not be refused by An unprojected SECOND dimension draws a grouped chart with no series and says nothing —hasNoCategoryKeyhas no counterpart on the series axis #4683's guard) is preserved on a two-measure fixture, and a new test pins the seam from that side — the category-column dataset gets finding(plugin-charts): scatter is NOT MEASURED for the degenerate-magnitude class — its all-positive CONTROL drew zero marks, so the sweep's zeros say nothing #7171's refusal and still never getsno-plottable-series.Tests
AdvancedChartImpl.degeneratePosition.test.tsx, 40 tests. The FIRST of them asserts the control draws — stated as an assertion, not an eyeballed observation, so that if it ever returns to zero every other assertion in the file reads as void rather than green.Ablation, direction and counts predicted before running
Predicted, written down first: leg A red = 15 refusal tests + 1 seam test + 1 in
unprojectedSeriesDimension= 17; leg B red = the 7 note tests; the two red sets disjoint.points.plottable === 0to=== -1)unprojectedSeriesDimensionunplotted <= 0to>= 0)Each leg proved its mutation on disk by anchor count and blob hash (
aceb44fc...tod740c74f...in A, to53138e45...in B; anchor 1 to 0, injected 0 to 1 in both) and its restore by state —git diff HEADandgit diff --cachedboth empty, blob back toaceb44fc...— under an absolute-pathtrap, withgit checkout HEAD -- pathnaming HEAD explicitly rather than restoring from the index. The red sets are disjoint and each leg left the other side green, which is what makes them discriminating rather than a blanket break.Gates, all on the committed head
9953dd5fcvitest packages/plugin-charts/Test Files 40 passed (40)/Tests 371 passed (371)type-checktsc --noEmit, script name echoedlint(package)299 problems (0 errors, 299 warnings)— exit 0check-changeset-presence5 file(s) changed, 4 of them published source ...check-changeset-no-majorNo changeset declares a major bump.check-changeset-overwriteNo pre-existing changeset was modified or deleted.check-changeset-fixedAll workspace packages are in the changeset fixed group.check-control-bytesOK (scanned 5956 tracked text file(s); skipped 85 binary).check-vi-mock-specifiersOK (4106 tracked source file(s), 2378 test-named; 526 carry a mock ...)check-vi-mock-inheritOK (... 118 inherit, 0 auto-mocked ...)check-i18n-keys/check-i18n-drift0 en value(s) changedcheck-self-import/check-phantom-depscheck-esm-specifiers/check-side-effects-array/check-shell-escape-residuecheck-lint-coverage46/46 packages linted, 0 with outstanding errors (0 total).check-type-check-coverage41/41 packages compile their tests, 0 declared debtThe tracked-file gates were run after
git add; their counts moved (5942 to 5956 text files, 525 to 526 carrying a mock), which is how I know they saw the new test rather than skipping it as untracked.tsc --listFilesconfirms all four edited files are in the compiled set (1 hit each), so the type-check green covers them.check-eager-closureexited 2 withnot applicable to a local run — this check compares a pull_request merge ref. That is PRECONDITION NOT MET, not a failure; CI owns it.The probe's first
vite buildwas also NOT MEASURED, not red:failed to resolve import "@object-ui/core"because the dependency closure was unbuilt. Built withpnpm --workspace-concurrency=2 --filter "@object-ui/plugin-charts^..." buildand re-run to a real verdict.Declared narrowing: repo-wide
pnpm lintwas not run locally; CI owns it. The per-package run is a measurement, not a guess — eslint's own config selected 53 files in this package (--format jsoncount, up from the 52 PR #7169 measured, which is the new test file), all four edited files are in that set with 0 errors, and this repo'seslint.config.jsextendstseslint.configs.recommendedwith noparserOptions.projectand noprojectService(0 matches), so no rule is type-aware and nothing in this diff can move a verdict in a file it does not touch.About the instrument
The sweep is a browser probe over a built bundle. It is not committed, deliberately: a test that cannot run in CI renders as coverage while measuring nothing. Its findings are committed instead, as the 40 assertions above, every one of which runs in CI on jsdom — including the control.
Out of scope, reported rather than ridden
ymandzmproduced 4 symbols at 2 distinct positions, each drawn twice;zm's values appear nowhere. Good data, confidently wrong picture, different mechanism.Generated by Claude Code