From 281ecd798066de988b5ac43859e31e68b960850a Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:06:45 +0800 Subject: [PATCH] fix(bar-table): honour Rank ordinal semantics instead of length-encoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rank semantic type is an ordinal (a standing), not a magnitude. The Bar Table template length-encoded it — bar length plus a sequential colour ramp — which inverted the ranking: rank 1 got the shortest, palest bar and the last-placed item the longest, darkest one. Honour the documented behaviour ("Rank → reversed axis (1 on top), discrete color") in both the Vega-Lite and Plotly templates: - order rows by rank ascending (1 first / on top), - use a discrete colour scale instead of a magnitude ramp, - draw equal-length bars so the mark no longer implies a magnitude that is not there. Fixes #85 --- .../src/plotly/templates/bar-table.ts | 18 +++-- .../src/vegalite/templates/bar-table.ts | 52 ++++++++++---- .../flint-js/tests/bar-table-rank.test.ts | 68 +++++++++++++++++++ 3 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 packages/flint-js/tests/bar-table-rank.test.ts diff --git a/packages/flint-js/src/plotly/templates/bar-table.ts b/packages/flint-js/src/plotly/templates/bar-table.ts index c1bccb6a..c21483e6 100644 --- a/packages/flint-js/src/plotly/templates/bar-table.ts +++ b/packages/flint-js/src/plotly/templates/bar-table.ts @@ -108,7 +108,7 @@ interface AggRow { /** Aggregate raw rows into ranked-and-topN'd category rows for one facet scope. */ function buildScopeRows( rows: any[], yField: string, xField: string, colorField: string | undefined, - useMean: boolean, maxRows: number, reversed: boolean, + useMean: boolean, maxRows: number, reversed: boolean, xOrdinal: boolean, ): AggRow[] { const byCat = new Map }>(); for (const r of rows) { @@ -126,7 +126,7 @@ function buildScopeRows( const agg = (g: { sum: number; n: number }) => useMean ? g.sum / Math.max(1, g.n) : g.sum; const ranked = Array.from(byCat.entries()) .map(([cat, g]) => ({ cat, value: agg(g), byColor: colorField ? g.byColor : undefined })) - .sort((a, b) => reversed ? a.value - b.value : b.value - a.value); + .sort((a, b) => (reversed || xOrdinal) ? a.value - b.value : b.value - a.value); if (maxRows <= 0 || ranked.length <= maxRows) { return ranked.map(r => ({ ...r, isOthers: false })); @@ -177,6 +177,10 @@ export const plBarTableDef: ChartTemplateDef = { const showPercent = chartProperties?.showPercent === true; const useMean = channelSemantics.x?.aggregationDefault === 'average'; const reversed = !!channelSemantics.y?.reversed; + // Ordinal measures (Rank) are standings, not magnitudes — length-encoding + // them inverts the ranking (see issue #85). Honor the documented `Rank` + // behaviour: rank ascending (1 first), discrete colour, equal-length bars. + const xIsOrdinal = channelSemantics.x?.type === 'ordinal'; const xEntry = getRegistryEntry(channelSemantics.x?.semanticAnnotation?.semanticType ?? 'Unknown'); let hasNegative = false, hasPositive = false; @@ -233,7 +237,7 @@ export const plBarTableDef: ChartTemplateDef = { // ── Per-cell aggregation (Top-N rollup within each facet scope). ── const scoped = cells.map(row => row.map(cell => - buildScopeRows(cell.rows, yField, xField, colorField, useMean, maxRows, reversed))); + buildScopeRows(cell.rows, yField, xField, colorField, useMean, maxRows, reversed, xIsOrdinal))); const allColorValues = colorField ? [...new Set(scoped.flat().flatMap(sr => sr.filter(r => !r.isOthers).flatMap(r => [...(r.byColor?.keys() ?? [])])))] @@ -374,12 +378,16 @@ export const plBarTableDef: ChartTemplateDef = { }); } } else { - const vals = sr.map(r => r.value); + const vals = xIsOrdinal ? sr.map(() => 1) : sr.map(r => r.value); const finite = vals.filter(Number.isFinite); const vmin = finite.length ? Math.min(...finite, 0) : 0; const vmax = finite.length ? Math.max(...finite) : 1; - const colors = sr.map(r => { + const colors = sr.map((r, idx) => { if (r.isOthers) return OTHERS_GRAY; + if (xIsOrdinal) { + // Discrete colour per rank — no magnitude ramp. + return palette[idx % palette.length]; + } if (isDiverging) { const span = Math.max(Math.abs(vmin), Math.abs(vmax)) || 1; const t = r.value / span; // -1..1 diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index eaab140a..5132e136 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -115,6 +115,17 @@ export const barTableDef: ChartTemplateDef = { const yCS: ChannelSemantics | undefined = ctx.channelSemantics?.y; const xEntry = getRegistryEntry(xCS?.semanticAnnotation?.semanticType ?? 'Unknown'); + // ── Ordinal measures (Rank) ────────────────────────────────── + // An ordinal is a standing, not a magnitude: "how much better is 1st + // than 2nd" has no answer. Length-encoding it (bar length + sequential + // colour ramp) would invert the ranking — rank 1 gets the shortest, + // palest bar. Honor the documented `Rank` behaviour instead (see + // flint://agent-skill: "Rank → reversed axis (1 on top), discrete + // color"): sort by rank ascending (1 first), use a discrete colour + // scale, and keep bars equal-length so the mark does not imply a + // magnitude that isn't there. + const xIsOrdinal = xCS?.type === 'ordinal'; + // Sign profile of x values — used by the diverging-palette check. let hasNegative = false; let hasPositive = false; @@ -192,7 +203,9 @@ export const barTableDef: ChartTemplateDef = { && maxScopedCategoryCount > maxRows; const sortRowsByValue = (items: Array<{ cat: any; value: number }>) => items - .sort((a, b) => yCS?.reversed ? a.value - b.value : b.value - a.value); + .sort((a, b) => xIsOrdinal + ? a.value - b.value + : (yCS?.reversed ? a.value - b.value : b.value - a.value)); let displayTable: any[] = []; let othersCatLabel: string | undefined; @@ -418,7 +431,9 @@ export const barTableDef: ChartTemplateDef = { } return uniqueCats .map(cat => ({ cat, value: aggValue(globalCategoryAgg.get(cat)!) })) - .sort((a, b) => yCS?.reversed ? a.value - b.value : b.value - a.value) + .sort((a, b) => xIsOrdinal + ? a.value - b.value + : (yCS?.reversed ? a.value - b.value : b.value - a.value)) .map(a => a.cat); })(); const ySort: any = ySortOrder && ySortOrder.length > 0 @@ -483,12 +498,19 @@ export const barTableDef: ChartTemplateDef = { legend: null, scale: { scheme: 'redyellowgreen', domainMid: 0 }, } - : { - field: xField, - type: 'quantitative', - legend: null, - scale: { range: ['#cdebd3', '#41a25f'] }, - }; + : xIsOrdinal + ? { + field: xField, + type: 'ordinal', + legend: null, + scale: { scheme: 'tableau10' }, + } + : { + field: xField, + type: 'quantitative', + legend: null, + scale: { range: ['#cdebd3', '#41a25f'] }, + }; // ── Dynamic panel widths from longest formatted label ──────── // @@ -708,12 +730,14 @@ export const barTableDef: ChartTemplateDef = { }, encoding: { y: yEncWithLabels, - x: { - field: barXField, - type: 'quantitative', - axis: null, - scale: barXScale, - }, + x: xIsOrdinal + ? { datum: 1, type: 'quantitative', axis: null, scale: { domain: [0, 1], nice: false } } + : { + field: barXField, + type: 'quantitative', + axis: null, + scale: barXScale, + }, color: barColorEnc, }, }); diff --git a/packages/flint-js/tests/bar-table-rank.test.ts b/packages/flint-js/tests/bar-table-rank.test.ts new file mode 100644 index 00000000..aacfaebe --- /dev/null +++ b/packages/flint-js/tests/bar-table-rank.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite, assemblePlotly } from '../src'; + +/** + * Regression test for issue #85: the `Rank` semantic type is an ordinal, not a + * magnitude. The Bar Table template used to length-encode it (bar length + + * sequential colour ramp), inverting the ranking so rank 1 got the shortest, + * palest bar. The documented behaviour is "Rank → reversed axis (1 on top), + * discrete color". + * + * The fix honours that in both the Vega-Lite and Plotly Bar Table templates: + * - rows ordered by rank ascending (1 first / on top), + * - discrete colour (no magnitude ramp), + * - equal-length bars (no length encoding of an ordinal). + */ + +const RANK_INPUT = { + data: { + values: [ + { Engine: 'Inworld TTS-2', Rank: 1 }, + { Engine: 'xAI leo', Rank: 2 }, + { Engine: 'Kokoro am_michael', Rank: 3 }, + { Engine: 'Gemini', Rank: 4 }, + { Engine: 'Inworld 1.5-max', Rank: 5 }, + ], + }, + semantic_types: { Engine: 'Name', Rank: 'Rank' }, + chart_spec: { + chartType: 'Bar Table', + encodings: { y: { field: 'Engine' }, x: { field: 'Rank' } }, + baseSize: { width: 560, height: 280 }, + }, +}; + +const RANK_ORDER_ASC = ['Inworld TTS-2', 'xAI leo', 'Kokoro am_michael', 'Gemini', 'Inworld 1.5-max']; + +describe('Bar Table honours Rank semantic (issue #85)', () => { + it('Vega-Lite: sorts rank ascending, discrete colour, equal-length bars', () => { + const spec = assembleVegaLite(RANK_INPUT as never) as any; + const barPanel = spec.hconcat[0]; + + // Rank ascending: rank 1 first (top). + expect(barPanel.encoding.y.sort).toEqual(RANK_ORDER_ASC); + + // Discrete colour scale (ordinal), not a sequential magnitude ramp. + expect(barPanel.encoding.color.type).toBe('ordinal'); + expect(barPanel.encoding.color.scale.scheme).toBeTruthy(); + + // No length encoding: bars are a constant value, not the rank field. + expect(barPanel.encoding.x.field).toBeUndefined(); + expect(barPanel.encoding.x.datum).toBe(1); + }); + + it('Plotly: sorts rank ascending, discrete colour, equal-length bars', () => { + const fig = assemblePlotly(RANK_INPUT as never) as any; + const trace = (fig.data ?? []).find((t: any) => t.type === 'bar' && t.orientation === 'h'); + + // Rank ascending: rank 1 first (top). + expect(trace.y).toEqual(RANK_ORDER_ASC); + + // Equal-length bars (no magnitude encoding) and discrete colour. + expect(trace.x.every((v: number) => v === 1)).toBe(true); + expect(new Set(trace.marker.color).size).toBeGreaterThan(1); + }); +});