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
20 changes: 20 additions & 0 deletions .changeset/degenerate-magnitude-charts.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-charts': patch
---

Pie, donut, funnel and treemap now say when rows carry no magnitude they can draw.

These four families size a mark BY its measure, so a row whose value is zero,
negative, `null` or unparseable stays in the data and is given no area. Measured
in Chromium across 74 tiles: an all-zero pie put ZERO non-white pixels on the
page while its DOM carried 31 descendants and a real `svg`; a treemap handed
`40 / null`, `40 / 0` or `40 / -25 / -12` rendered one full-bleed leaf that was
byte-identical to a genuinely one-row treemap; and a funnel handed `40` beside a
`null` drew no segments at all and labelled the tile with the row that had no
value.

When no row can be sized, these charts now render the file's refusal shell
(`no-positive-magnitude`) instead of a blank tile. When only some rows can be
sized, the chart still draws and carries a note counting the ones it could not.
All-positive charts, charts handed no rows at all, bar charts, and both sankey
answers are unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Pie / donut / funnel / treemap — rows that are NEVER filtered and are given
* no area anyway (objectui#7147). The third mechanism on this surface, and the
* one neither landed answer reaches.
*
* ## Three mechanisms, one reader-facing symptom
*
* - objectui#7140 / objectui#7146 — an early return emitting a bare `div`
* - objectui#7148 — a silent row DROP before the plot
* - objectui#7147 — DEGENERATE GEOMETRY, pinned here
*
* objectui#7148's footnote counts dropped rows (`data.length - rows.length`).
* Against these four families that count is exactly ZERO — the sankey arm holds
* the only row-dropping filter in the file — so hoisting it here would have
* rendered nothing at all while looking like coverage. The rows stay in `data`
* and the LAYOUT gives them no area. Zero area is not zero elements, and
* neither is a dropped row.
*
* ## The measurement that decided fix-over-decline, per family
*
* 74 tiles in real Chromium (`/opt/pw-browsers/chromium`) at `origin/main`
* 40c4711d6, each screenshotted, MD5'd and pixel-diffed against a literally
* empty `div` of the same box. `measured-and-declined` was on the table for
* every family, as the card says, and survived for none of the four:
*
* - pie / donut all-zero, all-null, all-negative: ZERO non-white pixels out
* of 124,800 — byte-identical to the empty div, with 31 descendants and a
* real `svg` in the DOM.
* - pie / donut `40` beside a `null`: a FULL circle in the first category's
* colour, 99.35% pixel-identical to a legitimately one-row dataset (the
* 0.654% residue is the `paddingAngle` hairline, not information).
* - funnel `40` beside a `null`: 178 ink pixels — ZERO segments, ONE label,
* and the label reads "Beta", the row with NO value. The row carrying 40
* drew nothing at all.
* - funnel all-negative: a confident two-band funnel whose mark area
* (220,320) EXCEEDS the all-positive control's (111,881).
* - treemap `40 / null`, `40 / 0` and `40 / -25 / -12`: all three
* BYTE-IDENTICAL (diff 0.000%) to a genuinely one-row treemap. Four
* datasets, one image.
* - treemap all-zero: ONE full-bleed leaf labelled with the LAST category.
*
* The controls are what make those zeros mean anything: an all-zero BAR drew
* 5,128 ink pixels of axes and ticks on the same instrument — which is why bar
* is deliberately untouched and pinned that way below — a two-row pie differed
* from a one-row pie by 9.683% of its pixels, and a two-row treemap from a
* one-row treemap by 38.301%.
*
* ## What this file pins, and why the passing cases are the discriminating half
*
* The refusal and the note are only half the pin. The other half is everything
* that must NOT have moved: the all-positive control of every family keeps its
* exact DOM (no wrapper element), the no-rows case stays byte-for-byte as it
* was (that is the empty-RESULT question, objectui#7130, answered upstream in
* `ObjectChart`), bar keeps its axes, and BOTH landed sankey answers keep
* firing on their own datasets under their own codes. Measured across the same
* 74 tiles: every sankey tile and every bar tile hashed IDENTICALLY before and
* after this change.
*/
import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';

vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import AdvancedChartImpl from './AdvancedChartImpl';

afterEach(cleanup);

const SERIES = [{ dataKey: 'amount', label: 'Amount' }];

type Row = Record<string, unknown>;

const renderChart = (chartType: string, data: Row[]) =>
render(
<AdvancedChartImpl
chartType={chartType as any}
xAxisKey="stage"
series={SERIES as any}
data={data as any}
/>,
);

const refusalOf = (c: HTMLElement) => c.querySelector('[data-chart-error="no-positive-magnitude"]');
const noteOf = (c: HTMLElement) => c.querySelector('[data-chart-note="unsized-rows"]');
const plotOf = (c: HTMLElement) => c.querySelector('[data-slot="chart"]');

/** The four families whose layout sizes a mark BY its measure. */
const MAGNITUDE_FAMILIES = ['pie', 'donut', 'funnel', 'treemap'] as const;

/**
* Every shape that reaches the layout carrying no positive magnitude.
*
* The copy deliberately names NONE of them, for the reason `no-positive-flow`'s
* docstring gives: a message saying "negative" is false of the `null` row, one
* saying "zero" is false of the unparseable string, and one naming a missing key
* is false of all four others. The predicate is true of every one of them, so
* the predicate is what the sentence names.
*/
const UNSIZABLE: Array<[string, unknown]> = [
['a genuine zero', 0],
['a negative', -25],
['null', null],
['undefined', undefined],
['an unparseable string', 'n/a'],
['Infinity, which `Number(x) || 0` would let through', 'Infinity'],
];

describe('objectui#7147 — no row can be sized: the chart refuses instead of drawing nothing', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: every row carrying ${label} gets a refusal, not a blank tile`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: value },
{ stage: 'Beta', amount: value },
]);
const refusal = refusalOf(container);
expect(refusal).not.toBeNull();
// The message names the KEY and the exact test it failed — the same
// diagnostic pair `no-positive-flow` carries, which is why neither
// needs a console warning.
expect(refusal!.textContent).toContain('amount');
expect(refusal!.textContent).toContain('above zero');
// A refusal REPLACES the plot; it does not sit beside one.
expect(plotOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}
}

it('a missing measure key on every row refuses too', () => {
const { container } = renderChart('pie', [{ stage: 'Alpha' }, { stage: 'Beta' }]);
expect(refusalOf(container)).not.toBeNull();
});

it('the refusal uses its OWN code, never the sankey arm\'s', () => {
const { container } = renderChart('funnel', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).toBeNull();
});
});

describe('objectui#7147 — SOME rows can be sized: the chart still draws, and says how many it could not', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: one good row beside ${label} still DRAWS, with a note`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: value },
]);
// The plot is not blanked — objectui#7146 pins the analogous "one
// positive row among zeros still draws" for the sankey arm, and the
// same must hold here or a fix becomes a regression.
expect(plotOf(container)).not.toBeNull();
expect(refusalOf(container)).toBeNull();
const note = noteOf(container);
expect(note).not.toBeNull();
expect(note!.getAttribute('role')).toBe('note');
expect(note!.textContent).toContain('1 of 2 rows has');
expect(note!.textContent).toContain('amount');
});
}
}

it('the COUNT is the half a reader cannot recover from the picture', () => {
const { container } = renderChart('treemap', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
{ stage: 'Gamma', amount: -12 },
]);
// Measured: this dataset rendered ONE full-bleed leaf, byte-identical to a
// genuinely one-row treemap. "Some rows" would leave those two images
// identical in meaning; "2 of 3" is the bit that was missing.
expect(noteOf(container)!.textContent).toContain('2 of 3 rows have');
});

it('the note uses its OWN attribute, never objectui#7148\'s', () => {
const { container } = renderChart('pie', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 0 },
]);
expect(noteOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-note="omitted-rows"]')).toBeNull();
});
});

describe('objectui#7147 — the cases that must NOT have moved', () => {
for (const family of MAGNITUDE_FAMILIES) {
it(`${family}: an all-positive chart gains no note, no refusal and NO WRAPPER`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 25 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
// `ChartFootnote` with a null note returns its children untouched, so the
// plot stays the FIRST element — no existing caller gains a wrapper.
expect(container.firstElementChild?.getAttribute('data-slot')).toBe('chart');
});

it(`${family}: handed NO rows at all, nothing is said`, () => {
// The empty-RESULT question (objectui#7130) is answered upstream in
// `ObjectChart`, where the query outcome is actually known. "No row's
// measure is above zero" would be a sentence about rows that do not
// exist.
const { container } = renderChart(family, []);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}

it('bar is untouched: an all-zero bar chart still draws its axes and says nothing', () => {
// Deliberate, and measured: an all-zero bar drew 5,128 ink pixels of axes
// and ticks against a blank tile's 0. Its reader can already tell a zero
// dataset from a broken widget, so bar is outside this card.
const { container } = renderChart('bar', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
expect(plotOf(container)).not.toBeNull();
});
});

describe('objectui#7147 — the seam with the two landed sankey answers, pinned from both sides', () => {
it('an all-zero SANKEY keeps objectui#7146\'s refusal and never gets this one', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).not.toBeNull();
expect(refusalOf(container)).toBeNull();
});

it('a thinned SANKEY keeps objectui#7148\'s footnote and never gets this note', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
]);
expect(container.querySelector('[data-chart-note="omitted-rows"]')).not.toBeNull();
expect(noteOf(container)).toBeNull();
});

