Skip to content

Commit d7625ea

Browse files
fix(plugin-charts): a two-dimension pie, donut, funnel or treemap draws every group or refuses, never the first group alone (#27736)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Fixes #27563 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:仪表板的矩形树图拿到两个维度(例如 阶段 × 负责人,金额求和)时,按「阶段 → 负责人」两级嵌套画出每一格;饼图、环形图、漏斗图拿到两个维度时不再只画第一个负责人的数,而是在磁贴上显示拒绝说明,并点名每一个负责人分组。 - 为什么改:第二个维度被透视成多条序列,这四个图表分支只读第一条,其余分组静默丢掉,看起来像一张完整的图。裁决是「每组都画,或者拒绝,绝不出半张图」:树图原生支持嵌套,所以画全;饼图一圈只能表达一个整体的份额、漏斗每段只能是一个总数,没有放第二条序列的位置,所以拒绝。 - 风险与回滚:只动这四个图表分支和仪表板组件给这几类图传的序列;单维度图表画法不变,对比叠加(compareTo)不算分组,没有数据时仍是原来的空图。仪表板外带多个度量的饼图/漏斗(例如手写多条 yAxis)现在也会拒绝、树图会按度量嵌套,见 Acceptance notes 第一条,请席位确认;回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-charts/src/AdvancedChartImpl.tsx` 的 `buildTreemapGroups`、`OneSeriesFamilyRefusal` 和 pie/funnel/treemap 分支开头;`ui/packages/plugin-dashboard/src/DatasetWidget.tsx` 的 `chartSeries`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-charts/src/AdvancedChartImpl.twoDimensionSingleSeries-27563.test.tsx packages/plugin-dashboard/src/__tests__/DatasetWidget.twoDimensionSingleSeries-27563.test.tsx`,预期 32 条全绿。 - 需要你做的:无,席位落地。 ## What changes A dashboard widget (or dataset-bound chart) of type `pie`, `donut`, `funnel` or `treemap` with `dimensions: ['stage', 'owner']` and one measure reaches the renderer pivoted by `buildChartSeries`: one row per stage, one series per owner. These four arms read `series[0]` alone, so they drew the first owner's amounts as if they were the whole chart. Triage's rule (the same as #27460): draw every group, or refuse a shape the chart cannot draw; never a partial chart. Per family: - **Treemap: draws every group.** Nesting is native to Recharts' `Treemap`, so two or more groups are drawn as two levels (`buildTreemapGroups`, the treemap counterpart of #27560's `buildSankeyCellFlows`): each stage is a parent, each owner it has records for a leaf inside it, sized by the measure. A leaf is named `stage / owner` (the title the dashboard's drill gives the same cell; a parent's own name is painted over by its leaves) and takes its stage's colour. A combination the dataset did not answer is no leaf and no omission. A carried cell that cannot be sized is left out and the existing `unsized-rows` note is printed once per owner that lost one; no sizable cell at all is the existing `no-positive-magnitude` refusal, now naming every owner it tested. Clicking a leaf drills to its cell (`{ category, series }`, as the two-level sankey's flow click does). - **Pie, donut, funnel: refuse on the tile.** A pie's angle is one share of one whole and a funnel stage one total, so a second series has no place on either; a second ring or a stacked funnel would be a new visual with its own legend and colour contract, which this card does not decide. Handed two or more groups with rows to draw, these arms now return `OneSeriesFamilyRefusal` in the existing `ChartRefusal` shell (`role="status"`, `data-chart-error="one-series-family"`): "A pie draws one series, and this chart carries 3: `Ann`, `Bo`, `Cy`. Drawing only the first would hide the others, so none is drawn." Every group is named, and the sentence stays true if the series are measures rather than groups. - **Which series are groups.** `groupSeries` (the sankey's `sankeyFlowSeries`, renamed and shared) drops a comparison overlay, so a `compareTo` treemap stays one level. - **The dashboard hands these families one measure.** `DatasetWidget` already handed a sankey only the measure it renders (#27560); the same filter now covers every `SINGLE_SERIES_CHART_FAMILIES` family. A stored widget of these types that declares several measures with one dimension (refused at publish by the spec's single-series arm since 17.7.0) therefore still draws its first measure, and the dropped-measure console warning stays true, instead of its second measure being read as a group. A pivot carries one measure, so nothing is dropped from it. Unchanged: every one-dimension chart of the four types (regression pins below), the empty-rows path, the sankey. ## Tests - `ui/packages/plugin-charts/src/AdvancedChartImpl.twoDimensionSingleSeries-27563.test.tsx` (20): the fixture is a real `buildChartSeries` pivot (stage × owner, sum amount). Treemap: one leaf per answered cell with its size, shared stage colour, the per-owner note for a cell at -5, the magnitude refusal naming every owner, the leaf click's drill event; pie/donut/funnel: no `Pie`/`Funnel` element rendered and the refusal names every owner; regression pins per family for one dimension, the compared treemap, and no rows. Marks are read from the props each Recharts element was rendered with, not jsdom pixels. - `ui/packages/plugin-charts/src/ChartRenderer.yAxisUndrawn-10691.test.tsx`: `draw` settles on the plot or the renderer's refusal, and the pie control asserts the one-series refusal (see Acceptance notes). - `ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.twoDimensionSingleSeries-27563.test.tsx` (12): the card's widgets through `DatasetWidget` → `SchemaRenderer` → `AdvancedChartImpl`: the treemap draws 5 depth-2 leaves, pie/donut/funnel refuse naming every owner, a one-dimension widget of each type draws, and a stored one-dimension widget with two measures still draws (no group refusal, no nested treemap). ## Acceptance notes - **Mechanism measured, premise holds.** On `origin/main` (`525ea3632`) the renderer file swapped in: the treemap drew 3 one-level leaves with no children, and pie, donut and funnel each rendered one `Pie`/`Funnel` element bound to the first owner's column (15 of the 32 new tests red, every control green). - **Refusal on the tile, not at publish.** Triage's text says "refuse at publish". A publish-time refusal of a second dimension on these types is a spec door (`packages/spec`, outside this card); the tile refusal meets "never a partial chart" today and names every group. Whether the spec should also refuse the shape at publish is the seat's call. - **⚠ Seat to confirm: charts outside the dashboard that carry several MEASURES.** `AdvancedChartImpl` cannot tell a pivoted group from a second measure; it reads two or more non-comparison series as groups, as #27560 made the sankey do. The dashboard hands these families one measure (above). Two other shapes reach the arms with several measures and change too: a chart block that authors a `yAxis` list on a pie, donut or funnel, and a page chart bound to a dataset with ONE dimension and TWO measures (`checkDashboardWidgetChartMeasureArity` is attached to the dashboard widget schema only, so no spec door refuses either there). Both used to draw their first measure silently, a partial chart; a pie, donut or funnel now shows the one-series refusal naming every measure, and a treemap nests the measures under each category. Measured: `ChartRenderer.yAxisUndrawn-10691.test.tsx`'s control "(control) pie, three entries: no note" settled on a drawn `svg`, which the pie no longer draws; its subject (no y-axis note on a pie) still holds, so the helper now settles on the plot or the refusal and the control also asserts the refusal. If the seat wants those multi-measure charts kept drawing their first measure, the alternative is to mark pivot series in `buildChartSeries` (`@object-ui/core`, a published type: `Clause-②: yes (widening)`) and refuse only on that marker. - **Copy is English**, matching every sibling refusal in this file (`no-positive-magnitude`, `no-positive-flow`, `missing-series-key`); no locale keys added. - A third dimension still collapses into the (d0, d1) cell, as the card notes; untouched. - **File surface:** `DatasetWidget.tsx` is touched for the measure filter (the line #27560 added for the sankey, widened) and two comments, not for a refusal note; the Claim lists the file conditionally. ## Verification Local subset at head `4fb5e46c` (after merging `origin/main`); the two commits after it touch only the changeset, and the changeset-reading gates were re-run at `d12c72c8`. CI runs the full farm. <details><summary>Premise and ablation (base `525ea3632`)</summary> Each leg swapped files from the base into the tree with `git restore --source=525ea3632`, checked the blob hash, ran, and restored from `HEAD` under a trap (restore proven by `git hash-object` equal to the `HEAD` blob and `git diff HEAD` empty). - Renderer and widget at base: the two new files, 15 failed / 17 passed (every subject red, every control green). The treemap drew three one-level leaves (`Prospect`, `Proposal`, `Won`) with no children; pie, donut and funnel each rendered one `Pie` / `Funnel` element (bound to the first owner's column). - Widget measure filter alone at base (renderer fixed): 8 failed: the four "stored one-dimension TYPE with two measures still draws its first measure" controls, and the four `fires iff the second measure's values leave the drawn chart unchanged` rows of `DatasetWidget.singleSeriesMeasures-11417.test.tsx` for pie, donut, funnel and treemap. </details> <details><summary>Tests</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-charts/ packages/plugin-dashboard/` (under the verify lock, at `5d26a453`): Test Files 297 passed, 1 failed (298); the failure was the `ChartRenderer.yAxisUndrawn-10691` pie control described in Acceptance notes, updated in the next commit. - The updated control, the 27563 renderer file and every ui test file that names a pie, donut, funnel or treemap outside the two packages (console `unfulfilled-chart-stubs-8760`, app-shell `listViewLegacyChartRetired-6152` and `ObjectView.chartRelay-7823`, plugin-chatbot `ChatbotEnhanced.chartOrder-13492` and `useObjectChat.chartOrderRehydrate-13492`, plugin-list `ListView.chart-capability-7544`, the whole `plugin-report/src/__tests__/`): Test Files 38 passed (38), Tests 390 passed, 7 skipped. - At `4fb5e46c`: the two 27563 files, the 10691 file, `DatasetWidget.singleSeriesMeasures-11417`, and both 27460 sankey files: Test Files 6 passed (6), Tests 81 passed. - `pnpm --filter @object-ui/plugin-charts run type-check && pnpm --filter @object-ui/plugin-dashboard run type-check`: exit 0 (both programs include the new tests via `tsconfig.test.json`). - eslint on the touched files: 0 errors; the warnings on new lines are `no-explicit-any` in the renderer test's recharts mocks, the shape the 27460 test uses. </details> <details><summary>Derived gates (dispatch-gates --commands --repo objectstack-ai/objectstack: 35; all run; reconciled with --ran)</summary> ```text node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 ``` `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files: 0 errors; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CanXq6PY9nH1pZ5E5m)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d8372a commit d7625ea

6 files changed

Lines changed: 571 additions & 23 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/plugin-charts': patch
3+
'@object-ui/plugin-dashboard': patch
4+
---
5+
6+
fix(plugin-charts): a pie, donut, funnel or treemap with two dimensions draws every second-dimension group or refuses on the tile, instead of silently drawing the first group only
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A dashboard widget (or a dataset-bound chart) of type `pie`, `donut`, `funnel` or `treemap` with `dimensions: ['stage', 'owner']` and one measure used to draw only the first `owner` value's amounts; every other owner was dropped and nothing on the chart said so. The second dimension reaches the renderer pivoted into one series per value, and these arms read the first series only.
12+
13+
- **Treemap draws every cell.** Two or more series on a treemap are now drawn as two levels: each first-dimension value is a parent, and each second-dimension value it has records for is a leaf inside it, sized by the measure and named `stage / owner`. A cell that is not above zero is left out, and the chart prints the existing "not drawn to scale" note once per second-dimension value that lost a cell; a chart with no cell above zero shows the existing "nothing to size" refusal, naming every value it tested. Clicking a leaf drills to its cell.
14+
- **Pie, donut and funnel refuse on the tile.** A pie slice is one share of one whole and a funnel stage one total, so neither has a place for a second series. Handed two or more, they now draw nothing and show a refusal (`data-chart-error="one-series-family"`) that names every group, instead of the first group's chart.
15+
- **Several measures outside a dashboard.** The renderer cannot tell a second-dimension group from a second measure, so a pie, donut or funnel chart block that authors a `yAxis` list, or a page chart with one dimension and two measures, now shows the same refusal naming every measure instead of drawing the first one alone, and such a treemap nests the measures under each category.
16+
- **Unchanged:** a chart with one dimension and one measure draws exactly as before on all four types, a comparison overlay (`compareTo`) is never read as a group, and a chart with no rows is the empty chart it was. A stored dashboard widget of these types that declares several measures (refused at publish by the spec) is handed only its first measure, as before, so its other measures are not read as groups.

‎ui/packages/plugin-charts/src/AdvancedChartImpl.tsx‎

Lines changed: 180 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,11 +1154,13 @@ function SankeyOmittedRowsNote({ shown, total, dataKey }: { shown: number; total
11541154
}
11551155

11561156
/**
1157-
* The series a sankey draws as FLOWS (objectstack#27460): every series except a
1158-
* comparison overlay, which is the same measure one period back and no group of
1159-
* it, so it never becomes a flow.
1157+
* The series a one-series family (sankey, treemap, pie, donut, funnel) reads as
1158+
* the GROUPS of a pivoted second dimension (objectstack#27460,
1159+
* objectstack#27563): every series except a comparison overlay, which is the
1160+
* same measure one period back and no group of it. Two or more of them mean the
1161+
* chart's one measure arrived split by a second dimension; one means it did not.
11601162
*/
1161-
const sankeyFlowSeries = (series: readonly NormalizedSeries[]): NormalizedSeries[] =>
1163+
const groupSeries = (series: readonly NormalizedSeries[]): NormalizedSeries[] =>
11621164
series.filter((s) => s.variant !== 'comparison');
11631165

11641166
/** One drawn flow of a two-level sankey, and the cell it was drawn from. */
@@ -1240,6 +1242,68 @@ function buildSankeyCellFlows(
12401242
return { nodes, links, omitted };
12411243
}
12421244

1245+
/**
1246+
* One leaf of a two-level treemap: a (category, group) cell, and the row and
1247+
* series it was drawn from. A type alias, not an interface: Recharts' treemap
1248+
* datum is an index-signature record, which only an alias is assignable to.
1249+
*/
1250+
type TreemapCellLeaf = {
1251+
name: string;
1252+
size: number;
1253+
fill: string;
1254+
/** The `data` row the cell belongs to — what a click drills to, with `seriesKey`. */
1255+
rowIndex: number;
1256+
/** The series (the second dimension's group) the cell is. */
1257+
seriesKey: string;
1258+
};
1259+
1260+
/**
1261+
* A TWO-LEVEL treemap: each row's category is a parent, and each group it
1262+
* carries is a leaf inside it (objectstack#27563).
1263+
*
1264+
* The treemap counterpart of {@link buildSankeyCellFlows}, for the same input:
1265+
* two or more groups reaching the arm are the groups of its one measure, split
1266+
* by a pivoted second dimension. Nesting is native to Recharts' `Treemap` (a
1267+
* parent's area is the sum of its leaves), so every cell is drawn; the arm used
1268+
* to read `series[0]` alone and drew the first group's cells as the whole chart.
1269+
*
1270+
* A cell the row does not CARRY is no leaf and no omission (the pivot writes a
1271+
* column only for a combination the dataset answered). A carried cell that
1272+
* cannot be sized (not a finite number above zero, the `countSizableRows` test)
1273+
* is left out and counted per group, so the chart says which group lost it.
1274+
* A leaf is named `category / group`, the title the dashboard's drill gives the
1275+
* same cell, because a parent's own name is painted over by its leaves; the
1276+
* leaves of one category share its colour.
1277+
*/
1278+
function buildTreemapGroups(
1279+
data: ReadonlyArray<Record<string, unknown>>,
1280+
xAxisKey: string,
1281+
groups: readonly NormalizedSeries[],
1282+
palette: readonly string[],
1283+
): { nodes: Array<{ name: string; fill: string; children: TreemapCellLeaf[] }>; carried: number; omitted: SankeyOmittedColumn[] } {
1284+
const isSizable = (v: unknown) => Number.isFinite(Number(v)) && Number(v) > 0;
1285+
const carries = (row: Record<string, unknown> | null | undefined, key: string) =>
1286+
row != null && Object.prototype.hasOwnProperty.call(row, key);
1287+
const nodes = data.flatMap((row, rowIndex) => {
1288+
const category = String(row?.[xAxisKey] ?? '');
1289+
const fill = resolveColor(palette[rowIndex % palette.length]);
1290+
const children = groups.flatMap((s) =>
1291+
carries(row, s.dataKey) && isSizable(row[s.dataKey])
1292+
? [{ name: `${category} / ${String(s.label ?? s.dataKey)}`, size: Number(row[s.dataKey]), fill, rowIndex, seriesKey: s.dataKey }]
1293+
: [],
1294+
);
1295+
return children.length > 0 ? [{ name: category, fill, children }] : [];
1296+
});
1297+
let carried = 0;
1298+
const omitted = groups.flatMap((s) => {
1299+
const total = data.filter((row) => carries(row, s.dataKey)).length;
1300+
const shown = data.filter((row) => carries(row, s.dataKey) && isSizable(row[s.dataKey])).length;
1301+
carried += total;
1302+
return shown < total ? [{ label: String(s.label ?? s.dataKey), shown, total }] : [];
1303+
});
1304+
return { nodes, carried, omitted };
1305+
}
1306+
12431307
/**
12441308
* Renders the spec's `ChartConfig.title` / `.subtitle` above the plot.
12451309
*
@@ -1385,12 +1449,60 @@ function countSizableRows(rows: unknown[], dataKey: string): { sizable: number;
13851449
* exist. That is the empty-RESULT question (objectui#7130), answered upstream in
13861450
* `ObjectChart` where the query outcome is known — so every no-rows tile is left
13871451
* byte-for-byte as it was.
1452+
*
1453+
* A two-level treemap (objectstack#27563) tests every group's column, so it
1454+
* hands a list and the sentence names each one, as `no-positive-flow` does.
13881455
*/
1389-
function MagnitudeRefusal({ dataKey, className }: { dataKey: string; className?: string }) {
1456+
function MagnitudeRefusal({ dataKey, className }: { dataKey: string | readonly string[]; className?: string }) {
1457+
const keys = typeof dataKey === 'string' ? [dataKey] : dataKey;
13901458
return (
13911459
<ChartRefusal code="no-positive-magnitude" className={className}>
13921460
This chart has nothing to size: no row&apos;s{' '}
1393-
<code className="font-mono">{dataKey}</code> is above zero.
1461+
{keys.map((key, i) => (
1462+
<React.Fragment key={`${i}-${key}`}>
1463+
{i > 0 ? ' / ' : null}
1464+
<code className="font-mono">{key}</code>
1465+
</React.Fragment>
1466+
))}{' '}
1467+
is above zero.
1468+
</ChartRefusal>
1469+
);
1470+
}
1471+
1472+
/**
1473+
* The refusal a ONE-series family (pie, donut, funnel) renders when it is
1474+
* handed two or more groups (objectstack#27563).
1475+
*
1476+
* Those groups are almost always a second dimension the dataset pivoted into
1477+
* one series per value. A pie's angle is one share of one whole and a funnel's
1478+
* width one total per stage, so neither has a place for a second series, and
1479+
* drawing the first one alone is a partial chart that looks complete: every
1480+
* other group silently dropped. So the chart draws nothing and names every
1481+
* group, which stays true when the series are measures instead of groups.
1482+
* (The treemap nests them and the sankey flows them; both draw every group.)
1483+
*
1484+
* Called only with rows to draw: handed NO rows, the chart is the empty-result
1485+
* tile it always was, as `MagnitudeRefusal`'s callers gate on `total > 0`.
1486+
*/
1487+
function OneSeriesFamilyRefusal({
1488+
family,
1489+
groups,
1490+
className,
1491+
}: {
1492+
family: string;
1493+
groups: readonly NormalizedSeries[];
1494+
className?: string;
1495+
}) {
1496+
return (
1497+
<ChartRefusal code="one-series-family" className={className}>
1498+
A {family} draws one series, and this chart carries {groups.length}:{' '}
1499+
{groups.map((s, i) => (
1500+
<React.Fragment key={s.dataKey}>
1501+
{i > 0 ? ', ' : null}
1502+
<code className="font-mono">{String(s.label ?? s.dataKey)}</code>
1503+
</React.Fragment>
1504+
))}
1505+
. Drawing only the first would hide the others, so none is drawn.
13941506
</ChartRefusal>
13951507
);
13961508
}
@@ -2137,6 +2249,27 @@ function AdvancedChartImplInner({
21372249
});
21382250
}, [onChartClick, data, xAxisKey, series]);
21392251

2252+
// A two-level treemap (objectstack#27563) drills on a LEAF, which is exactly
2253+
// one cell: Recharts reports only leaf clicks, and each leaf carries its row
2254+
// and series, the `{ category, series }` pair the drill lookup resolves to one
2255+
// dataset row — the two-level sankey's flow click, for a leaf.
2256+
const handleTreemapCellClick = React.useCallback((node: unknown) => {
2257+
if (!onChartClick) return;
2258+
const leaf = node as Partial<TreemapCellLeaf> | undefined;
2259+
const row = typeof leaf?.rowIndex === 'number' ? data[leaf.rowIndex] : undefined;
2260+
const key = leaf?.seriesKey;
2261+
if (!row || typeof key !== 'string') return;
2262+
const cat = row[xAxisKey];
2263+
onChartClick({
2264+
category: cat != null ? String(cat) : undefined,
2265+
categoryId: chartRowBucketId(row),
2266+
series: key,
2267+
seriesLabel: seriesLabelForKey(key, series),
2268+
value: typeof row[key] === 'number' ? row[key] : undefined,
2269+
});
2270+
}, [onChartClick, data, xAxisKey, series]);
2271+
2272+
const treemapCellClickProps = onChartClick ? { onClick: handleTreemapCellClick, style: { cursor: 'pointer' as const } } : {};
21402273
const sankeyClickProps = onChartClick ? { onClick: handleSankeyClick, style: { cursor: 'pointer' as const } } : {};
21412274
const sankeyFlowClickProps = onChartClick ? { onClick: handleSankeyFlowClick, style: { cursor: 'pointer' as const } } : {};
21422275

@@ -2617,6 +2750,12 @@ function AdvancedChartImplInner({
26172750

26182751
// Pie and Donut charts
26192752
if (chartType === 'pie' || chartType === 'donut') {
2753+
// objectstack#27563 — two or more groups have no ring to go on; see
2754+
// `OneSeriesFamilyRefusal`. Never the first group's slices alone.
2755+
const pieGroups = groupSeries(series);
2756+
if (pieGroups.length >= 2 && data.length > 0) {
2757+
return <OneSeriesFamilyRefusal family={chartType} groups={pieGroups} className={className} />;
2758+
}
26202759
const innerRadius = chartType === 'donut' ? '52%' : 0;
26212760
const palette = getPalette();
26222761
// objectui#7147 — see `countSizableRows`. A slice's angle is its share of
@@ -2690,6 +2829,11 @@ function AdvancedChartImplInner({
26902829

26912830
// Funnel chart — uses recharts FunnelChart (single series only)
26922831
if (chartType === 'funnel') {
2832+
// objectstack#27563 — a funnel stage is one total; see `OneSeriesFamilyRefusal`.
2833+
const funnelGroups = groupSeries(series);
2834+
if (funnelGroups.length >= 2 && data.length > 0) {
2835+
return <OneSeriesFamilyRefusal family="funnel" groups={funnelGroups} className={className} />;
2836+
}
26932837
const dataKey = series[0]?.dataKey || 'value';
26942838
const palette = getPalette();
26952839
// objectui#7147 — see `countSizableRows`. Recharts derives each segment's
@@ -2782,6 +2926,35 @@ function AdvancedChartImplInner({
27822926
// chart root (no wrapping cartesian chart); a custom content paints each
27832927
// leaf with a palette color + label.
27842928
if (chartType === 'treemap') {
2929+
// Two or more groups are a pivoted second dimension, drawn as a second
2930+
// level of leaves (objectstack#27563, `buildTreemapGroups`). One group
2931+
// keeps the one-level treemap below.
2932+
const treemapGroups = groupSeries(series);
2933+
if (treemapGroups.length >= 2 && data.length > 0) {
2934+
const cells = buildTreemapGroups(data, xAxisKey, treemapGroups, getPalette());
2935+
if (cells.nodes.length === 0 && cells.carried > 0) {
2936+
return <MagnitudeRefusal dataKey={treemapGroups.map((s) => String(s.label ?? s.dataKey))} className={className} />;
2937+
}
2938+
return (
2939+
<ChartFootnote
2940+
note={
2941+
cells.omitted.length > 0 ? (
2942+
<>
2943+
{cells.omitted.map((c) => (
2944+
<React.Fragment key={c.label}>{unsizedRowsNote(c.shown, c.total, c.label)}</React.Fragment>
2945+
))}
2946+
</>
2947+
) : null
2948+
}
2949+
>
2950+
<ChartContainer config={config} className={className} {...containerProps}>
2951+
<Treemap data={cells.nodes} dataKey="size" nameKey="name" {...animProps} content={<TreemapCell />} {...treemapCellClickProps}>
2952+
<Tooltip />
2953+
</Treemap>
2954+
</ChartContainer>
2955+
</ChartFootnote>
2956+
);
2957+
}
27852958
const dataKey = series[0]?.dataKey || 'value';
27862959
const palette = getPalette();
27872960
// objectui#7147 — see `countSizableRows`. A treemap's leaf area IS its
@@ -2820,7 +2993,7 @@ function AdvancedChartImplInner({
28202993
// Two or more flow series are the groups of a pivoted second dimension,
28212994
// drawn as a second column of nodes (objectstack#27460,
28222995
// `buildSankeyCellFlows`). One series keeps the one-level flow below.
2823-
const flowSeries = sankeyFlowSeries(series);
2996+
const flowSeries = groupSeries(series);
28242997
if (flowSeries.length >= 2) {
28252998
const flows = buildSankeyCellFlows(data, xAxisKey, flowSeries, getPalette());
28262999
if (flows.links.length === 0) {

0 commit comments

Comments
 (0)