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
132 changes: 118 additions & 14 deletions src/lib/agents-chart/chartjs/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function assembleChartjs(input: ChartAssemblyInput): any {
const budgets = computeChannelBudgets(
channelSemantics, declaration, convertedData, canvasSize, effectiveOptions,
);
const facetGridResult = budgets.facetGrid;

const overflowResult = filterOverflow(
channelSemantics, declaration, encodings, convertedData,
Expand All @@ -150,6 +151,7 @@ export function assembleChartjs(input: ChartAssemblyInput): any {
values,
canvasSize,
effectiveOptions,
facetGridResult,
);

layoutResult.truncations = overflowResult.truncations;
Expand Down Expand Up @@ -192,22 +194,98 @@ export function assembleChartjs(input: ChartAssemblyInput): any {
}),
};

// Standard single-panel rendering (no faceting for initial CJS backend)
const cjsConfig: any = structuredClone(chartTemplate.template);

chartTemplate.instantiate(cjsConfig, instantiateContext);

// Apply layout decisions (CJS-specific)
cjsApplyLayoutToSpec(cjsConfig, instantiateContext, warnings);
const colField = channelSemantics.column?.field;
const rowField = channelSemantics.row?.field;
const hasFacet = !!(colField || rowField);
const hasAxes = chartTemplate.channels.includes('x') || chartTemplate.channels.includes('y');