it('the three codes are mutually exclusive on every dataset in the sweep', () => {
const datasets: Row[][] = [
[{ stage: 'Alpha', amount: 0 }, { stage: 'Beta', amount: 0 }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: null }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: 25 }],
[],
];
for (const family of [...MAGNITUDE_FAMILIES, 'sankey', 'bar']) {
for (const data of datasets) {
const { container } = renderChart(family, data);
const codes = [
container.querySelector('[data-chart-error="no-positive-flow"]'),
container.querySelector('[data-chart-error="no-positive-magnitude"]'),
container.querySelector('[data-chart-note="omitted-rows"]'),
container.querySelector('[data-chart-note="unsized-rows"]'),
].filter(Boolean).length;
expect(codes).toBeLessThanOrEqual(1);
cleanup();
}
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
20 changes: 20 additions & 0 deletions .changeset/degenerate-magnitude-charts.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-charts': patch
---

Pie, donut, funnel and treemap now say when rows carry no magnitude they can draw.

These four families size a mark BY its measure, so a row whose value is zero,
negative, `null` or unparseable stays in the data and is given no area. Measured
in Chromium across 74 tiles: an all-zero pie put ZERO non-white pixels on the
page while its DOM carried 31 descendants and a real `svg`; a treemap handed
`40 / null`, `40 / 0` or `40 / -25 / -12` rendered one full-bleed leaf that was
byte-identical to a genuinely one-row treemap; and a funnel handed `40` beside a
`null` drew no segments at all and labelled the tile with the row that had no
value.

When no row can be sized, these charts now render the file's refusal shell
(`no-positive-magnitude`) instead of a blank tile. When only some rows can be
sized, the chart still draws and carries a note counting the ones it could not.
All-positive charts, charts handed no rows at all, bar charts, and both sankey
answers are unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Pie / donut / funnel / treemap — rows that are NEVER filtered and are given
* no area anyway (objectui#7147). The third mechanism on this surface, and the
* one neither landed answer reaches.
*
* ## Three mechanisms, one reader-facing symptom
*
* - objectui#7140 / objectui#7146 — an early return emitting a bare `div`
* - objectui#7148 — a silent row DROP before the plot
* - objectui#7147 — DEGENERATE GEOMETRY, pinned here
*
* objectui#7148's footnote counts dropped rows (`data.length - rows.length`).
* Against these four families that count is exactly ZERO — the sankey arm holds
* the only row-dropping filter in the file — so hoisting it here would have
* rendered nothing at all while looking like coverage. The rows stay in `data`
* and the LAYOUT gives them no area. Zero area is not zero elements, and
* neither is a dropped row.
*
* ## The measurement that decided fix-over-decline, per family
*
* 74 tiles in real Chromium (`/opt/pw-browsers/chromium`) at `origin/main`
* 40c4711d6, each screenshotted, MD5'd and pixel-diffed against a literally
* empty `div` of the same box. `measured-and-declined` was on the table for
* every family, as the card says, and survived for none of the four:
*
* - pie / donut all-zero, all-null, all-negative: ZERO non-white pixels out
* of 124,800 — byte-identical to the empty div, with 31 descendants and a
* real `svg` in the DOM.
* - pie / donut `40` beside a `null`: a FULL circle in the first category's
* colour, 99.35% pixel-identical to a legitimately one-row dataset (the
* 0.654% residue is the `paddingAngle` hairline, not information).
* - funnel `40` beside a `null`: 178 ink pixels — ZERO segments, ONE label,
* and the label reads "Beta", the row with NO value. The row carrying 40
* drew nothing at all.
* - funnel all-negative: a confident two-band funnel whose mark area
* (220,320) EXCEEDS the all-positive control's (111,881).
* - treemap `40 / null`, `40 / 0` and `40 / -25 / -12`: all three
* BYTE-IDENTICAL (diff 0.000%) to a genuinely one-row treemap. Four
* datasets, one image.
* - treemap all-zero: ONE full-bleed leaf labelled with the LAST category.
*
* The controls are what make those zeros mean anything: an all-zero BAR drew
* 5,128 ink pixels of axes and ticks on the same instrument — which is why bar
* is deliberately untouched and pinned that way below — a two-row pie differed
* from a one-row pie by 9.683% of its pixels, and a two-row treemap from a
* one-row treemap by 38.301%.
*
* ## What this file pins, and why the passing cases are the discriminating half
*
* The refusal and the note are only half the pin. The other half is everything
* that must NOT have moved: the all-positive control of every family keeps its
* exact DOM (no wrapper element), the no-rows case stays byte-for-byte as it
* was (that is the empty-RESULT question, objectui#7130, answered upstream in
* `ObjectChart`), bar keeps its axes, and BOTH landed sankey answers keep
* firing on their own datasets under their own codes. Measured across the same
* 74 tiles: every sankey tile and every bar tile hashed IDENTICALLY before and
* after this change.
*/
import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';

vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import AdvancedChartImpl from './AdvancedChartImpl';

afterEach(cleanup);

const SERIES = [{ dataKey: 'amount', label: 'Amount' }];

type Row = Record<string, unknown>;

const renderChart = (chartType: string, data: Row[]) =>
render(
<AdvancedChartImpl
chartType={chartType as any}
xAxisKey="stage"
series={SERIES as any}
data={data as any}
/>,
);

const refusalOf = (c: HTMLElement) => c.querySelector('[data-chart-error="no-positive-magnitude"]');
const noteOf = (c: HTMLElement) => c.querySelector('[data-chart-note="unsized-rows"]');
const plotOf = (c: HTMLElement) => c.querySelector('[data-slot="chart"]');

/** The four families whose layout sizes a mark BY its measure. */
const MAGNITUDE_FAMILIES = ['pie', 'donut', 'funnel', 'treemap'] as const;

/**
* Every shape that reaches the layout carrying no positive magnitude.
*
* The copy deliberately names NONE of them, for the reason `no-positive-flow`'s
* docstring gives: a message saying "negative" is false of the `null` row, one
* saying "zero" is false of the unparseable string, and one naming a missing key
* is false of all four others. The predicate is true of every one of them, so
* the predicate is what the sentence names.
*/
const UNSIZABLE: Array<[string, unknown]> = [
['a genuine zero', 0],
['a negative', -25],
['null', null],
['undefined', undefined],
['an unparseable string', 'n/a'],
['Infinity, which `Number(x) || 0` would let through', 'Infinity'],
];

describe('objectui#7147 — no row can be sized: the chart refuses instead of drawing nothing', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: every row carrying ${label} gets a refusal, not a blank tile`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: value },
{ stage: 'Beta', amount: value },
]);
const refusal = refusalOf(container);
expect(refusal).not.toBeNull();
// The message names the KEY and the exact test it failed — the same
// diagnostic pair `no-positive-flow` carries, which is why neither
// needs a console warning.
expect(refusal!.textContent).toContain('amount');
expect(refusal!.textContent).toContain('above zero');
// A refusal REPLACES the plot; it does not sit beside one.
expect(plotOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}
}

it('a missing measure key on every row refuses too', () => {
const { container } = renderChart('pie', [{ stage: 'Alpha' }, { stage: 'Beta' }]);
expect(refusalOf(container)).not.toBeNull();
});

it('the refusal uses its OWN code, never the sankey arm\'s', () => {
const { container } = renderChart('funnel', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).toBeNull();
});
});

describe('objectui#7147 — SOME rows can be sized: the chart still draws, and says how many it could not', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: one good row beside ${label} still DRAWS, with a note`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: value },
]);
// The plot is not blanked — objectui#7146 pins the analogous "one
// positive row among zeros still draws" for the sankey arm, and the
// same must hold here or a fix becomes a regression.
expect(plotOf(container)).not.toBeNull();
expect(refusalOf(container)).toBeNull();
const note = noteOf(container);
expect(note).not.toBeNull();
expect(note!.getAttribute('role')).toBe('note');
expect(note!.textContent).toContain('1 of 2 rows has');
expect(note!.textContent).toContain('amount');
});
}
}

it('the COUNT is the half a reader cannot recover from the picture', () => {
const { container } = renderChart('treemap', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
{ stage: 'Gamma', amount: -12 },
]);
// Measured: this dataset rendered ONE full-bleed leaf, byte-identical to a
// genuinely one-row treemap. "Some rows" would leave those two images
// identical in meaning; "2 of 3" is the bit that was missing.
expect(noteOf(container)!.textContent).toContain('2 of 3 rows have');
});

it('the note uses its OWN attribute, never objectui#7148\'s', () => {
const { container } = renderChart('pie', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 0 },
]);
expect(noteOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-note="omitted-rows"]')).toBeNull();
});
});

describe('objectui#7147 — the cases that must NOT have moved', () => {
for (const family of MAGNITUDE_FAMILIES) {
it(`${family}: an all-positive chart gains no note, no refusal and NO WRAPPER`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 25 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
// `ChartFootnote` with a null note returns its children untouched, so the
// plot stays the FIRST element — no existing caller gains a wrapper.
expect(container.firstElementChild?.getAttribute('data-slot')).toBe('chart');
});

it(`${family}: handed NO rows at all, nothing is said`, () => {
// The empty-RESULT question (objectui#7130) is answered upstream in
// `ObjectChart`, where the query outcome is actually known. "No row's
// measure is above zero" would be a sentence about rows that do not
// exist.
const { container } = renderChart(family, []);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}

it('bar is untouched: an all-zero bar chart still draws its axes and says nothing', () => {
// Deliberate, and measured: an all-zero bar drew 5,128 ink pixels of axes
// and ticks against a blank tile's 0. Its reader can already tell a zero
// dataset from a broken widget, so bar is outside this card.
const { container } = renderChart('bar', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
expect(plotOf(container)).not.toBeNull();
});
});

describe('objectui#7147 — the seam with the two landed sankey answers, pinned from both sides', () => {
it('an all-zero SANKEY keeps objectui#7146\'s refusal and never gets this one', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).not.toBeNull();
expect(refusalOf(container)).toBeNull();
});

it('a thinned SANKEY keeps objectui#7148\'s footnote and never gets this note', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
]);
expect(container.querySelector('[data-chart-note="omitted-rows"]')).not.toBeNull();
expect(noteOf(container)).toBeNull();
});

