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
18 changes: 13 additions & 5 deletions packages/flint-js/src/plotly/templates/bar-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { sum: number; n: number; byColor: Map<string, number> }>();
for (const r of rows) {
Expand All @@ -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 }));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() ?? [])])))]
Expand Down Expand Up @@ -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
Expand Down
52 changes: 38 additions & 14 deletions packages/flint-js/src/vegalite/templates/bar-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ────────
//
Expand Down Expand Up @@ -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,
},
});
Expand Down
68 changes: 68 additions & 0 deletions packages/flint-js/tests/bar-table-rank.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});