let cjsConfig: any;
if (hasFacet && hasAxes) {
const colValues = colField ? [...new Set(values.map((r: any) => String(r[colField])))] : [''];
const rowValues = rowField ? [...new Set(values.map((r: any) => String(r[rowField])))] : [''];
const facetLegend: Array<{ label: string; color: string }> = [];

const yField = channelSemantics.y?.field;
let sharedYDomain: { min: number; max: number } | undefined;
if (yField) {
const nums = values
.map((r: any) => r[yField])
.filter((v: any) => typeof v === 'number' && Number.isFinite(v)) as number[];
if (nums.length > 0) {
const rawMin = Math.min(...nums);
const rawMax = Math.max(...nums);
const forceZero = !!channelSemantics.y?.zero?.zero;
const min = forceZero ? Math.min(0, rawMin) : rawMin;
const max = forceZero ? Math.max(0, rawMax) : rawMax;
sharedYDomain = { min, max };
}
}

// Tooltips
if (addTooltipsOpt) {
cjsApplyTooltips(cjsConfig);
}
const panelRows: any[][] = [];
for (let ri = 0; ri < rowValues.length; ri++) {
const rowVal = rowValues[ri];
const rowPanels: any[] = [];
for (let ci = 0; ci < colValues.length; ci++) {
const colVal = colValues[ci];
const panelData = values.filter((r: any) => {
if (colField && String(r[colField]) !== colVal) return false;
if (rowField && String(r[rowField]) !== rowVal) return false;
return true;
});

const panelConfig: any = structuredClone(chartTemplate.template);
const panelContext: InstantiateContext = {
...instantiateContext,
table: panelData,
layout: layoutResult,
};
chartTemplate.instantiate(panelConfig, panelContext);
// Keep all facet panels the same plot size: disable per-panel built-in legend.
// A shared legend is rendered by the gallery host.
if (!panelConfig.options) panelConfig.options = {};
if (!panelConfig.options.plugins) panelConfig.options.plugins = {};
panelConfig.options.plugins.legend = {
...(panelConfig.options.plugins.legend || {}),
display: false,
position: 'right',
};
cjsApplyLayoutToSpec(panelConfig, panelContext, []);
if (addTooltipsOpt) cjsApplyTooltips(panelConfig);
if (chartTemplate.postProcess) chartTemplate.postProcess(panelConfig, panelContext);
if (facetLegend.length === 0 && Array.isArray(panelConfig.data?.datasets)) {
for (const ds of panelConfig.data.datasets) {
const label = String(ds?.label ?? '').trim();
if (!label) continue;
const color = String(ds?.borderColor ?? ds?.backgroundColor ?? '#666');
facetLegend.push({ label, color });
}
}

rowPanels.push({
key: `${ri}:${ci}`,
rowIndex: ri,
colIndex: ci,
rowHeader: rowField ? rowVal : undefined,
colHeader: colField ? colVal : undefined,
config: panelConfig,
});
}
panelRows.push(rowPanels);
}

// Template-specific post-processing
if (chartTemplate.postProcess) {
chartTemplate.postProcess(cjsConfig, instantiateContext);
cjsConfig = cjsCombineFacetPanels(
panelRows,
!!colField,
!!rowField,
sharedYDomain,
);
cjsConfig._facetLegend = facetLegend;
} else {
cjsConfig = structuredClone(chartTemplate.template);
chartTemplate.instantiate(cjsConfig, instantiateContext);
cjsApplyLayoutToSpec(cjsConfig, instantiateContext, warnings);
if (addTooltipsOpt) cjsApplyTooltips(cjsConfig);
if (chartTemplate.postProcess) chartTemplate.postProcess(cjsConfig, instantiateContext);
}

// ═══════════════════════════════════════════════════════════════════════
Expand All @@ -222,3 +300,29 @@ export function assembleChartjs(input: ChartAssemblyInput): any {

return cjsConfig;
}

function cjsCombineFacetPanels(
panelRows: any[][],
hasColHeader: boolean,
hasRowHeader: boolean,
sharedYDomain?: { min: number; max: number },
): any {
const rows = panelRows.length;
const cols = Math.max(1, ...panelRows.map(r => r.length));
const ref = panelRows[0]?.[0]?.config;
const panelW = ref?._width || 400;
const panelH = ref?._height || 300;
const gap = 16;
const colHeaderH = hasColHeader ? 22 : 0;
const rowHeaderW = hasRowHeader ? 28 : 0;

return {
_facet: true,
_facetPanels: panelRows,
_facetRows: rows,
_facetCols: cols,
_facetSharedYDomain: sharedYDomain,
_width: rowHeaderW + cols * panelW + (cols - 1) * gap,
_height: colHeaderH + rows * panelH + (rows - 1) * gap,
};
}
84 changes: 78 additions & 6 deletions src/lib/agents-chart/chartjs/instantiate-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,33 @@ export function cjsApplyLayoutToSpec(
// ── Canvas dimensions ────────────────────────────────────────────────
// Chart.js uses the canvas element dimensions.
// For non-axis charts (pie, radar), templates set their own _width/_height.
// For axis charts, the core layout engine's subplotWidth/Height already
// accounts for discrete band sizing (step × count, floored at canvasSize),
// so we just add padding for axes/labels/legend.
if (hasAxes && !config._width) {
const PADDING = 80;
config._width = (layout.subplotWidth || canvasSize.width) + PADDING;
config._height = (layout.subplotHeight || canvasSize.height) + PADDING;
// Axes + optional right legend column (see legend block below for extra gutter).
const PADDING = 80; // approximate space for axes, labels

const xIsDiscrete = layout.xNominalCount > 0 || layout.xContinuousAsDiscrete > 0;
const yIsDiscrete = layout.yNominalCount > 0 || layout.yContinuousAsDiscrete > 0;

let plotWidth: number;
let plotHeight: number;

if (xIsDiscrete && layout.xStepUnit !== 'group') {
const xItemCount = layout.xNominalCount || layout.xContinuousAsDiscrete || 0;
plotWidth = xItemCount > 0 ? layout.xStep * xItemCount : (layout.subplotWidth || canvasSize.width);
} else {
plotWidth = layout.subplotWidth || canvasSize.width;
}

if (yIsDiscrete && layout.yStepUnit !== 'group') {
const yItemCount = layout.yNominalCount || layout.yContinuousAsDiscrete || 0;
plotHeight = yItemCount > 0 ? layout.yStep * yItemCount : (layout.subplotHeight || canvasSize.height);
} else {
plotHeight = layout.subplotHeight || canvasSize.height;
}

const legendGutter = cjsLegendLikelyVisible(config) ? 96 : 0;
config._width = plotWidth + PADDING + legendGutter;
config._height = plotHeight + PADDING;
}

// ── Bar sizing ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -125,6 +145,58 @@ export function cjsApplyLayoutToSpec(
}
}
}

// ── Legend: right side (Chart.js stacks items vertically for position 'right') ──
cjsApplyLegendRightColumn(config);
}

/** Whether the Chart.js legend is expected to show (respects display:false; default is show when multiple datasets). */
function cjsLegendLikelyVisible(config: any): boolean {
const legend = config.options?.plugins?.legend;
if (legend?.display === false) return false;
if (legend?.display === true) return true;
const n = config.data?.datasets?.length ?? 0;
return n > 1;
}