it('the three codes are mutually exclusive on every dataset in the sweep', () => {
const datasets: Row[][] = [
[{ stage: 'Alpha', amount: 0 }, { stage: 'Beta', amount: 0 }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: null }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: 25 }],
[],
];
for (const family of [...MAGNITUDE_FAMILIES, 'sankey', 'bar']) {
for (const data of datasets) {
const { container } = renderChart(family, data);
const codes = [
container.querySelector('[data-chart-error="no-positive-flow"]'),
container.querySelector('[data-chart-error="no-positive-magnitude"]'),
container.querySelector('[data-chart-note="omitted-rows"]'),
container.querySelector('[data-chart-note="unsized-rows"]'),
].filter(Boolean).length;
expect(codes).toBeLessThanOrEqual(1);
cleanup();
}
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
20 changes: 20 additions & 0 deletions .changeset/degenerate-magnitude-charts.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-charts': patch
---

Pie, donut, funnel and treemap now say when rows carry no magnitude they can draw.

These four families size a mark BY its measure, so a row whose value is zero,
negative, `null` or unparseable stays in the data and is given no area. Measured
in Chromium across 74 tiles: an all-zero pie put ZERO non-white pixels on the
page while its DOM carried 31 descendants and a real `svg`; a treemap handed
`40 / null`, `40 / 0` or `40 / -25 / -12` rendered one full-bleed leaf that was
byte-identical to a genuinely one-row treemap; and a funnel handed `40` beside a
`null` drew no segments at all and labelled the tile with the row that had no
value.

When no row can be sized, these charts now render the file's refusal shell
(`no-positive-magnitude`) instead of a blank tile. When only some rows can be
sized, the chart still draws and carries a note counting the ones it could not.
All-positive charts, charts handed no rows at all, bar charts, and both sankey
answers are unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Pie / donut / funnel / treemap — rows that are NEVER filtered and are given
* no area anyway (objectui#7147). The third mechanism on this surface, and the
* one neither landed answer reaches.
*
* ## Three mechanisms, one reader-facing symptom
*
* - objectui#7140 / objectui#7146 — an early return emitting a bare `div`
* - objectui#7148 — a silent row DROP before the plot
* - objectui#7147 — DEGENERATE GEOMETRY, pinned here
*
* objectui#7148's footnote counts dropped rows (`data.length - rows.length`).
* Against these four families that count is exactly ZERO — the sankey arm holds
* the only row-dropping filter in the file — so hoisting it here would have
* rendered nothing at all while looking like coverage. The rows stay in `data`
* and the LAYOUT gives them no area. Zero area is not zero elements, and
* neither is a dropped row.
*
* ## The measurement that decided fix-over-decline, per family
*
* 74 tiles in real Chromium (`/opt/pw-browsers/chromium`) at `origin/main`
* 40c4711d6, each screenshotted, MD5'd and pixel-diffed against a literally
* empty `div` of the same box. `measured-and-declined` was on the table for
* every family, as the card says, and survived for none of the four:
*
* - pie / donut all-zero, all-null, all-negative: ZERO non-white pixels out
* of 124,800 — byte-identical to the empty div, with 31 descendants and a
* real `svg` in the DOM.
* - pie / donut `40` beside a `null`: a FULL circle in the first category's
* colour, 99.35% pixel-identical to a legitimately one-row dataset (the
* 0.654% residue is the `paddingAngle` hairline, not information).
* - funnel `40` beside a `null`: 178 ink pixels — ZERO segments, ONE label,
* and the label reads "Beta", the row with NO value. The row carrying 40
* drew nothing at all.
* - funnel all-negative: a confident two-band funnel whose mark area
* (220,320) EXCEEDS the all-positive control's (111,881).
* - treemap `40 / null`, `40 / 0` and `40 / -25 / -12`: all three
* BYTE-IDENTICAL (diff 0.000%) to a genuinely one-row treemap. Four
* datasets, one image.
* - treemap all-zero: ONE full-bleed leaf labelled with the LAST category.
*
* The controls are what make those zeros mean anything: an all-zero BAR drew
* 5,128 ink pixels of axes and ticks on the same instrument — which is why bar
* is deliberately untouched and pinned that way below — a two-row pie differed
* from a one-row pie by 9.683% of its pixels, and a two-row treemap from a
* one-row treemap by 38.301%.
*
* ## What this file pins, and why the passing cases are the discriminating half
*
* The refusal and the note are only half the pin. The other half is everything
* that must NOT have moved: the all-positive control of every family keeps its
* exact DOM (no wrapper element), the no-rows case stays byte-for-byte as it
* was (that is the empty-RESULT question, objectui#7130, answered upstream in
* `ObjectChart`), bar keeps its axes, and BOTH landed sankey answers keep
* firing on their own datasets under their own codes. Measured across the same
* 74 tiles: every sankey tile and every bar tile hashed IDENTICALLY before and
* after this change.
*/
import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';

vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import AdvancedChartImpl from './AdvancedChartImpl';

afterEach(cleanup);

const SERIES = [{ dataKey: 'amount', label: 'Amount' }];

type Row = Record<string, unknown>;

const renderChart = (chartType: string, data: Row[]) =>
render(
<AdvancedChartImpl
chartType={chartType as any}
xAxisKey="stage"
series={SERIES as any}
data={data as any}
/>,
);

const refusalOf = (c: HTMLElement) => c.querySelector('[data-chart-error="no-positive-magnitude"]');
const noteOf = (c: HTMLElement) => c.querySelector('[data-chart-note="unsized-rows"]');
const plotOf = (c: HTMLElement) => c.querySelector('[data-slot="chart"]');

/** The four families whose layout sizes a mark BY its measure. */
const MAGNITUDE_FAMILIES = ['pie', 'donut', 'funnel', 'treemap'] as const;

/**
* Every shape that reaches the layout carrying no positive magnitude.
*
* The copy deliberately names NONE of them, for the reason `no-positive-flow`'s
* docstring gives: a message saying "negative" is false of the `null` row, one
* saying "zero" is false of the unparseable string, and one naming a missing key
* is false of all four others. The predicate is true of every one of them, so
* the predicate is what the sentence names.
*/
const UNSIZABLE: Array<[string, unknown]> = [
['a genuine zero', 0],
['a negative', -25],
['null', null],
['undefined', undefined],
['an unparseable string', 'n/a'],
['Infinity, which `Number(x) || 0` would let through', 'Infinity'],
];

describe('objectui#7147 — no row can be sized: the chart refuses instead of drawing nothing', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: every row carrying ${label} gets a refusal, not a blank tile`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: value },
{ stage: 'Beta', amount: value },
]);
const refusal = refusalOf(container);
expect(refusal).not.toBeNull();
// The message names the KEY and the exact test it failed — the same
// diagnostic pair `no-positive-flow` carries, which is why neither
// needs a console warning.
expect(refusal!.textContent).toContain('amount');
expect(refusal!.textContent).toContain('above zero');
// A refusal REPLACES the plot; it does not sit beside one.
expect(plotOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}
}

it('a missing measure key on every row refuses too', () => {
const { container } = renderChart('pie', [{ stage: 'Alpha' }, { stage: 'Beta' }]);
expect(refusalOf(container)).not.toBeNull();
});

it('the refusal uses its OWN code, never the sankey arm\'s', () => {
const { container } = renderChart('funnel', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).toBeNull();
});
});

describe('objectui#7147 — SOME rows can be sized: the chart still draws, and says how many it could not', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: one good row beside ${label} still DRAWS, with a note`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: value },
]);
// The plot is not blanked — objectui#7146 pins the analogous "one
// positive row among zeros still draws" for the sankey arm, and the
// same must hold here or a fix becomes a regression.
expect(plotOf(container)).not.toBeNull();
expect(refusalOf(container)).toBeNull();
const note = noteOf(container);
expect(note).not.toBeNull();
expect(note!.getAttribute('role')).toBe('note');
expect(note!.textContent).toContain('1 of 2 rows has');
expect(note!.textContent).toContain('amount');
});
}
}

it('the COUNT is the half a reader cannot recover from the picture', () => {
const { container } = renderChart('treemap', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
{ stage: 'Gamma', amount: -12 },
]);
// Measured: this dataset rendered ONE full-bleed leaf, byte-identical to a
// genuinely one-row treemap. "Some rows" would leave those two images
// identical in meaning; "2 of 3" is the bit that was missing.
expect(noteOf(container)!.textContent).toContain('2 of 3 rows have');
});

it('the note uses its OWN attribute, never objectui#7148\'s', () => {
const { container } = renderChart('pie', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 0 },
]);
expect(noteOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-note="omitted-rows"]')).toBeNull();
});
});

describe('objectui#7147 — the cases that must NOT have moved', () => {
for (const family of MAGNITUDE_FAMILIES) {
it(`${family}: an all-positive chart gains no note, no refusal and NO WRAPPER`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 25 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
// `ChartFootnote` with a null note returns its children untouched, so the
// plot stays the FIRST element — no existing caller gains a wrapper.
expect(container.firstElementChild?.getAttribute('data-slot')).toBe('chart');
});

it(`${family}: handed NO rows at all, nothing is said`, () => {
// The empty-RESULT question (objectui#7130) is answered upstream in
// `ObjectChart`, where the query outcome is actually known. "No row's
// measure is above zero" would be a sentence about rows that do not
// exist.
const { container } = renderChart(family, []);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}

it('bar is untouched: an all-zero bar chart still draws its axes and says nothing', () => {
// Deliberate, and measured: an all-zero bar drew 5,128 ink pixels of axes
// and ticks against a blank tile's 0. Its reader can already tell a zero
// dataset from a broken widget, so bar is outside this card.
const { container } = renderChart('bar', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
expect(plotOf(container)).not.toBeNull();
});
});

describe('objectui#7147 — the seam with the two landed sankey answers, pinned from both sides', () => {
it('an all-zero SANKEY keeps objectui#7146\'s refusal and never gets this one', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).not.toBeNull();
expect(refusalOf(container)).toBeNull();
});

it('a thinned SANKEY keeps objectui#7148\'s footnote and never gets this note', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
]);
expect(container.querySelector('[data-chart-note="omitted-rows"]')).not.toBeNull();
expect(noteOf(container)).toBeNull();
});

