Skip to content
Open
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
146 changes: 146 additions & 0 deletions packages/flint-js/src/chartjs/templates/combo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Chart.js Combo (Bar + Line) template.
*
* A dual-measure chart: bars for a primary measure and a line for a secondary
* measure plotted on its own right-hand axis. This is one of the most common
* dashboard idioms (e.g. revenue bars + growth-rate line).
*
* Data model:
* x (nominal) → shared category axis
* y (quantitative) → the BAR measure (left axis)
* line measure → the LINE measure (right axis); chosen from
* `chartProperties.lineField`, else the first other
* numeric field in the data.
* color is intentionally not used — the two series ARE the legend.
*
* Chart.js renders this as a base `type: 'bar'` chart with one dataset
* overridden to `type: 'line'` and bound to a secondary `y1` scale.
*/

import { ChartTemplateDef } from '../../core/types';
import {
extractCategories,
buildCategoryAlignedData,
getChartJsPalette,
getSeriesBorderColor,
getSeriesBackgroundColor,
} from './utils';
import { detectBandedAxisFromSemantics } from '../../vegalite/templates/utils';

/** A field is numeric if (nearly) all of its non-null values parse as numbers. */
function isNumericField(table: any[], field: string): boolean {
let total = 0;
let numeric = 0;
for (const row of table) {
const v = row[field];
if (v == null || v === '') continue;
total++;
if (typeof v === 'number' ? isFinite(v) : !isNaN(Number(v))) numeric++;
}
return total > 0 && numeric / total >= 0.9;
}

export const cjsComboChartDef: ChartTemplateDef = {
chart: 'Combo Chart',
template: { mark: 'bar', encoding: {} },
channels: ['x', 'y', 'column', 'row'],
markCognitiveChannel: 'length',
declareLayoutMode: (cs, table) => {
const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' });
return {
axisFlags: result ? { [result.axis]: { banded: true } } : { x: { banded: true } },
resolvedTypes: result?.resolvedTypes,
};
},
instantiate: (spec, ctx) => {
const { channelSemantics, table, chartProperties } = ctx;
const catField = channelSemantics.x?.field;
const barField = channelSemantics.y?.field;
if (!catField || !barField || table.length === 0) return;

// Resolve the line measure: explicit override, else first other numeric field.
const lineField: string | undefined =
(chartProperties?.lineField && chartProperties.lineField in table[0])
? chartProperties.lineField
: Object.keys(table[0]).find(
(k) => k !== catField && k !== barField && isNumericField(table, k),
);

const categories = extractCategories(table, catField, channelSemantics.x?.ordinalSortOrder);
const barData = buildCategoryAlignedData(table, catField, barField, categories);

const palette = getChartJsPalette(ctx, 'color');

const datasets: any[] = [{
type: 'bar' as const,
label: barField,
data: barData,
yAxisID: 'y',
order: 2,
backgroundColor: getSeriesBackgroundColor(palette, 0),
borderColor: getSeriesBorderColor(palette, 0),
borderWidth: 1,
borderRadius: chartProperties?.cornerRadius ?? 0,
}];

if (lineField) {
const lineData = buildCategoryAlignedData(table, catField, lineField, categories);
datasets.push({
type: 'line' as const,
label: lineField,
data: lineData,
yAxisID: 'y1',
order: 1,
borderColor: getSeriesBorderColor(palette, 1),
backgroundColor: 'transparent',
borderWidth: 2,
pointRadius: 3,
tension: 0.3,
fill: false,
});
}

const config: any = {
type: 'bar',
data: { labels: categories, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
title: { display: true, text: catField },
},
y: {
type: 'linear' as const,
position: 'left' as const,
beginAtZero: channelSemantics.y?.zero ? channelSemantics.y.zero.zero !== false : true,
title: { display: true, text: barField },
},
...(lineField ? {
y1: {
type: 'linear' as const,
position: 'right' as const,
title: { display: true, text: lineField },
// Don't draw the right axis grid over the bars.
grid: { drawOnChartArea: false },
},
} : {}),
},
plugins: {
legend: { display: true },
tooltip: { enabled: true },
},
},
};

Object.assign(spec, config);
delete spec.mark;
delete spec.encoding;
},
properties: [
{ key: 'cornerRadius', label: 'Corners', type: 'continuous', min: 0, max: 15, step: 1, defaultValue: 0 },
],
};
102 changes: 102 additions & 0 deletions packages/flint-js/src/chartjs/templates/doughnut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Chart.js Doughnut Chart template.
*
* Contrast with Pie:
* Pie: type = 'pie' (full disc, area encodes value)
* Doughnut: type = 'doughnut' with a `cutout` — the hollow centre trades a
* little area-judgement accuracy for a cleaner look and room for a
* centre label. Same data model as Pie (color = slice, size = value).
*/