/** How many legend rows we expect (pie/doughnut: slice labels; else: one per dataset). */
function cjsLegendEntryCount(config: any): number {
const t = config.type;
if (t === 'pie' || t === 'doughnut') {
return config.data?.labels?.length ?? 0;
}
return config.data?.datasets?.length ?? 0;
}

/**
* Place legend on the right in a vertical column; match EC/VL legend text size so labels fit the canvas.
* (Chart.js defaults ~12px; VL labelFontSize 8, EC textStyle 11 or 8 when high-cardinality.)
*/
function cjsApplyLegendRightColumn(config: any): void {
if (!cjsLegendLikelyVisible(config)) return;
if (!config.options) config.options = {};
if (!config.options.plugins) config.options.plugins = {};
const prev = config.options.plugins.legend ?? {};
const prevLabels = prev.labels ?? {};
const prevFont = prevLabels.font ?? {};
const entryCount = cjsLegendEntryCount(config);
const highCardinality = entryCount >= 16;
const fontSize = prevFont.size ?? (highCardinality ? 8 : 10);
const boxW = prevLabels.boxWidth ?? (highCardinality ? 8 : 10);
const boxH = prevLabels.boxHeight ?? (highCardinality ? 8 : 10);
config.options.plugins.legend = {
...prev,
position: 'right' as const,
labels: {
...prevLabels,
font: {
...prevFont,
size: fontSize,
},
boxWidth: boxW,
boxHeight: boxH,
},
};
}

/**
Expand Down
31 changes: 29 additions & 2 deletions src/lib/agents-chart/chartjs/templates/area.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getChartJsPalette,
getSeriesBorderColor,
getSeriesBackgroundColor,
coerceUnixMsForChartJs,
} from './utils';

const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal';
Expand All @@ -40,6 +41,10 @@ export const cjsAreaChartDef: ChartTemplateDef = {
const yField = yCS.field;

const xIsDiscrete = isDiscrete(xCS.type);
const xIsTemporal = xCS.type === 'temporal';
const mapContinuousX = (raw: unknown) =>
(xIsTemporal ? coerceUnixMsForChartJs(raw) : raw);

const categories = xIsDiscrete
? extractCategories(table, xField, xCS.ordinalSortOrder)
: undefined;
Expand Down Expand Up @@ -71,11 +76,29 @@ export const cjsAreaChartDef: ChartTemplateDef = {
x: {
type: xIsDiscrete ? 'category' : 'linear',
title: { display: true, text: xField },
ticks: {
font: { size: 10 },
...(xIsTemporal
? {
maxTicksLimit: 8,
callback(v: number | string) {
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return String(v);
return new Date(n).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
},
}
: {}),
},
},
y: {
type: 'linear',
title: { display: true, text: yField },
stacked,
ticks: { font: { size: 10 } },
},
},
plugins: {
Expand All @@ -99,7 +122,9 @@ export const cjsAreaChartDef: ChartTemplateDef = {
for (const [name, rows] of groups) {
const data = xIsDiscrete
? buildCategoryAlignedData(rows, xField, yField, categories!)
: rows.map(r => ({ x: r[xField], y: r[yField] }));
: rows
.map(r => ({ x: mapContinuousX(r[xField]), y: r[yField] }))
.filter(p => p.y != null && (xIsTemporal ? Number.isFinite(p.x as number) : true));

const borderColor = getSeriesBorderColor(palette, colorIdx);
const bgColor = getSeriesBackgroundColor(palette, colorIdx, opacity);
Expand All @@ -121,7 +146,9 @@ export const cjsAreaChartDef: ChartTemplateDef = {
const row = table.find(r => String(r[xField]) === cat);
return row ? row[yField] : null;
})
: table.map(r => ({ x: r[xField], y: r[yField] }));
: table
.map(r => ({ x: mapContinuousX(r[xField]), y: r[yField] }))
.filter(p => p.y != null && (xIsTemporal ? Number.isFinite(p.x as number) : true));

config.data.datasets.push({
label: yField,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/agents-chart/chartjs/templates/bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ export const cjsStackedBarChartDef: ChartTemplateDef = {
export const cjsGroupedBarChartDef: ChartTemplateDef = {
chart: 'Grouped Bar Chart',
template: { mark: 'bar', encoding: {} },
channels: ['x', 'y', 'group', 'column', 'row'],
channels: ['x', 'y', 'group', 'color', 'column', 'row'],
markCognitiveChannel: 'length',
declareLayoutMode: (cs, table) => {
const result = detectBandedAxisForceDiscrete(cs, table, { preferAxis: 'x' });
Expand Down
Loading