it('the three codes are mutually exclusive on every dataset in the sweep', () => {
const datasets: Row[][] = [
[{ stage: 'Alpha', amount: 0 }, { stage: 'Beta', amount: 0 }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: null }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: 25 }],
[],
];
for (const family of [...MAGNITUDE_FAMILIES, 'sankey', 'bar']) {
for (const data of datasets) {
const { container } = renderChart(family, data);
const codes = [
container.querySelector('[data-chart-error="no-positive-flow"]'),
container.querySelector('[data-chart-error="no-positive-magnitude"]'),
container.querySelector('[data-chart-note="omitted-rows"]'),
container.querySelector('[data-chart-note="unsized-rows"]'),
].filter(Boolean).length;
expect(codes).toBeLessThanOrEqual(1);
cleanup();
}
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
20 changes: 20 additions & 0 deletions .changeset/degenerate-magnitude-charts.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-charts': patch
---

Pie, donut, funnel and treemap now say when rows carry no magnitude they can draw.

These four families size a mark BY its measure, so a row whose value is zero,
negative, `null` or unparseable stays in the data and is given no area. Measured
in Chromium across 74 tiles: an all-zero pie put ZERO non-white pixels on the
page while its DOM carried 31 descendants and a real `svg`; a treemap handed
`40 / null`, `40 / 0` or `40 / -25 / -12` rendered one full-bleed leaf that was
byte-identical to a genuinely one-row treemap; and a funnel handed `40` beside a
`null` drew no segments at all and labelled the tile with the row that had no
value.

When no row can be sized, these charts now render the file's refusal shell
(`no-positive-magnitude`) instead of a blank tile. When only some rows can be
sized, the chart still draws and carries a note counting the ones it could not.
All-positive charts, charts handed no rows at all, bar charts, and both sankey
answers are unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Pie / donut / funnel / treemap — rows that are NEVER filtered and are given
* no area anyway (objectui#7147). The third mechanism on this surface, and the
* one neither landed answer reaches.
*
* ## Three mechanisms, one reader-facing symptom
*
* - objectui#7140 / objectui#7146 — an early return emitting a bare `div`
* - objectui#7148 — a silent row DROP before the plot
* - objectui#7147 — DEGENERATE GEOMETRY, pinned here
*
* objectui#7148's footnote counts dropped rows (`data.length - rows.length`).
* Against these four families that count is exactly ZERO — the sankey arm holds
* the only row-dropping filter in the file — so hoisting it here would have
* rendered nothing at all while looking like coverage. The rows stay in `data`
* and the LAYOUT gives them no area. Zero area is not zero elements, and
* neither is a dropped row.
*
* ## The measurement that decided fix-over-decline, per family
*
* 74 tiles in real Chromium (`/opt/pw-browsers/chromium`) at `origin/main`
* 40c4711d6, each screenshotted, MD5'd and pixel-diffed against a literally
* empty `div` of the same box. `measured-and-declined` was on the table for
* every family, as the card says, and survived for none of the four:
*
* - pie / donut all-zero, all-null, all-negative: ZERO non-white pixels out
* of 124,800 — byte-identical to the empty div, with 31 descendants and a
* real `svg` in the DOM.
* - pie / donut `40` beside a `null`: a FULL circle in the first category's
* colour, 99.35% pixel-identical to a legitimately one-row dataset (the
* 0.654% residue is the `paddingAngle` hairline, not information).
* - funnel `40` beside a `null`: 178 ink pixels — ZERO segments, ONE label,
* and the label reads "Beta", the row with NO value. The row carrying 40
* drew nothing at all.
* - funnel all-negative: a confident two-band funnel whose mark area
* (220,320) EXCEEDS the all-positive control's (111,881).
* - treemap `40 / null`, `40 / 0` and `40 / -25 / -12`: all three
* BYTE-IDENTICAL (diff 0.000%) to a genuinely one-row treemap. Four
* datasets, one image.
* - treemap all-zero: ONE full-bleed leaf labelled with the LAST category.
*
* The controls are what make those zeros mean anything: an all-zero BAR drew
* 5,128 ink pixels of axes and ticks on the same instrument — which is why bar
* is deliberately untouched and pinned that way below — a two-row pie differed
* from a one-row pie by 9.683% of its pixels, and a two-row treemap from a
* one-row treemap by 38.301%.
*
* ## What this file pins, and why the passing cases are the discriminating half
*
* The refusal and the note are only half the pin. The other half is everything
* that must NOT have moved: the all-positive control of every family keeps its
* exact DOM (no wrapper element), the no-rows case stays byte-for-byte as it
* was (that is the empty-RESULT question, objectui#7130, answered upstream in
* `ObjectChart`), bar keeps its axes, and BOTH landed sankey answers keep
* firing on their own datasets under their own codes. Measured across the same
* 74 tiles: every sankey tile and every bar tile hashed IDENTICALLY before and
* after this change.
*/
import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';

vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import AdvancedChartImpl from './AdvancedChartImpl';

afterEach(cleanup);

const SERIES = [{ dataKey: 'amount', label: 'Amount' }];

type Row = Record<string, unknown>;

const renderChart = (chartType: string, data: Row[]) =>
render(
<AdvancedChartImpl
chartType={chartType as any}
xAxisKey="stage"
series={SERIES as any}
data={data as any}
/>,
);

const refusalOf = (c: HTMLElement) => c.querySelector('[data-chart-error="no-positive-magnitude"]');
const noteOf = (c: HTMLElement) => c.querySelector('[data-chart-note="unsized-rows"]');
const plotOf = (c: HTMLElement) => c.querySelector('[data-slot="chart"]');

/** The four families whose layout sizes a mark BY its measure. */
const MAGNITUDE_FAMILIES = ['pie', 'donut', 'funnel', 'treemap'] as const;

/**
* Every shape that reaches the layout carrying no positive magnitude.
*
* The copy deliberately names NONE of them, for the reason `no-positive-flow`'s
* docstring gives: a message saying "negative" is false of the `null` row, one
* saying "zero" is false of the unparseable string, and one naming a missing key
* is false of all four others. The predicate is true of every one of them, so
* the predicate is what the sentence names.
*/
const UNSIZABLE: Array<[string, unknown]> = [
['a genuine zero', 0],
['a negative', -25],
['null', null],
['undefined', undefined],
['an unparseable string', 'n/a'],
['Infinity, which `Number(x) || 0` would let through', 'Infinity'],
];

describe('objectui#7147 — no row can be sized: the chart refuses instead of drawing nothing', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: every row carrying ${label} gets a refusal, not a blank tile`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: value },
{ stage: 'Beta', amount: value },
]);
const refusal = refusalOf(container);
expect(refusal).not.toBeNull();
// The message names the KEY and the exact test it failed — the same
// diagnostic pair `no-positive-flow` carries, which is why neither
// needs a console warning.
expect(refusal!.textContent).toContain('amount');
expect(refusal!.textContent).toContain('above zero');
// A refusal REPLACES the plot; it does not sit beside one.
expect(plotOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}
}

it('a missing measure key on every row refuses too', () => {
const { container } = renderChart('pie', [{ stage: 'Alpha' }, { stage: 'Beta' }]);
expect(refusalOf(container)).not.toBeNull();
});

it('the refusal uses its OWN code, never the sankey arm\'s', () => {
const { container } = renderChart('funnel', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).toBeNull();
});
});

describe('objectui#7147 — SOME rows can be sized: the chart still draws, and says how many it could not', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: one good row beside ${label} still DRAWS, with a note`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: value },
]);
// The plot is not blanked — objectui#7146 pins the analogous "one
// positive row among zeros still draws" for the sankey arm, and the
// same must hold here or a fix becomes a regression.
expect(plotOf(container)).not.toBeNull();
expect(refusalOf(container)).toBeNull();
const note = noteOf(container);
expect(note).not.toBeNull();
expect(note!.getAttribute('role')).toBe('note');
expect(note!.textContent).toContain('1 of 2 rows has');
expect(note!.textContent).toContain('amount');
});
}
}

it('the COUNT is the half a reader cannot recover from the picture', () => {
const { container } = renderChart('treemap', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
{ stage: 'Gamma', amount: -12 },
]);
// Measured: this dataset rendered ONE full-bleed leaf, byte-identical to a
// genuinely one-row treemap. "Some rows" would leave those two images
// identical in meaning; "2 of 3" is the bit that was missing.
expect(noteOf(container)!.textContent).toContain('2 of 3 rows have');
});

it('the note uses its OWN attribute, never objectui#7148\'s', () => {
const { container } = renderChart('pie', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 0 },
]);
expect(noteOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-note="omitted-rows"]')).toBeNull();
});
});

describe('objectui#7147 — the cases that must NOT have moved', () => {
for (const family of MAGNITUDE_FAMILIES) {
it(`${family}: an all-positive chart gains no note, no refusal and NO WRAPPER`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 25 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
// `ChartFootnote` with a null note returns its children untouched, so the
// plot stays the FIRST element — no existing caller gains a wrapper.
expect(container.firstElementChild?.getAttribute('data-slot')).toBe('chart');
});

it(`${family}: handed NO rows at all, nothing is said`, () => {
// The empty-RESULT question (objectui#7130) is answered upstream in
// `ObjectChart`, where the query outcome is actually known. "No row's
// measure is above zero" would be a sentence about rows that do not
// exist.
const { container } = renderChart(family, []);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}

it('bar is untouched: an all-zero bar chart still draws its axes and says nothing', () => {
// Deliberate, and measured: an all-zero bar drew 5,128 ink pixels of axes
// and ticks against a blank tile's 0. Its reader can already tell a zero
// dataset from a broken widget, so bar is outside this card.
const { container } = renderChart('bar', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
expect(plotOf(container)).not.toBeNull();
});
});

describe('objectui#7147 — the seam with the two landed sankey answers, pinned from both sides', () => {
it('an all-zero SANKEY keeps objectui#7146\'s refusal and never gets this one', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).not.toBeNull();
expect(refusalOf(container)).toBeNull();
});

it('a thinned SANKEY keeps objectui#7148\'s footnote and never gets this note', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
]);
expect(container.querySelector('[data-chart-note="omitted-rows"]')).not.toBeNull();
expect(noteOf(container)).toBeNull();
});