import { ChartTemplateDef, ChartPropertyDef } from '../../core/types';
import {
extractCategories,
getChartJsPalette,
getSeriesBorderColor,
getSeriesBackgroundColor,
} from './utils';

export const cjsDoughnutChartDef: ChartTemplateDef = {
chart: 'Doughnut Chart',
template: { mark: 'arc', encoding: {} },
channels: ['size', 'color', 'column', 'row'],
markCognitiveChannel: 'area',
instantiate: (spec, ctx) => {
const { channelSemantics, table, chartProperties } = ctx;
const colorField = channelSemantics.color?.field;
const sizeField = channelSemantics.size?.field;

const labels: string[] = [];
const values: number[] = [];

const palette = getChartJsPalette(ctx, 'color');

if (colorField && sizeField) {
const agg = new Map<string, number>();
for (const row of table) {
const cat = String(row[colorField] ?? '');
const val = Number(row[sizeField]) || 0;
agg.set(cat, (agg.get(cat) ?? 0) + val);
}
const categories = extractCategories(table, colorField, channelSemantics.color?.ordinalSortOrder);
for (const cat of categories) {
labels.push(cat);
values.push(agg.get(cat) ?? 0);
}
} else if (colorField) {
const counts = new Map<string, number>();
for (const row of table) {
const cat = String(row[colorField] ?? '');
counts.set(cat, (counts.get(cat) ?? 0) + 1);
}
const categories = extractCategories(table, colorField, channelSemantics.color?.ordinalSortOrder);
for (const cat of categories) {
labels.push(cat);
values.push(counts.get(cat) ?? 0);
}
} else if (sizeField) {
for (const row of table) {
const val = Number(row[sizeField]) || 0;
labels.push(String(val));
values.push(val);
}
}

// Default to a 55% hollow centre; let callers tune it.
const cutout = chartProperties?.innerRadius ?? 55;

const config: any = {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: labels.map((_, i) => getSeriesBackgroundColor(palette, i, 0.6)),
borderColor: labels.map((_, i) => getSeriesBorderColor(palette, i)),
borderWidth: 1,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: `${cutout}%`,
plugins: {
legend: { display: true, position: 'right' as const },
tooltip: { enabled: true },
},
},
_width: Math.max(ctx.canvasSize.width, 300),
_height: Math.max(ctx.canvasSize.height, 250),
};

Object.assign(spec, config);
delete spec.mark;
delete spec.encoding;
},
properties: [
{ key: 'innerRadius', label: 'Hole', type: 'continuous', min: 20, max: 80, step: 5, defaultValue: 55 } as ChartPropertyDef,
],
};
6 changes: 4 additions & 2 deletions packages/flint-js/src/chartjs/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { ChartTemplateDef } from '../../core/types';
import { cjsScatterPlotDef } from './scatter';
import { cjsBubbleChartDef } from './bubble';
import { cjsBarChartDef, cjsStackedBarChartDef, cjsGroupedBarChartDef } from './bar';
import { cjsComboChartDef } from './combo';
import { cjsLineChartDef } from './line';
import { cjsAreaChartDef } from './area';
import { cjsPieChartDef } from './pie';
import { cjsDoughnutChartDef } from './doughnut';
import { cjsHistogramDef } from './histogram';
import { cjsRadarChartDef } from './radar';
import { cjsRoseChartDef } from './rose';
Expand All @@ -24,9 +26,9 @@ import { cjsRoseChartDef } from './rose';
*/
export const cjsTemplateDefs: { [key: string]: ChartTemplateDef[] } = {
'Scatter & Point': [cjsScatterPlotDef, cjsBubbleChartDef],
'Bar': [cjsBarChartDef, cjsGroupedBarChartDef, cjsStackedBarChartDef, cjsHistogramDef],
'Bar': [cjsBarChartDef, cjsGroupedBarChartDef, cjsStackedBarChartDef, cjsComboChartDef, cjsHistogramDef],
'Line & Area': [cjsLineChartDef, cjsAreaChartDef],
'Part-to-Whole': [cjsPieChartDef],
'Part-to-Whole': [cjsPieChartDef, cjsDoughnutChartDef],
'Polar': [cjsRadarChartDef, cjsRoseChartDef],
};

Expand Down
Loading