it('the three codes are mutually exclusive on every dataset in the sweep', () => {
const datasets: Row[][] = [
[{ stage: 'Alpha', amount: 0 }, { stage: 'Beta', amount: 0 }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: null }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: 25 }],
[],
];
for (const family of [...MAGNITUDE_FAMILIES, 'sankey', 'bar']) {
for (const data of datasets) {
const { container } = renderChart(family, data);
const codes = [
container.querySelector('[data-chart-error="no-positive-flow"]'),
container.querySelector('[data-chart-error="no-positive-magnitude"]'),
container.querySelector('[data-chart-note="omitted-rows"]'),
container.querySelector('[data-chart-note="unsized-rows"]'),
].filter(Boolean).length;
expect(codes).toBeLessThanOrEqual(1);
cleanup();
}
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
20 changes: 20 additions & 0 deletions .changeset/degenerate-magnitude-charts.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-charts': patch
---

Pie, donut, funnel and treemap now say when rows carry no magnitude they can draw.

These four families size a mark BY its measure, so a row whose value is zero,
negative, `null` or unparseable stays in the data and is given no area. Measured
in Chromium across 74 tiles: an all-zero pie put ZERO non-white pixels on the
page while its DOM carried 31 descendants and a real `svg`; a treemap handed
`40 / null`, `40 / 0` or `40 / -25 / -12` rendered one full-bleed leaf that was
byte-identical to a genuinely one-row treemap; and a funnel handed `40` beside a
`null` drew no segments at all and labelled the tile with the row that had no
value.

When no row can be sized, these charts now render the file's refusal shell
(`no-positive-magnitude`) instead of a blank tile. When only some rows can be
sized, the chart still draws and carries a note counting the ones it could not.
All-positive charts, charts handed no rows at all, bar charts, and both sankey
answers are unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Pie / donut / funnel / treemap — rows that are NEVER filtered and are given
* no area anyway (objectui#7147). The third mechanism on this surface, and the
* one neither landed answer reaches.
*
* ## Three mechanisms, one reader-facing symptom
*
* - objectui#7140 / objectui#7146 — an early return emitting a bare `div`
* - objectui#7148 — a silent row DROP before the plot
* - objectui#7147 — DEGENERATE GEOMETRY, pinned here
*
* objectui#7148's footnote counts dropped rows (`data.length - rows.length`).
* Against these four families that count is exactly ZERO — the sankey arm holds
* the only row-dropping filter in the file — so hoisting it here would have
* rendered nothing at all while looking like coverage. The rows stay in `data`
* and the LAYOUT gives them no area. Zero area is not zero elements, and
* neither is a dropped row.
*
* ## The measurement that decided fix-over-decline, per family
*
* 74 tiles in real Chromium (`/opt/pw-browsers/chromium`) at `origin/main`
* 40c4711d6, each screenshotted, MD5'd and pixel-diffed against a literally
* empty `div` of the same box. `measured-and-declined` was on the table for
* every family, as the card says, and survived for none of the four:
*
* - pie / donut all-zero, all-null, all-negative: ZERO non-white pixels out
* of 124,800 — byte-identical to the empty div, with 31 descendants and a
* real `svg` in the DOM.
* - pie / donut `40` beside a `null`: a FULL circle in the first category's
* colour, 99.35% pixel-identical to a legitimately one-row dataset (the
* 0.654% residue is the `paddingAngle` hairline, not information).
* - funnel `40` beside a `null`: 178 ink pixels — ZERO segments, ONE label,
* and the label reads "Beta", the row with NO value. The row carrying 40
* drew nothing at all.
* - funnel all-negative: a confident two-band funnel whose mark area
* (220,320) EXCEEDS the all-positive control's (111,881).
* - treemap `40 / null`, `40 / 0` and `40 / -25 / -12`: all three
* BYTE-IDENTICAL (diff 0.000%) to a genuinely one-row treemap. Four
* datasets, one image.
* - treemap all-zero: ONE full-bleed leaf labelled with the LAST category.
*
* The controls are what make those zeros mean anything: an all-zero BAR drew
* 5,128 ink pixels of axes and ticks on the same instrument — which is why bar
* is deliberately untouched and pinned that way below — a two-row pie differed
* from a one-row pie by 9.683% of its pixels, and a two-row treemap from a
* one-row treemap by 38.301%.
*
* ## What this file pins, and why the passing cases are the discriminating half
*
* The refusal and the note are only half the pin. The other half is everything
* that must NOT have moved: the all-positive control of every family keeps its
* exact DOM (no wrapper element), the no-rows case stays byte-for-byte as it
* was (that is the empty-RESULT question, objectui#7130, answered upstream in
* `ObjectChart`), bar keeps its axes, and BOTH landed sankey answers keep
* firing on their own datasets under their own codes. Measured across the same
* 74 tiles: every sankey tile and every bar tile hashed IDENTICALLY before and
* after this change.
*/
import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';

vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import AdvancedChartImpl from './AdvancedChartImpl';

afterEach(cleanup);

const SERIES = [{ dataKey: 'amount', label: 'Amount' }];

type Row = Record<string, unknown>;

const renderChart = (chartType: string, data: Row[]) =>
render(
<AdvancedChartImpl
chartType={chartType as any}
xAxisKey="stage"
series={SERIES as any}
data={data as any}
/>,
);

const refusalOf = (c: HTMLElement) => c.querySelector('[data-chart-error="no-positive-magnitude"]');
const noteOf = (c: HTMLElement) => c.querySelector('[data-chart-note="unsized-rows"]');
const plotOf = (c: HTMLElement) => c.querySelector('[data-slot="chart"]');

/** The four families whose layout sizes a mark BY its measure. */
const MAGNITUDE_FAMILIES = ['pie', 'donut', 'funnel', 'treemap'] as const;

/**
* Every shape that reaches the layout carrying no positive magnitude.
*
* The copy deliberately names NONE of them, for the reason `no-positive-flow`'s
* docstring gives: a message saying "negative" is false of the `null` row, one
* saying "zero" is false of the unparseable string, and one naming a missing key
* is false of all four others. The predicate is true of every one of them, so
* the predicate is what the sentence names.
*/
const UNSIZABLE: Array<[string, unknown]> = [
['a genuine zero', 0],
['a negative', -25],
['null', null],
['undefined', undefined],
['an unparseable string', 'n/a'],
['Infinity, which `Number(x) || 0` would let through', 'Infinity'],
];

describe('objectui#7147 — no row can be sized: the chart refuses instead of drawing nothing', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: every row carrying ${label} gets a refusal, not a blank tile`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: value },
{ stage: 'Beta', amount: value },
]);
const refusal = refusalOf(container);
expect(refusal).not.toBeNull();
// The message names the KEY and the exact test it failed — the same
// diagnostic pair `no-positive-flow` carries, which is why neither
// needs a console warning.
expect(refusal!.textContent).toContain('amount');
expect(refusal!.textContent).toContain('above zero');
// A refusal REPLACES the plot; it does not sit beside one.
expect(plotOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}
}

it('a missing measure key on every row refuses too', () => {
const { container } = renderChart('pie', [{ stage: 'Alpha' }, { stage: 'Beta' }]);
expect(refusalOf(container)).not.toBeNull();
});

it('the refusal uses its OWN code, never the sankey arm\'s', () => {
const { container } = renderChart('funnel', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).toBeNull();
});
});

describe('objectui#7147 — SOME rows can be sized: the chart still draws, and says how many it could not', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: one good row beside ${label} still DRAWS, with a note`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: value },
]);
// The plot is not blanked — objectui#7146 pins the analogous "one
// positive row among zeros still draws" for the sankey arm, and the
// same must hold here or a fix becomes a regression.
expect(plotOf(container)).not.toBeNull();
expect(refusalOf(container)).toBeNull();
const note = noteOf(container);
expect(note).not.toBeNull();
expect(note!.getAttribute('role')).toBe('note');
expect(note!.textContent).toContain('1 of 2 rows has');
expect(note!.textContent).toContain('amount');
});
}
}

it('the COUNT is the half a reader cannot recover from the picture', () => {
const { container } = renderChart('treemap', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
{ stage: 'Gamma', amount: -12 },
]);
// Measured: this dataset rendered ONE full-bleed leaf, byte-identical to a
// genuinely one-row treemap. "Some rows" would leave those two images
// identical in meaning; "2 of 3" is the bit that was missing.
expect(noteOf(container)!.textContent).toContain('2 of 3 rows have');
});

it('the note uses its OWN attribute, never objectui#7148\'s', () => {
const { container } = renderChart('pie', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 0 },
]);
expect(noteOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-note="omitted-rows"]')).toBeNull();
});
});

describe('objectui#7147 — the cases that must NOT have moved', () => {
for (const family of MAGNITUDE_FAMILIES) {
it(`${family}: an all-positive chart gains no note, no refusal and NO WRAPPER`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 25 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
// `ChartFootnote` with a null note returns its children untouched, so the
// plot stays the FIRST element — no existing caller gains a wrapper.
expect(container.firstElementChild?.getAttribute('data-slot')).toBe('chart');
});

it(`${family}: handed NO rows at all, nothing is said`, () => {
// The empty-RESULT question (objectui#7130) is answered upstream in
// `ObjectChart`, where the query outcome is actually known. "No row's
// measure is above zero" would be a sentence about rows that do not
// exist.
const { container } = renderChart(family, []);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}

it('bar is untouched: an all-zero bar chart still draws its axes and says nothing', () => {
// Deliberate, and measured: an all-zero bar drew 5,128 ink pixels of axes
// and ticks against a blank tile's 0. Its reader can already tell a zero
// dataset from a broken widget, so bar is outside this card.
const { container } = renderChart('bar', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
expect(plotOf(container)).not.toBeNull();
});
});

describe('objectui#7147 — the seam with the two landed sankey answers, pinned from both sides', () => {
it('an all-zero SANKEY keeps objectui#7146\'s refusal and never gets this one', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).not.toBeNull();
expect(refusalOf(container)).toBeNull();
});

it('a thinned SANKEY keeps objectui#7148\'s footnote and never gets this note', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
]);
expect(container.querySelector('[data-chart-note="omitted-rows"]')).not.toBeNull();
expect(noteOf(container)).toBeNull();
});

it('the three codes are mutually exclusive on every dataset in the sweep', () => {
const datasets: Row[][] = [
[{ stage: 'Alpha', amount: 0 }, { stage: 'Beta', amount: 0 }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: null }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: 25 }],
[],
];
for (const family of [...MAGNITUDE_FAMILIES, 'sankey', 'bar']) {
for (const data of datasets) {
const { container } = renderChart(family, data);
const codes = [
container.querySelector('[data-chart-error="no-positive-flow"]'),
container.querySelector('[data-chart-error="no-positive-magnitude"]'),
container.querySelector('[data-chart-note="omitted-rows"]'),
container.querySelector('[data-chart-note="unsized-rows"]'),
].filter(Boolean).length;
expect(codes).toBeLessThanOrEqual(1);
cleanup();
}
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
20 changes: 20 additions & 0 deletions .changeset/degenerate-magnitude-charts.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-charts': patch
---

Pie, donut, funnel and treemap now say when rows carry no magnitude they can draw.

These four families size a mark BY its measure, so a row whose value is zero,
negative, `null` or unparseable stays in the data and is given no area. Measured
in Chromium across 74 tiles: an all-zero pie put ZERO non-white pixels on the
page while its DOM carried 31 descendants and a real `svg`; a treemap handed
`40 / null`, `40 / 0` or `40 / -25 / -12` rendered one full-bleed leaf that was
byte-identical to a genuinely one-row treemap; and a funnel handed `40` beside a
`null` drew no segments at all and labelled the tile with the row that had no
value.

When no row can be sized, these charts now render the file's refusal shell
(`no-positive-magnitude`) instead of a blank tile. When only some rows can be
sized, the chart still draws and carries a note counting the ones it could not.
All-positive charts, charts handed no rows at all, bar charts, and both sankey
answers are unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Pie / donut / funnel / treemap — rows that are NEVER filtered and are given
* no area anyway (objectui#7147). The third mechanism on this surface, and the
* one neither landed answer reaches.
*
* ## Three mechanisms, one reader-facing symptom
*
* - objectui#7140 / objectui#7146 — an early return emitting a bare `div`
* - objectui#7148 — a silent row DROP before the plot
* - objectui#7147 — DEGENERATE GEOMETRY, pinned here
*
* objectui#7148's footnote counts dropped rows (`data.length - rows.length`).
* Against these four families that count is exactly ZERO — the sankey arm holds
* the only row-dropping filter in the file — so hoisting it here would have
* rendered nothing at all while looking like coverage. The rows stay in `data`
* and the LAYOUT gives them no area. Zero area is not zero elements, and
* neither is a dropped row.
*
* ## The measurement that decided fix-over-decline, per family
*
* 74 tiles in real Chromium (`/opt/pw-browsers/chromium`) at `origin/main`
* 40c4711d6, each screenshotted, MD5'd and pixel-diffed against a literally
* empty `div` of the same box. `measured-and-declined` was on the table for
* every family, as the card says, and survived for none of the four:
*
* - pie / donut all-zero, all-null, all-negative: ZERO non-white pixels out
* of 124,800 — byte-identical to the empty div, with 31 descendants and a
* real `svg` in the DOM.
* - pie / donut `40` beside a `null`: a FULL circle in the first category's
* colour, 99.35% pixel-identical to a legitimately one-row dataset (the
* 0.654% residue is the `paddingAngle` hairline, not information).
* - funnel `40` beside a `null`: 178 ink pixels — ZERO segments, ONE label,
* and the label reads "Beta", the row with NO value. The row carrying 40
* drew nothing at all.
* - funnel all-negative: a confident two-band funnel whose mark area
* (220,320) EXCEEDS the all-positive control's (111,881).
* - treemap `40 / null`, `40 / 0` and `40 / -25 / -12`: all three
* BYTE-IDENTICAL (diff 0.000%) to a genuinely one-row treemap. Four
* datasets, one image.
* - treemap all-zero: ONE full-bleed leaf labelled with the LAST category.
*
* The controls are what make those zeros mean anything: an all-zero BAR drew
* 5,128 ink pixels of axes and ticks on the same instrument — which is why bar
* is deliberately untouched and pinned that way below — a two-row pie differed
* from a one-row pie by 9.683% of its pixels, and a two-row treemap from a
* one-row treemap by 38.301%.
*
* ## What this file pins, and why the passing cases are the discriminating half
*
* The refusal and the note are only half the pin. The other half is everything
* that must NOT have moved: the all-positive control of every family keeps its
* exact DOM (no wrapper element), the no-rows case stays byte-for-byte as it
* was (that is the empty-RESULT question, objectui#7130, answered upstream in
* `ObjectChart`), bar keeps its axes, and BOTH landed sankey answers keep
* firing on their own datasets under their own codes. Measured across the same
* 74 tiles: every sankey tile and every bar tile hashed IDENTICALLY before and
* after this change.
*/
import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';

vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import AdvancedChartImpl from './AdvancedChartImpl';

afterEach(cleanup);

const SERIES = [{ dataKey: 'amount', label: 'Amount' }];

type Row = Record<string, unknown>;

const renderChart = (chartType: string, data: Row[]) =>
render(
<AdvancedChartImpl
chartType={chartType as any}
xAxisKey="stage"
series={SERIES as any}
data={data as any}
/>,
);

const refusalOf = (c: HTMLElement) => c.querySelector('[data-chart-error="no-positive-magnitude"]');
const noteOf = (c: HTMLElement) => c.querySelector('[data-chart-note="unsized-rows"]');
const plotOf = (c: HTMLElement) => c.querySelector('[data-slot="chart"]');

/** The four families whose layout sizes a mark BY its measure. */
const MAGNITUDE_FAMILIES = ['pie', 'donut', 'funnel', 'treemap'] as const;

/**
* Every shape that reaches the layout carrying no positive magnitude.
*
* The copy deliberately names NONE of them, for the reason `no-positive-flow`'s
* docstring gives: a message saying "negative" is false of the `null` row, one
* saying "zero" is false of the unparseable string, and one naming a missing key
* is false of all four others. The predicate is true of every one of them, so
* the predicate is what the sentence names.
*/
const UNSIZABLE: Array<[string, unknown]> = [
['a genuine zero', 0],
['a negative', -25],
['null', null],
['undefined', undefined],
['an unparseable string', 'n/a'],
['Infinity, which `Number(x) || 0` would let through', 'Infinity'],
];

describe('objectui#7147 — no row can be sized: the chart refuses instead of drawing nothing', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: every row carrying ${label} gets a refusal, not a blank tile`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: value },
{ stage: 'Beta', amount: value },
]);
const refusal = refusalOf(container);
expect(refusal).not.toBeNull();
// The message names the KEY and the exact test it failed — the same
// diagnostic pair `no-positive-flow` carries, which is why neither
// needs a console warning.
expect(refusal!.textContent).toContain('amount');
expect(refusal!.textContent).toContain('above zero');
// A refusal REPLACES the plot; it does not sit beside one.
expect(plotOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}
}

it('a missing measure key on every row refuses too', () => {
const { container } = renderChart('pie', [{ stage: 'Alpha' }, { stage: 'Beta' }]);
expect(refusalOf(container)).not.toBeNull();
});

it('the refusal uses its OWN code, never the sankey arm\'s', () => {
const { container } = renderChart('funnel', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).toBeNull();
});
});

describe('objectui#7147 — SOME rows can be sized: the chart still draws, and says how many it could not', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: one good row beside ${label} still DRAWS, with a note`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: value },
]);
// The plot is not blanked — objectui#7146 pins the analogous "one
// positive row among zeros still draws" for the sankey arm, and the
// same must hold here or a fix becomes a regression.
expect(plotOf(container)).not.toBeNull();
expect(refusalOf(container)).toBeNull();
const note = noteOf(container);
expect(note).not.toBeNull();
expect(note!.getAttribute('role')).toBe('note');
expect(note!.textContent).toContain('1 of 2 rows has');
expect(note!.textContent).toContain('amount');
});
}
}

it('the COUNT is the half a reader cannot recover from the picture', () => {
const { container } = renderChart('treemap', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
{ stage: 'Gamma', amount: -12 },
]);
// Measured: this dataset rendered ONE full-bleed leaf, byte-identical to a
// genuinely one-row treemap. "Some rows" would leave those two images
// identical in meaning; "2 of 3" is the bit that was missing.
expect(noteOf(container)!.textContent).toContain('2 of 3 rows have');
});

it('the note uses its OWN attribute, never objectui#7148\'s', () => {
const { container } = renderChart('pie', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 0 },
]);
expect(noteOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-note="omitted-rows"]')).toBeNull();
});
});

describe('objectui#7147 — the cases that must NOT have moved', () => {
for (const family of MAGNITUDE_FAMILIES) {
it(`${family}: an all-positive chart gains no note, no refusal and NO WRAPPER`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 25 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
// `ChartFootnote` with a null note returns its children untouched, so the
// plot stays the FIRST element — no existing caller gains a wrapper.
expect(container.firstElementChild?.getAttribute('data-slot')).toBe('chart');
});

it(`${family}: handed NO rows at all, nothing is said`, () => {
// The empty-RESULT question (objectui#7130) is answered upstream in
// `ObjectChart`, where the query outcome is actually known. "No row's
// measure is above zero" would be a sentence about rows that do not
// exist.
const { container } = renderChart(family, []);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}

it('bar is untouched: an all-zero bar chart still draws its axes and says nothing', () => {
// Deliberate, and measured: an all-zero bar drew 5,128 ink pixels of axes
// and ticks against a blank tile's 0. Its reader can already tell a zero
// dataset from a broken widget, so bar is outside this card.
const { container } = renderChart('bar', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
expect(plotOf(container)).not.toBeNull();
});
});

describe('objectui#7147 — the seam with the two landed sankey answers, pinned from both sides', () => {
it('an all-zero SANKEY keeps objectui#7146\'s refusal and never gets this one', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).not.toBeNull();
expect(refusalOf(container)).toBeNull();
});

it('a thinned SANKEY keeps objectui#7148\'s footnote and never gets this note', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
]);
expect(container.querySelector('[data-chart-note="omitted-rows"]')).not.toBeNull();
expect(noteOf(container)).toBeNull();
});

it('the three codes are mutually exclusive on every dataset in the sweep', () => {
const datasets: Row[][] = [
[{ stage: 'Alpha', amount: 0 }, { stage: 'Beta', amount: 0 }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: null }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: 25 }],
[],
];
for (const family of [...MAGNITUDE_FAMILIES, 'sankey', 'bar']) {
for (const data of datasets) {
const { container } = renderChart(family, data);
const codes = [
container.querySelector('[data-chart-error="no-positive-flow"]'),
container.querySelector('[data-chart-error="no-positive-magnitude"]'),
container.querySelector('[data-chart-note="omitted-rows"]'),
container.querySelector('[data-chart-note="unsized-rows"]'),
].filter(Boolean).length;
expect(codes).toBeLessThanOrEqual(1);
cleanup();
}
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
20 changes: 20 additions & 0 deletions .changeset/degenerate-magnitude-charts.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-charts': patch
---

Pie, donut, funnel and treemap now say when rows carry no magnitude they can draw.

These four families size a mark BY its measure, so a row whose value is zero,
negative, `null` or unparseable stays in the data and is given no area. Measured
in Chromium across 74 tiles: an all-zero pie put ZERO non-white pixels on the
page while its DOM carried 31 descendants and a real `svg`; a treemap handed
`40 / null`, `40 / 0` or `40 / -25 / -12` rendered one full-bleed leaf that was
byte-identical to a genuinely one-row treemap; and a funnel handed `40` beside a
`null` drew no segments at all and labelled the tile with the row that had no
value.

When no row can be sized, these charts now render the file's refusal shell
(`no-positive-magnitude`) instead of a blank tile. When only some rows can be
sized, the chart still draws and carries a note counting the ones it could not.
All-positive charts, charts handed no rows at all, bar charts, and both sankey
answers are unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Pie / donut / funnel / treemap — rows that are NEVER filtered and are given
* no area anyway (objectui#7147). The third mechanism on this surface, and the
* one neither landed answer reaches.
*
* ## Three mechanisms, one reader-facing symptom
*
* - objectui#7140 / objectui#7146 — an early return emitting a bare `div`
* - objectui#7148 — a silent row DROP before the plot
* - objectui#7147 — DEGENERATE GEOMETRY, pinned here
*
* objectui#7148's footnote counts dropped rows (`data.length - rows.length`).
* Against these four families that count is exactly ZERO — the sankey arm holds
* the only row-dropping filter in the file — so hoisting it here would have
* rendered nothing at all while looking like coverage. The rows stay in `data`
* and the LAYOUT gives them no area. Zero area is not zero elements, and
* neither is a dropped row.
*
* ## The measurement that decided fix-over-decline, per family
*
* 74 tiles in real Chromium (`/opt/pw-browsers/chromium`) at `origin/main`
* 40c4711d6, each screenshotted, MD5'd and pixel-diffed against a literally
* empty `div` of the same box. `measured-and-declined` was on the table for
* every family, as the card says, and survived for none of the four:
*
* - pie / donut all-zero, all-null, all-negative: ZERO non-white pixels out
* of 124,800 — byte-identical to the empty div, with 31 descendants and a
* real `svg` in the DOM.
* - pie / donut `40` beside a `null`: a FULL circle in the first category's
* colour, 99.35% pixel-identical to a legitimately one-row dataset (the
* 0.654% residue is the `paddingAngle` hairline, not information).
* - funnel `40` beside a `null`: 178 ink pixels — ZERO segments, ONE label,
* and the label reads "Beta", the row with NO value. The row carrying 40
* drew nothing at all.
* - funnel all-negative: a confident two-band funnel whose mark area
* (220,320) EXCEEDS the all-positive control's (111,881).
* - treemap `40 / null`, `40 / 0` and `40 / -25 / -12`: all three
* BYTE-IDENTICAL (diff 0.000%) to a genuinely one-row treemap. Four
* datasets, one image.
* - treemap all-zero: ONE full-bleed leaf labelled with the LAST category.
*
* The controls are what make those zeros mean anything: an all-zero BAR drew
* 5,128 ink pixels of axes and ticks on the same instrument — which is why bar
* is deliberately untouched and pinned that way below — a two-row pie differed
* from a one-row pie by 9.683% of its pixels, and a two-row treemap from a
* one-row treemap by 38.301%.
*
* ## What this file pins, and why the passing cases are the discriminating half
*
* The refusal and the note are only half the pin. The other half is everything
* that must NOT have moved: the all-positive control of every family keeps its
* exact DOM (no wrapper element), the no-rows case stays byte-for-byte as it
* was (that is the empty-RESULT question, objectui#7130, answered upstream in
* `ObjectChart`), bar keeps its axes, and BOTH landed sankey answers keep
* firing on their own datasets under their own codes. Measured across the same
* 74 tiles: every sankey tile and every bar tile hashed IDENTICALLY before and
* after this change.
*/
import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';

vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import AdvancedChartImpl from './AdvancedChartImpl';

afterEach(cleanup);

const SERIES = [{ dataKey: 'amount', label: 'Amount' }];

type Row = Record<string, unknown>;

const renderChart = (chartType: string, data: Row[]) =>
render(
<AdvancedChartImpl
chartType={chartType as any}
xAxisKey="stage"
series={SERIES as any}
data={data as any}
/>,
);

const refusalOf = (c: HTMLElement) => c.querySelector('[data-chart-error="no-positive-magnitude"]');
const noteOf = (c: HTMLElement) => c.querySelector('[data-chart-note="unsized-rows"]');
const plotOf = (c: HTMLElement) => c.querySelector('[data-slot="chart"]');

/** The four families whose layout sizes a mark BY its measure. */
const MAGNITUDE_FAMILIES = ['pie', 'donut', 'funnel', 'treemap'] as const;

/**
* Every shape that reaches the layout carrying no positive magnitude.
*
* The copy deliberately names NONE of them, for the reason `no-positive-flow`'s
* docstring gives: a message saying "negative" is false of the `null` row, one
* saying "zero" is false of the unparseable string, and one naming a missing key
* is false of all four others. The predicate is true of every one of them, so
* the predicate is what the sentence names.
*/
const UNSIZABLE: Array<[string, unknown]> = [
['a genuine zero', 0],
['a negative', -25],
['null', null],
['undefined', undefined],
['an unparseable string', 'n/a'],
['Infinity, which `Number(x) || 0` would let through', 'Infinity'],
];

describe('objectui#7147 — no row can be sized: the chart refuses instead of drawing nothing', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: every row carrying ${label} gets a refusal, not a blank tile`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: value },
{ stage: 'Beta', amount: value },
]);
const refusal = refusalOf(container);
expect(refusal).not.toBeNull();
// The message names the KEY and the exact test it failed — the same
// diagnostic pair `no-positive-flow` carries, which is why neither
// needs a console warning.
expect(refusal!.textContent).toContain('amount');
expect(refusal!.textContent).toContain('above zero');
// A refusal REPLACES the plot; it does not sit beside one.
expect(plotOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}
}

it('a missing measure key on every row refuses too', () => {
const { container } = renderChart('pie', [{ stage: 'Alpha' }, { stage: 'Beta' }]);
expect(refusalOf(container)).not.toBeNull();
});

it('the refusal uses its OWN code, never the sankey arm\'s', () => {
const { container } = renderChart('funnel', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).toBeNull();
});
});

describe('objectui#7147 — SOME rows can be sized: the chart still draws, and says how many it could not', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: one good row beside ${label} still DRAWS, with a note`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: value },
]);
// The plot is not blanked — objectui#7146 pins the analogous "one
// positive row among zeros still draws" for the sankey arm, and the
// same must hold here or a fix becomes a regression.
expect(plotOf(container)).not.toBeNull();
expect(refusalOf(container)).toBeNull();
const note = noteOf(container);
expect(note).not.toBeNull();
expect(note!.getAttribute('role')).toBe('note');
expect(note!.textContent).toContain('1 of 2 rows has');
expect(note!.textContent).toContain('amount');
});
}
}

it('the COUNT is the half a reader cannot recover from the picture', () => {
const { container } = renderChart('treemap', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
{ stage: 'Gamma', amount: -12 },
]);
// Measured: this dataset rendered ONE full-bleed leaf, byte-identical to a
// genuinely one-row treemap. "Some rows" would leave those two images
// identical in meaning; "2 of 3" is the bit that was missing.
expect(noteOf(container)!.textContent).toContain('2 of 3 rows have');
});

it('the note uses its OWN attribute, never objectui#7148\'s', () => {
const { container } = renderChart('pie', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 0 },
]);
expect(noteOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-note="omitted-rows"]')).toBeNull();
});
});

describe('objectui#7147 — the cases that must NOT have moved', () => {
for (const family of MAGNITUDE_FAMILIES) {
it(`${family}: an all-positive chart gains no note, no refusal and NO WRAPPER`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 25 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
// `ChartFootnote` with a null note returns its children untouched, so the
// plot stays the FIRST element — no existing caller gains a wrapper.
expect(container.firstElementChild?.getAttribute('data-slot')).toBe('chart');
});

it(`${family}: handed NO rows at all, nothing is said`, () => {
// The empty-RESULT question (objectui#7130) is answered upstream in
// `ObjectChart`, where the query outcome is actually known. "No row's
// measure is above zero" would be a sentence about rows that do not
// exist.
const { container } = renderChart(family, []);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}

it('bar is untouched: an all-zero bar chart still draws its axes and says nothing', () => {
// Deliberate, and measured: an all-zero bar drew 5,128 ink pixels of axes
// and ticks against a blank tile's 0. Its reader can already tell a zero
// dataset from a broken widget, so bar is outside this card.
const { container } = renderChart('bar', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
expect(plotOf(container)).not.toBeNull();
});
});

describe('objectui#7147 — the seam with the two landed sankey answers, pinned from both sides', () => {
it('an all-zero SANKEY keeps objectui#7146\'s refusal and never gets this one', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).not.toBeNull();
expect(refusalOf(container)).toBeNull();
});

it('a thinned SANKEY keeps objectui#7148\'s footnote and never gets this note', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
]);
expect(container.querySelector('[data-chart-note="omitted-rows"]')).not.toBeNull();
expect(noteOf(container)).toBeNull();
});

it('the three codes are mutually exclusive on every dataset in the sweep', () => {
const datasets: Row[][] = [
[{ stage: 'Alpha', amount: 0 }, { stage: 'Beta', amount: 0 }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: null }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: 25 }],
[],
];
for (const family of [...MAGNITUDE_FAMILIES, 'sankey', 'bar']) {
for (const data of datasets) {
const { container } = renderChart(family, data);
const codes = [
container.querySelector('[data-chart-error="no-positive-flow"]'),
container.querySelector('[data-chart-error="no-positive-magnitude"]'),
container.querySelector('[data-chart-note="omitted-rows"]'),
container.querySelector('[data-chart-note="unsized-rows"]'),
].filter(Boolean).length;
expect(codes).toBeLessThanOrEqual(1);
cleanup();
}
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
20 changes: 20 additions & 0 deletions .changeset/degenerate-magnitude-charts.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-charts': patch
---

Pie, donut, funnel and treemap now say when rows carry no magnitude they can draw.

These four families size a mark BY its measure, so a row whose value is zero,
negative, `null` or unparseable stays in the data and is given no area. Measured
in Chromium across 74 tiles: an all-zero pie put ZERO non-white pixels on the
page while its DOM carried 31 descendants and a real `svg`; a treemap handed
`40 / null`, `40 / 0` or `40 / -25 / -12` rendered one full-bleed leaf that was
byte-identical to a genuinely one-row treemap; and a funnel handed `40` beside a
`null` drew no segments at all and labelled the tile with the row that had no
value.

When no row can be sized, these charts now render the file's refusal shell
(`no-positive-magnitude`) instead of a blank tile. When only some rows can be
sized, the chart still draws and carries a note counting the ones it could not.
All-positive charts, charts handed no rows at all, bar charts, and both sankey
answers are unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Pie / donut / funnel / treemap — rows that are NEVER filtered and are given
* no area anyway (objectui#7147). The third mechanism on this surface, and the
* one neither landed answer reaches.
*
* ## Three mechanisms, one reader-facing symptom
*
* - objectui#7140 / objectui#7146 — an early return emitting a bare `div`
* - objectui#7148 — a silent row DROP before the plot
* - objectui#7147 — DEGENERATE GEOMETRY, pinned here
*
* objectui#7148's footnote counts dropped rows (`data.length - rows.length`).
* Against these four families that count is exactly ZERO — the sankey arm holds
* the only row-dropping filter in the file — so hoisting it here would have
* rendered nothing at all while looking like coverage. The rows stay in `data`
* and the LAYOUT gives them no area. Zero area is not zero elements, and
* neither is a dropped row.
*
* ## The measurement that decided fix-over-decline, per family
*
* 74 tiles in real Chromium (`/opt/pw-browsers/chromium`) at `origin/main`
* 40c4711d6, each screenshotted, MD5'd and pixel-diffed against a literally
* empty `div` of the same box. `measured-and-declined` was on the table for
* every family, as the card says, and survived for none of the four:
*
* - pie / donut all-zero, all-null, all-negative: ZERO non-white pixels out
* of 124,800 — byte-identical to the empty div, with 31 descendants and a
* real `svg` in the DOM.
* - pie / donut `40` beside a `null`: a FULL circle in the first category's
* colour, 99.35% pixel-identical to a legitimately one-row dataset (the
* 0.654% residue is the `paddingAngle` hairline, not information).
* - funnel `40` beside a `null`: 178 ink pixels — ZERO segments, ONE label,
* and the label reads "Beta", the row with NO value. The row carrying 40
* drew nothing at all.
* - funnel all-negative: a confident two-band funnel whose mark area
* (220,320) EXCEEDS the all-positive control's (111,881).
* - treemap `40 / null`, `40 / 0` and `40 / -25 / -12`: all three
* BYTE-IDENTICAL (diff 0.000%) to a genuinely one-row treemap. Four
* datasets, one image.
* - treemap all-zero: ONE full-bleed leaf labelled with the LAST category.
*
* The controls are what make those zeros mean anything: an all-zero BAR drew
* 5,128 ink pixels of axes and ticks on the same instrument — which is why bar
* is deliberately untouched and pinned that way below — a two-row pie differed
* from a one-row pie by 9.683% of its pixels, and a two-row treemap from a
* one-row treemap by 38.301%.
*
* ## What this file pins, and why the passing cases are the discriminating half
*
* The refusal and the note are only half the pin. The other half is everything
* that must NOT have moved: the all-positive control of every family keeps its
* exact DOM (no wrapper element), the no-rows case stays byte-for-byte as it
* was (that is the empty-RESULT question, objectui#7130, answered upstream in
* `ObjectChart`), bar keeps its axes, and BOTH landed sankey answers keep
* firing on their own datasets under their own codes. Measured across the same
* 74 tiles: every sankey tile and every bar tile hashed IDENTICALLY before and
* after this change.
*/
import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';

vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import AdvancedChartImpl from './AdvancedChartImpl';

afterEach(cleanup);

const SERIES = [{ dataKey: 'amount', label: 'Amount' }];

type Row = Record<string, unknown>;

const renderChart = (chartType: string, data: Row[]) =>
render(
<AdvancedChartImpl
chartType={chartType as any}
xAxisKey="stage"
series={SERIES as any}
data={data as any}
/>,
);

const refusalOf = (c: HTMLElement) => c.querySelector('[data-chart-error="no-positive-magnitude"]');
const noteOf = (c: HTMLElement) => c.querySelector('[data-chart-note="unsized-rows"]');
const plotOf = (c: HTMLElement) => c.querySelector('[data-slot="chart"]');

/** The four families whose layout sizes a mark BY its measure. */
const MAGNITUDE_FAMILIES = ['pie', 'donut', 'funnel', 'treemap'] as const;

/**
* Every shape that reaches the layout carrying no positive magnitude.
*
* The copy deliberately names NONE of them, for the reason `no-positive-flow`'s
* docstring gives: a message saying "negative" is false of the `null` row, one
* saying "zero" is false of the unparseable string, and one naming a missing key
* is false of all four others. The predicate is true of every one of them, so
* the predicate is what the sentence names.
*/
const UNSIZABLE: Array<[string, unknown]> = [
['a genuine zero', 0],
['a negative', -25],
['null', null],
['undefined', undefined],
['an unparseable string', 'n/a'],
['Infinity, which `Number(x) || 0` would let through', 'Infinity'],
];

describe('objectui#7147 — no row can be sized: the chart refuses instead of drawing nothing', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: every row carrying ${label} gets a refusal, not a blank tile`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: value },
{ stage: 'Beta', amount: value },
]);
const refusal = refusalOf(container);
expect(refusal).not.toBeNull();
// The message names the KEY and the exact test it failed — the same
// diagnostic pair `no-positive-flow` carries, which is why neither
// needs a console warning.
expect(refusal!.textContent).toContain('amount');
expect(refusal!.textContent).toContain('above zero');
// A refusal REPLACES the plot; it does not sit beside one.
expect(plotOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}
}

it('a missing measure key on every row refuses too', () => {
const { container } = renderChart('pie', [{ stage: 'Alpha' }, { stage: 'Beta' }]);
expect(refusalOf(container)).not.toBeNull();
});

it('the refusal uses its OWN code, never the sankey arm\'s', () => {
const { container } = renderChart('funnel', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).toBeNull();
});
});

describe('objectui#7147 — SOME rows can be sized: the chart still draws, and says how many it could not', () => {
for (const family of MAGNITUDE_FAMILIES) {
for (const [label, value] of UNSIZABLE) {
it(`${family}: one good row beside ${label} still DRAWS, with a note`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: value },
]);
// The plot is not blanked — objectui#7146 pins the analogous "one
// positive row among zeros still draws" for the sankey arm, and the
// same must hold here or a fix becomes a regression.
expect(plotOf(container)).not.toBeNull();
expect(refusalOf(container)).toBeNull();
const note = noteOf(container);
expect(note).not.toBeNull();
expect(note!.getAttribute('role')).toBe('note');
expect(note!.textContent).toContain('1 of 2 rows has');
expect(note!.textContent).toContain('amount');
});
}
}

it('the COUNT is the half a reader cannot recover from the picture', () => {
const { container } = renderChart('treemap', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
{ stage: 'Gamma', amount: -12 },
]);
// Measured: this dataset rendered ONE full-bleed leaf, byte-identical to a
// genuinely one-row treemap. "Some rows" would leave those two images
// identical in meaning; "2 of 3" is the bit that was missing.
expect(noteOf(container)!.textContent).toContain('2 of 3 rows have');
});

it('the note uses its OWN attribute, never objectui#7148\'s', () => {
const { container } = renderChart('pie', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 0 },
]);
expect(noteOf(container)).not.toBeNull();
expect(container.querySelector('[data-chart-note="omitted-rows"]')).toBeNull();
});
});

describe('objectui#7147 — the cases that must NOT have moved', () => {
for (const family of MAGNITUDE_FAMILIES) {
it(`${family}: an all-positive chart gains no note, no refusal and NO WRAPPER`, () => {
const { container } = renderChart(family, [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: 25 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
// `ChartFootnote` with a null note returns its children untouched, so the
// plot stays the FIRST element — no existing caller gains a wrapper.
expect(container.firstElementChild?.getAttribute('data-slot')).toBe('chart');
});

it(`${family}: handed NO rows at all, nothing is said`, () => {
// The empty-RESULT question (objectui#7130) is answered upstream in
// `ObjectChart`, where the query outcome is actually known. "No row's
// measure is above zero" would be a sentence about rows that do not
// exist.
const { container } = renderChart(family, []);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
});
}

it('bar is untouched: an all-zero bar chart still draws its axes and says nothing', () => {
// Deliberate, and measured: an all-zero bar drew 5,128 ink pixels of axes
// and ticks against a blank tile's 0. Its reader can already tell a zero
// dataset from a broken widget, so bar is outside this card.
const { container } = renderChart('bar', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(refusalOf(container)).toBeNull();
expect(noteOf(container)).toBeNull();
expect(plotOf(container)).not.toBeNull();
});
});

describe('objectui#7147 — the seam with the two landed sankey answers, pinned from both sides', () => {
it('an all-zero SANKEY keeps objectui#7146\'s refusal and never gets this one', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 0 },
{ stage: 'Beta', amount: 0 },
]);
expect(container.querySelector('[data-chart-error="no-positive-flow"]')).not.toBeNull();
expect(refusalOf(container)).toBeNull();
});

it('a thinned SANKEY keeps objectui#7148\'s footnote and never gets this note', () => {
const { container } = renderChart('sankey', [
{ stage: 'Alpha', amount: 40 },
{ stage: 'Beta', amount: -25 },
]);
expect(container.querySelector('[data-chart-note="omitted-rows"]')).not.toBeNull();
expect(noteOf(container)).toBeNull();
});

it('the three codes are mutually exclusive on every dataset in the sweep', () => {
const datasets: Row[][] = [
[{ stage: 'Alpha', amount: 0 }, { stage: 'Beta', amount: 0 }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: null }],
[{ stage: 'Alpha', amount: 40 }, { stage: 'Beta', amount: 25 }],
[],
];
for (const family of [...MAGNITUDE_FAMILIES, 'sankey', 'bar']) {
for (const data of datasets) {
const { container } = renderChart(family, data);
const codes = [
container.querySelector('[data-chart-error="no-positive-flow"]'),
container.querySelector('[data-chart-error="no-positive-magnitude"]'),
container.querySelector('[data-chart-note="omitted-rows"]'),
container.querySelector('[data-chart-note="unsized-rows"]'),
].filter(Boolean).length;
expect(codes).toBeLessThanOrEqual(1);
cleanup();
}
}
});
});
Loading
Loading