Every Widget available in the Widget selector is grouped here exactly as the selector groups them, with a live example and setup options for each group.
See Widget Basics for how Widgets are created and resized, and Building Widgets for configuring a Widget's data and appearance once it is on the layout.
Column Chart Copy Link
Column Chart Widgets use vertical bars to compare values across categories.
Grouped Column Chart shows adjacent vertical bars for comparing categories or series side by side. It should be used when comparing individual values matters more than showing how they add up.
Stacked Column Chart stacks vertical bars to show part-to-whole relationships across categories. It should be used when both a category's total and its composition matter.
100% Stacked Column Chart normalises stacked bars to 100% so segments show proportions rather than absolute values. It should be used when comparing composition across categories matters more than comparing totals.
The example below shows all three Column Chart variants plotted against the same categories, making the shift from absolute values to proportions easy to compare.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"column-chart-grouped": {
type: "column-chart-grouped",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Grouped Column Chart",
},
},
},
"column-chart-stacked": {
type: "column-chart-stacked",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Stacked Column Chart",
},
},
},
"column-chart-stacked-100": {
type: "column-chart-stacked-100",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "100% Stacked Column Chart",
},
},
},
},
widgetLayout: {
"column-chart-grouped": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 16,
},
"column-chart-stacked": {
xTrack: 0,
yTrack: 16,
xSpan: 24,
ySpan: 16,
},
"column-chart-stacked-100": {
xTrack: 0,
yTrack: 32,
xSpan: 24,
ySpan: 16,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Bar Chart Copy Link
Bar Chart Widgets use horizontal bars and behave like Column Charts turned on their side, which suits long category labels.
Grouped Bar Chart shows adjacent horizontal bars for comparing categories, and works well when category names are long.
Stacked Bar Chart stacks horizontal bars to show part-to-whole relationships across categories.
100% Stacked Bar Chart normalises stacked horizontal bars to 100% for comparing proportions across categories.
The example below shows the three Bar Chart variants stacked one above the other, plotting the same categories as the Column Chart example.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"bar-chart-grouped": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Grouped Bar Chart",
},
},
},
"bar-chart-stacked": {
type: "bar-chart-stacked",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Stacked Bar Chart",
},
},
},
"bar-chart-stacked-100": {
type: "bar-chart-stacked-100",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "100% Stacked Bar Chart",
},
},
},
},
widgetLayout: {
"bar-chart-grouped": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 16,
},
"bar-chart-stacked": {
xTrack: 0,
yTrack: 16,
xSpan: 24,
ySpan: 16,
},
"bar-chart-stacked-100": {
xTrack: 0,
yTrack: 32,
xSpan: 24,
ySpan: 16,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Line Chart Copy Link
The Line Chart group holds a single Widget.
Line Chart connects data points with a line to show a trend over time or across a continuous dimension. It should be used when the shape of change matters more than any single value.
The example below shows a Line Chart comparing gold, silver and bronze totals across countries.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"line-chart": {
type: "line-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Line Chart",
},
},
},
},
widgetLayout: {
"line-chart": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 16,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Area Chart Copy Link
Area Chart Widgets fill the area beneath a line, emphasising magnitude alongside trend.
Area Chart fills the area beneath a line to emphasise the magnitude of a trend over time.
Stacked Area Chart stacks filled areas to show cumulative trends and part-to-whole relationships over time.
100% Stacked Area Chart normalises stacked areas to 100% to show proportional trends over time.
The example below shows all three Area Chart variants plotted against the same categories.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"area-chart": {
type: "area-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Area Chart",
},
},
},
"area-chart-stacked": {
type: "area-chart-stacked",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Stacked Area Chart",
},
},
},
"area-chart-stacked-100": {
type: "area-chart-stacked-100",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "100% Stacked Area Chart",
},
},
},
},
widgetLayout: {
"area-chart": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 16,
},
"area-chart-stacked": {
xTrack: 0,
yTrack: 16,
xSpan: 24,
ySpan: 16,
},
"area-chart-stacked-100": {
xTrack: 0,
yTrack: 32,
xSpan: 24,
ySpan: 16,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Value Copy Link
The Value group covers Widgets that spotlight a single aggregated number.
KPI displays a single aggregated number prominently. It should be used for totals and headline metrics that need no visual comparison.
Radial Gauge shows a value against a defined range on a circular dial. It should be used for progress or target tracking.
Linear Gauge shows a value against a range on a horizontal or vertical bar. It should be used for progress or threshold indicators.
The example below shows a KPI alongside both gauge types tracking the same underlying value.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
value: {
type: "value",
dataMapping: {
value: [{ id: "medals.gold", aggregation: "sum" }],
},
format: {
title: {
enabled: true,
text: "KPI",
},
},
},
"radial-gauge": {
type: "radial-gauge",
dataMapping: {
value: [{ id: "medals.gold", aggregation: "sum" }],
},
format: {
title: {
enabled: true,
text: "Radial Gauge",
},
},
},
"linear-gauge": {
type: "linear-gauge",
dataMapping: {
value: [{ id: "medals.gold", aggregation: "sum" }],
},
format: {
title: {
enabled: true,
text: "Linear Gauge",
},
},
},
},
widgetLayout: {
value: {
xTrack: 0,
yTrack: 0,
xSpan: 8,
ySpan: 12,
},
"radial-gauge": {
xTrack: 8,
yTrack: 0,
xSpan: 8,
ySpan: 12,
},
"linear-gauge": {
xTrack: 16,
yTrack: 0,
xSpan: 8,
ySpan: 12,
},
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Scatter Chart Copy Link
Scatter Chart Widgets plot individual data points across two axes to reveal correlations.
Scatter Chart plots dots on two axes to compare grouped data and show correlations.
Bubble Chart extends a scatter plot with variable dot sizes, adding a third data dimension.
The example below shows a Scatter Chart above a Bubble Chart using the same two axes, with the Bubble Chart adding size as a third dimension.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"scatter-chart": {
type: "scatter-chart",
dataMapping: {
groupByKey: [{ id: "medals.country" }],
categoryKey: [{ id: "medals.gold", aggregation: "sum" }],
valueKey: [{ id: "medals.silver", aggregation: "sum" }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Scatter Chart",
},
},
},
"bubble-chart": {
type: "bubble-chart",
dataMapping: {
groupByKey: [{ id: "medals.country" }],
sizeKey: [{ id: "medals.total", aggregation: "sum" }],
categoryKey: [{ id: "medals.gold", aggregation: "sum" }],
valueKey: [{ id: "medals.silver", aggregation: "sum" }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Bubble Chart",
},
},
},
},
widgetLayout: {
"scatter-chart": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 16,
},
"bubble-chart": {
xTrack: 0,
yTrack: 16,
xSpan: 24,
ySpan: 16,
},
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Table Copy Link
This group holds the two Widgets for displaying data in rows and columns.
Table lists rows and columns with sortable and filterable headers. It should be used for row-level exploration of detailed data.
Pivot Table groups and pivots data across row and column axes. It should be used for hierarchical drill-down and cross-tabulated summaries.
The example below shows a flat list of rows above a pivoted view of the same data.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
grid: {
type: "grid",
dataMapping: {
cols: [
{ id: "medals.country" },
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
},
format: {
title: {
enabled: true,
text: "Table",
},
},
},
"pivot-grid": {
type: "pivot-grid",
dataMapping: {
rows: [{ id: "medals.country" }],
columns: [{ id: "medals.year" }],
values: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
},
format: {
title: {
enabled: true,
text: "Pivot Table",
},
},
},
},
widgetLayout: {
grid: {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 16,
},
"pivot-grid": {
xTrack: 0,
yTrack: 16,
xSpan: 24,
ySpan: 16,
},
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Pivot Table Setup Copy Link
A Pivot Table summarises a measure across a row axis and, optionally, a column axis. Its Setup panel has three data slots:
- Rows - grouping fields, outer to inner. Each field adds a level of row grouping.
- Columns - optional grouping fields, outer to inner. Adding a field here pivots the Values onto a column axis instead of listing them as flat columns.
- Values - one or more measures, aggregated for each row (and, once pivoted, for each row-column cell).
Row groups start expanded when no field is mapped to Columns, and collapsed once one is. Click a row to expand or collapse it; expanding loads its child rows. Column groups always start collapsed - click to expand them.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
import { getMainDemoData } from "./data.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "a",
widgets: {
"sales-pivot": {
type: "pivot-grid",
dataMapping: {
rows: [{ id: "stores.region" }, { id: "products.subcategory" }],
columns: [{ id: "orders.channel" }],
values: [{ id: "net_sales" }, { id: "gross_sales" }],
},
format: {
title: {
enabled: true,
text: "Net Sales and Gross Sales by Region, Category, and Channel",
},
},
sort: [{ field: { id: "products.subcategory" }, direction: "asc" }],
},
},
widgetLayout: {
"sales-pivot": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 41 },
},
selection: { type: "widget", id: "sales-pivot" },
},
],
selectedPageId: "a",
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
panels: {
edit: {
right: ["edit"],
},
},
data: getMainDemoData("https://www.ag-grid.com/studio/archive/3.0.0/example-assets"),
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
import type { AgDataSourcesDefinition, AgExpressionFieldDefinition, AgFieldDefinition } from 'ag-studio';
// =============================================================================
// Field Definitions
// =============================================================================
const storesFields: AgFieldDefinition[] = [
{
id: 'store_id',
name: 'Store ID',
format: 'textFormat',
},
{ id: 'store_name', name: 'Store', format: 'textFormat', notBlank: true },
{ id: 'region', name: 'Region', format: 'textFormat', notBlank: true },
{ id: 'city', name: 'City', format: 'textFormat', notBlank: true },
{
id: 'opened_date',
name: 'Opened Date',
format: 'dateFormat',
},
{ id: 'store_type', name: 'Store Type', format: 'textFormat', notBlank: true },
];
const productsFields: AgFieldDefinition[] = [
{
id: 'product_id',
name: 'Product ID',
format: 'textFormat',
hide: false,
},
{
id: 'product_name',
name: 'Product',
format: 'textFormat',
},
{ id: 'category', name: 'Category', format: 'textFormat', notBlank: true },
{
id: 'subcategory',
name: 'Subcategory',
format: 'textFormat',
},
{ id: 'brand', name: 'Brand', format: 'textFormat', notBlank: true },
{ id: 'launch_date', name: 'Launch Date', format: 'dateFormat', notBlank: true },
{
id: 'list_price',
name: 'List Price',
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
},
{
id: 'unit_cost',
name: 'Unit Cost',
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
},
{
id: 'is_discontinued',
name: 'Discontinued',
format: 'booleanFormat',
},
];
const customersFields: AgFieldDefinition[] = [
{
id: 'customer_id',
name: 'Customer ID',
format: 'textFormat',
hide: false,
},
{
id: 'customer_name',
name: 'Customer',
format: 'textFormat',
},
{ id: 'signup_date', name: 'Signup Date', format: 'dateFormat', notBlank: true },
{ id: 'region', name: 'Region', format: 'textFormat', notBlank: true },
{ id: 'segment', name: 'Segment', format: 'textFormat', notBlank: true },
{ id: 'is_active', name: 'Active', format: 'booleanFormat', notBlank: true },
{
id: 'marketing_opt_in',
name: 'Marketing Opt-in',
format: 'booleanFormat',
},
{
id: 'lifetime_orders',
name: 'Lifetime Orders',
format: 'integerFormat',
formatOptions: { format: '#,##0' },
},
];
const ordersFields: AgFieldDefinition[] = [
{
id: 'order_id',
name: 'Order ID',
format: 'textFormat',
hide: false,
},
{
id: 'customer_id',
name: 'Customer ID',
format: 'textFormat',
},
{
id: 'store_id',
name: 'Store ID',
format: 'textFormat',
},
{
id: 'order_datetime',
name: 'Order Date/Time',
format: 'dateTimeFormat',
},
{ id: 'channel', name: 'Channel', format: 'textFormat', notBlank: true },
{ id: 'status', name: 'Status', format: 'textFormat', notBlank: true },
{
id: 'payment_method',
name: 'Payment Method',
format: 'textFormat',
},
{
id: 'currency',
name: 'Currency',
format: 'textFormat',
hide: false,
},
{
id: 'promo_code',
name: 'Promo Code',
format: 'textFormat',
hide: false,
},
{ id: 'notes', name: 'Notes', format: 'textFormat', hide: false },
{
id: 'order_month',
name: 'Order Month',
format: 'textFormat',
hide: false,
accessor: (row: any) => {
const d = new Date(row.order_datetime);
if (Number.isNaN(d.getTime())) return null;
return `${String(d.getUTCFullYear())}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
},
},
];
const orderItemsFields: AgFieldDefinition[] = [
{
id: 'order_item_id',
name: 'Order Item ID',
format: 'textFormat',
},
{
id: 'order_id',
name: 'Order ID',
format: 'textFormat',
},
{
id: 'product_id',
name: 'Product ID',
format: 'textFormat',
},
{
id: 'quantity',
name: 'Qty',
format: 'integerFormat',
formatOptions: { format: '#,##0' },
},
{
id: 'unit_price',
name: 'Unit Price',
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
},
{
id: 'discount_pct',
name: 'Discount',
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
{
id: 'tax_rate',
name: 'Tax Rate',
format: 'percentageFormat',
formatOptions: { format: '#,##0%' },
},
{ id: 'returned', name: 'Returned', format: 'booleanFormat', notBlank: true },
{
id: 'return_reason',
name: 'Return Reason',
format: 'textFormat',
},
];
const shipmentsFields: AgFieldDefinition[] = [
{
id: 'shipment_id',
name: 'Shipment ID',
format: 'textFormat',
},
{
id: 'order_id',
name: 'Order ID',
format: 'textFormat',
},
{
id: 'ship_datetime',
name: 'Shipped Date/Time',
format: 'dateTimeFormat',
},
{
id: 'delivery_datetime',
name: 'Delivered Date/Time',
format: 'dateTimeFormat',
},
{ id: 'carrier', name: 'Carrier', format: 'textFormat', notBlank: true },
{ id: 'delayed', name: 'Delayed', format: 'booleanFormat' },
];
// =============================================================================
// JSON Loading & Per-Source Caches
// =============================================================================
async function loadJson(baseUrl: string, filename: string): Promise<any[]> {
const url = `${baseUrl}/${filename}`;
const response = await fetch(url);
if (!response.ok) {
console.error(`Failed to load ${filename}: ${response.status}`);
return [];
}
const data = await response.json();
return data;
}
// =============================================================================
// Data Parsing & Cached Loaders
// =============================================================================
const parseBool = (v: unknown): boolean | undefined =>
v === true || v === 'True' ? true : v === false || v === 'False' ? false : undefined;
// Each loader caches its promise so the file is fetched and parsed at most once.
let storesCache: Promise<any[]> | null = null;
const getStores = (baseUrl: string) => (storesCache ??= loadJson(baseUrl, 'stores.json'));
let productsCache: Promise<any[]> | null = null;
const getProducts = (baseUrl: string) =>
(productsCache ??= loadJson(baseUrl, 'products.json').then((rows) =>
rows.map((row) => ({
...row,
list_price: Number(row.list_price),
unit_cost: Number(row.unit_cost),
is_discontinued: parseBool(row.is_discontinued),
}))
));
let customersCache: Promise<any[]> | null = null;
const getCustomers = (baseUrl: string) =>
(customersCache ??= loadJson(baseUrl, 'customers.json').then((rows) =>
rows.map((row) => ({
...row,
lifetime_orders: row.lifetime_orders !== '' ? Number(row.lifetime_orders) : null,
is_active: parseBool(row.is_active),
marketing_opt_in: parseBool(row.marketing_opt_in),
}))
));
let ordersCache: Promise<any[]> | null = null;
const getOrders = (baseUrl: string) => (ordersCache ??= loadJson(baseUrl, 'orders.json'));
let orderItemsCache: Promise<any[]> | null = null;
const getOrderItems = (baseUrl: string) =>
(orderItemsCache ??= loadJson(baseUrl, 'order_items.json').then((rows) =>
rows.map((row) => ({
...row,
quantity: Number(row.quantity),
unit_price: Number(row.unit_price),
discount_pct: Number(row.discount_pct),
tax_rate: Number(row.tax_rate),
returned: parseBool(row.returned),
}))
));
let shipmentsCache: Promise<any[]> | null = null;
const getShipments = (baseUrl: string) =>
(shipmentsCache ??= loadJson(baseUrl, 'shipments.json').then((rows) =>
rows.map((row) => {
const ship_datetime = row.ship_datetime == null || row.ship_datetime === '' ? null : row.ship_datetime;
const delivery_datetime =
row.delivery_datetime == null || row.delivery_datetime === '' ? null : row.delivery_datetime;
// Always guarantee boolean: default to false if not true.
const delayed =
row.delayed === true || row.delayed === 'True'
? true
: row.delayed === false || row.delayed === 'False'
? false
: false;
return { ...row, ship_datetime, delivery_datetime, delayed };
})
));
// =============================================================================
// Expressions (Calculated Columns & Measures)
// =============================================================================
const expressions: AgExpressionFieldDefinition[] = [
// -------------------------------------------------------------------------
// Pre-aggregation expressions (row-level calculations on order_items)
// -------------------------------------------------------------------------
// line_gross = quantity * unit_price
{
id: 'line_gross',
isMeasure: false,
name: 'Line Gross',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }],
},
},
// line_discount_amount = line_gross * discount_pct
{
id: 'line_discount_amount',
isMeasure: false,
name: 'Discount Amount',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'multiply',
inputs: [
{ operator: 'multiply', inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }] },
{ id: 'order_items.discount_pct' },
],
},
},
// line_net = line_gross - line_discount_amount
{
id: 'line_net',
isMeasure: false,
name: 'Line Net',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'subtract',
inputs: [
{ operator: 'multiply', inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }] },
{
operator: 'multiply',
inputs: [
{
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }],
},
{ id: 'order_items.discount_pct' },
],
},
],
},
},
// line_cogs = quantity * unit_cost (from products via join)
{
id: 'line_cogs',
isMeasure: false,
name: 'Line COGS',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'products.unit_cost' }],
},
},
// line_margin = line_net - line_cogs
{
id: 'line_margin',
isMeasure: false,
name: 'Line Margin',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'subtract',
inputs: [
// line_net
{
operator: 'subtract',
inputs: [
{
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }],
},
{
operator: 'multiply',
inputs: [
{
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }],
},
{ id: 'order_items.discount_pct' },
],
},
],
},
// line_cogs
{ operator: 'multiply', inputs: [{ id: 'order_items.quantity' }, { id: 'products.unit_cost' }] },
],
},
},
// return_flag = returned IS TRUE (for filtering/counting)
{
id: 'return_flag',
isMeasure: false,
name: 'Return Flag',
hide: true,
expression: {
operator: 'isTrue',
inputs: [{ id: 'order_items.returned' }],
},
},
// returned_line_flag = IF(return_flag, 1, 0) - numeric flag for summing
{
id: 'returned_line_flag',
isMeasure: false,
name: 'Returned Line Flag',
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'return_flag' }, { type: 'number', value: 1 }, { type: 'number', value: 0 }],
},
},
// returned_line_net = IF(return_flag, line_net, 0) - line net only for returned items
{
id: 'returned_line_net',
isMeasure: false,
name: 'Returned Line Net',
hide: true,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'if',
inputs: [{ id: 'return_flag' }, { id: 'line_net' }, { type: 'number', value: 0 }],
},
},
// returned_line_margin = IF(return_flag, line_margin, 0) - margin only for returned items
{
id: 'returned_line_margin',
isMeasure: false,
name: 'Returned Line Margin',
hide: true,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'if',
inputs: [{ id: 'return_flag' }, { id: 'line_margin' }, { type: 'number', value: 0 }],
},
},
// -------------------------------------------------------------------------
// Post-aggregation expressions (measures for KPIs)
// For now, use pre-agg expressions with aggregation in widgets directly
// -------------------------------------------------------------------------
// Gross Margin % = (SUM(line_net) - SUM(line_cogs)) / SUM(line_net)
{
id: 'gross_margin_pct',
isMeasure: true,
name: 'Gross Margin %',
hide: false,
expression: {
operator: 'divide',
inputs: [
{
operator: 'subtract',
inputs: [
{ id: 'line_net', aggregation: 'sum' },
{ id: 'line_cogs', aggregation: 'sum' },
],
},
{ id: 'line_net', aggregation: 'sum' },
],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
// order_status_group = IF(status = "Processing", "Open", "Closed")
{
id: 'order_status_group',
isMeasure: false,
name: 'Status Group',
expression: {
operator: 'if',
inputs: [
{ operator: 'equals', inputs: [{ id: 'orders.status' }, { type: 'string', value: 'Processing' }] },
{ type: 'string', value: 'Open' },
{ type: 'string', value: 'Closed' },
],
},
},
// is_closed = status IN ("Completed", "Returned", "Cancelled") via chained OR
{
id: 'is_closed',
isMeasure: false,
name: 'Is Closed',
expression: {
operator: 'or',
inputs: [
{ operator: 'equals', inputs: [{ id: 'orders.status' }, { type: 'string', value: 'Completed' }] },
{
operator: 'or',
inputs: [
{
operator: 'equals',
inputs: [{ id: 'orders.status' }, { type: 'string', value: 'Returned' }],
},
{
operator: 'equals',
inputs: [{ id: 'orders.status' }, { type: 'string', value: 'Cancelled' }],
},
],
},
],
},
},
// ship_to_delivery_days = DATEDIFF(day, ship_datetime, delivery_datetime)
{
id: 'ship_to_delivery_days',
isMeasure: false,
name: 'Ship to Delivery Days',
expression: {
operator: 'datediff',
inputs: [
{ type: 'string', value: 'day' },
{ id: 'shipments.ship_datetime' },
{ id: 'shipments.delivery_datetime' },
],
},
},
// order_to_ship_hours = DATEDIFF(hour, order_datetime, ship_datetime)
{
id: 'order_to_ship_hours',
isMeasure: false,
name: 'Order to Ship Hours',
expression: {
operator: 'datediff',
inputs: [
{ type: 'string', value: 'hour' },
{ id: 'orders.order_datetime' },
{ id: 'shipments.ship_datetime' },
],
},
},
// is_shipped = ship_datetime IS NOT NULL
{
id: 'is_shipped',
isMeasure: false,
name: 'Is Shipped',
hide: true,
expression: {
operator: 'isNotNull',
inputs: [{ id: 'shipments.ship_datetime' }],
},
},
// is_delivered = delivery_datetime IS NOT NULL
{
id: 'is_delivered',
isMeasure: false,
name: 'Is Delivered',
hide: true,
expression: {
operator: 'isNotNull',
inputs: [{ id: 'shipments.delivery_datetime' }],
},
},
// is_on_time = IF(is_delivered, delayed = FALSE, FALSE)
// Use IF+EQUALS to guarantee a boolean result.
{
id: 'is_on_time',
isMeasure: false,
name: 'Is On Time',
hide: true,
expression: {
operator: 'if',
inputs: [
{ id: 'is_delivered' },
{ operator: 'equals', inputs: [{ id: 'shipments.delayed' }, { type: 'boolean', value: false }] },
{ type: 'boolean', value: false },
],
},
},
// is_delayed = IF(is_delivered, delayed = TRUE, FALSE)
// Use IF+EQUALS to guarantee a boolean result.
{
id: 'is_delayed',
isMeasure: false,
name: 'Is Delayed',
hide: true,
expression: {
operator: 'if',
inputs: [
{ id: 'is_delivered' },
{ operator: 'equals', inputs: [{ id: 'shipments.delayed' }, { type: 'boolean', value: true }] },
{ type: 'boolean', value: false },
],
},
},
// delayed_shipments = IF(is_delayed, 1, 0) - numeric flag for stacking
{
id: 'delayed_shipments',
isMeasure: false,
name: 'Delayed Shipments',
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_delayed' }, { type: 'number', value: 1 }, { type: 'number', value: 0 }],
},
},
// on_time_shipments = IF(is_on_time, 1, 0) - numeric flag for stacking
{
id: 'on_time_shipments',
isMeasure: false,
name: 'On-time Shipments',
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_on_time' }, { type: 'number', value: 1 }, { type: 'number', value: 0 }],
},
},
// delivered_shipments = IF(is_delivered, 1, 0) - numeric flag for rate denominators
{
id: 'delivered_shipments',
isMeasure: false,
name: 'Delivered Shipments',
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_delivered' }, { type: 'number', value: 1 }, { type: 'number', value: 0 }],
},
},
// delay_rate = SUM(delayed_shipments) / SUM(delivered_shipments)
// Use delivered shipments as the denominator so pending (not delivered) rows don't dilute the rate.
{
id: 'delay_rate',
isMeasure: true,
name: 'Delay Rate',
hide: false,
expression: {
operator: 'divide',
inputs: [
{ id: 'delayed_shipments', aggregation: 'sum' },
{ id: 'delivered_shipments', aggregation: 'sum' },
],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
// shipped_order_id = IF(is_shipped, order_id, NULL)
{
id: 'shipped_order_id',
isMeasure: false,
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_shipped' }, { id: 'shipments.order_id' }, { type: 'string', value: null }],
},
},
// on_time_order_id = IF(is_on_time, order_id, NULL)
{
id: 'on_time_order_id',
isMeasure: false,
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_on_time' }, { id: 'shipments.order_id' }, { type: 'string', value: null }],
},
},
// delivered_order_id = IF(is_delivered, order_id, NULL)
{
id: 'delivered_order_id',
isMeasure: false,
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_delivered' }, { id: 'shipments.order_id' }, { type: 'string', value: null }],
},
},
// delivery_status = IF(NOT is_shipped, "Not shipped", IF(delayed = true, "Delayed", "On time"))
{
id: 'delivery_status',
isMeasure: false,
name: 'Delivery Status',
expression: {
operator: 'if',
inputs: [
{ operator: 'not', inputs: [{ id: 'is_shipped' }] },
{ type: 'string', value: 'Not shipped' },
{
operator: 'if',
inputs: [
{ id: 'shipments.delayed' },
{ type: 'string', value: 'Delayed' },
{ type: 'string', value: 'On time' },
],
},
],
},
},
// returned_order_id = IF(return_flag, order_id, NULL)
// Used for counting orders that have at least one returned line.
{
id: 'returned_order_id',
isMeasure: false,
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'return_flag' }, { id: 'order_items.order_id' }, { type: 'string', value: null }],
},
},
// Aggregated calculations for KPIs (using pre-aggregation expressions as inputs)
{
id: 'net_sales',
isMeasure: true,
name: 'Net Sales',
expression: {
id: 'line_net',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,\\K',
},
},
{
id: 'gross_sales',
isMeasure: true,
name: 'Gross Sales',
expression: {
id: 'line_gross',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,\\K',
},
},
{
id: 'discount_amount',
isMeasure: true,
name: 'Discount Amount',
expression: {
id: 'line_discount_amount',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,K',
},
},
{
id: 'COGS',
isMeasure: true,
name: 'COGS',
expression: {
id: 'line_cogs',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,,\\M',
},
},
{
id: 'gross_margin',
isMeasure: true,
name: 'Gross Margin',
expression: {
id: 'line_margin',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,,\\M',
},
},
{
id: 'gross_margin_percentage',
isMeasure: true,
name: 'Gross Margin %',
expression: {
operator: 'divide',
inputs: [{ id: 'gross_margin' }, { id: 'net_sales' }],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
// --- Orders ---
{
id: 'order_count',
isMeasure: true,
name: 'Order Count',
expression: {
id: 'orders.order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'average_order_value',
isMeasure: true,
name: 'Average Order Value',
expression: {
operator: 'divide',
inputs: [{ id: 'net_sales' }, { id: 'order_count' }],
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
},
{
id: 'active_customers',
isMeasure: true,
name: 'Active Customers',
expression: {
id: 'orders.customer_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
// --- Returns ---
{
id: 'returned_lines',
isMeasure: true,
name: 'Returned Lines',
expression: {
id: 'returned_line_flag',
aggregation: 'sum',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0',
},
},
{
id: 'returned_orders',
isMeasure: true,
name: 'Returned Orders',
expression: {
id: 'returned_order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0',
},
},
{
id: 'return_rate',
isMeasure: true,
name: 'Return Rate (Lines)',
expression: {
operator: 'divide',
inputs: [{ id: 'returned_lines' }, { id: 'order_items.order_item_id', aggregation: 'count' }],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
{
id: 'return_value',
isMeasure: true,
name: 'Return Value',
expression: {
id: 'returned_line_net',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,K',
},
},
// return_margin_impact = -SUM(returned_line_margin)
// Display as a negative number to represent margin lost due to returns.
{
id: 'return_margin_impact',
isMeasure: true,
name: 'Return Margin Impact',
expression: {
operator: 'subtract',
inputs: [
{ type: 'number', value: 0 },
{ id: 'returned_line_margin', aggregation: 'sum' },
],
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,K',
},
},
// --- Delivery ---
{
id: 'shipped_orders',
isMeasure: true,
name: 'Shipped Orders',
expression: {
id: 'shipped_order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'delivered_orders',
isMeasure: true,
name: 'Delivered Orders',
expression: {
id: 'delivered_order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'delivered_not_returned_orders',
isMeasure: true,
name: 'Delivered (Not Returned)',
expression: {
operator: 'subtract',
inputs: [{ id: 'delivered_orders' }, { id: 'returned_orders' }],
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'on_time_deliveries',
isMeasure: true,
name: 'On-time Deliveries',
expression: {
id: 'on_time_order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'on_time_rate',
isMeasure: true,
name: 'On-time Rate',
expression: {
operator: 'divide',
inputs: [{ id: 'on_time_deliveries' }, { id: 'delivered_orders' }],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
{
id: 'avg_ship_to_delivery_days',
isMeasure: true,
name: 'Avg Ship to Delivery Days',
expression: {
id: 'ship_to_delivery_days',
aggregation: 'avg',
},
format: 'decimalFormat',
formatOptions: { format: '#,##0.0' },
},
];
// =============================================================================
// Data Source Definition
// =============================================================================
export function getMainDemoData(baseUrl: string): AgDataSourcesDefinition {
const url = `${baseUrl}/main-demo`;
return {
sources: [
{
id: 'stores',
name: 'Stores',
dataShape: 'row',
tables: [{ id: 'stores', name: 'Stores', fields: storesFields }],
getData: async () => ({ data: await getStores(url) }),
},
{
id: 'products',
name: 'Products',
dataShape: 'row',
tables: [{ id: 'products', name: 'Products', fields: productsFields }],
getData: async () => ({ data: await getProducts(url) }),
},
{
id: 'customers',
name: 'Customers',
dataShape: 'row',
tables: [{ id: 'customers', name: 'Customers', fields: customersFields }],
getData: async () => ({ data: await getCustomers(url) }),
},
{
id: 'orders',
name: 'Orders',
dataShape: 'row',
tables: [{ id: 'orders', name: 'Orders', fields: ordersFields }],
getData: async () => ({ data: await getOrders(url) }),
},
{
id: 'order_items',
name: 'Order Items',
dataShape: 'row',
tables: [{ id: 'order_items', name: 'Order Items', fields: orderItemsFields }],
getData: async () => ({ data: await getOrderItems(url) }),
},
{
id: 'shipments',
name: 'Shipments',
dataShape: 'row',
tables: [{ id: 'shipments', name: 'Shipments', fields: shipmentsFields }],
getData: async () => ({ data: await getShipments(url) }),
},
],
relationships: [
{
id: 'orders-customers',
source: { tableId: 'orders', fieldId: 'customer_id' },
target: { tableId: 'customers', fieldId: 'customer_id' },
type: 'many-to-one',
},
{
id: 'orders-stores',
source: { tableId: 'orders', fieldId: 'store_id' },
target: { tableId: 'stores', fieldId: 'store_id' },
type: 'many-to-one',
},
{
id: 'order_items-orders',
source: { tableId: 'order_items', fieldId: 'order_id' },
target: { tableId: 'orders', fieldId: 'order_id' },
type: 'many-to-one',
},
{
id: 'order_items-products',
source: { tableId: 'order_items', fieldId: 'product_id' },
target: { tableId: 'products', fieldId: 'product_id' },
type: 'many-to-one',
// line_cogs/line_margin multiply order_items.quantity (many-side, varies per row) by
// products.unit_cost (one-side) before summing - grain-invariant-safe, not a real fan-out.
acceptFanout: true,
},
{
id: 'shipments-orders',
source: { tableId: 'shipments', fieldId: 'order_id' },
target: { tableId: 'orders', fieldId: 'order_id' },
type: 'many-to-one',
},
],
expressions,
};
}
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Totals Copy Link
Two independent totals are available on the Pivot Table's Format tab, both off by default:
- Total Row - a grand total pinned as the last row, summing every measure across the whole table.
- Total Columns - available once a field is mapped to Columns. Adds a column summing each measure across every column member. Set Placement to Beginning or End.
Empty Cells Copy Link
A Pivot Table holds a cell for every combination of row and column member, but the data rarely fills all of them. Where a combination has no rows behind it there is nothing to aggregate, so the cell shows N/A instead of a number. A cell showing 0 means the opposite: rows exist for that combination, and the measure adds up to zero across them.
Both appear in the medals example at the top of this section. Its data covers Summer and Winter Games in alternating years, and most countries win in one or the other. A country that competes only in summer shows N/A across the winter years. A country that won that year but took no gold shows 0 in its Gold column.
Polar Chart Copy Link
The Polar Chart group brings together the radar and radial Widgets, which plot values around a circular axis rather than a linear one.
Radar Line Chart uses lines on a radar layout to compare multiple variables at once.
Radar Area Chart fills the area on a radar layout to emphasise coverage across dimensions.
Radial Column Chart arranges bars in a circle. It should be used for cyclical or categorical data.
Nightingale Chart uses sectors of varying radius to emphasise differences in value.
Radial Bar Chart uses concentric circular bars for compact category comparisons.
The example below shows all five Polar Chart Widgets plotting the same categories, so the different circular layouts can be compared directly.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"radar-line-chart": {
type: "radar-line-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Radar Line Chart",
},
},
},
"radar-area-chart": {
type: "radar-area-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Radar Area Chart",
},
},
},
"radial-column-chart": {
type: "radial-column-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Radial Column Chart",
},
},
},
"nightingale-chart": {
type: "nightingale-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Nightingale Chart",
},
},
},
"radial-bar-chart": {
type: "radial-bar-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Radial Bar Chart",
},
},
},
},
widgetLayout: {
"radar-line-chart": {
xTrack: 0,
yTrack: 0,
xSpan: 12,
ySpan: 20,
},
"radar-area-chart": {
xTrack: 12,
yTrack: 0,
xSpan: 12,
ySpan: 20,
},
"radial-column-chart": {
xTrack: 0,
yTrack: 20,
xSpan: 12,
ySpan: 20,
},
"radial-bar-chart": {
xTrack: 12,
yTrack: 20,
xSpan: 12,
ySpan: 20,
},
"nightingale-chart": {
xTrack: 6,
yTrack: 40,
xSpan: 12,
ySpan: 20,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Proportional Chart Copy Link
Proportional Chart Widgets show how a whole splits into parts.
Pie Chart divides a circle into slices to show simple part-to-whole proportions.
Donut Chart is a Pie Chart with a hollow centre, leaving room for a central metric or label.
The example below places a Pie Chart beside a Donut Chart splitting the same values, so the hollow centre is the only difference between them.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"pie-chart": {
type: "pie-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Pie Chart",
},
},
},
"donut-chart": {
type: "donut-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Donut Chart",
},
},
},
},
widgetLayout: {
"pie-chart": {
xTrack: 0,
yTrack: 0,
xSpan: 12,
ySpan: 20,
},
"donut-chart": {
xTrack: 12,
yTrack: 0,
xSpan: 12,
ySpan: 20,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Progression Chart Copy Link
Progression Chart Widgets show values narrowing through sequential stages, such as a sales or conversion funnel.
Funnel Chart uses a stepped funnel to show conversion rates or sequential process stages.
Cone Funnel Chart uses a smooth-tapering funnel for the same kind of conversion or process visualisation.
Pyramid Chart uses a triangular layout for hierarchical or tiered data.
The example below places a Funnel beside a Cone Funnel with a Pyramid below them, so the three shapes can be compared directly.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"funnel-chart": {
type: "funnel-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [{ id: "medals.gold", aggregation: "sum" }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Funnel Chart",
},
},
},
"cone-funnel-chart": {
type: "cone-funnel-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [{ id: "medals.gold", aggregation: "sum" }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Cone Funnel Chart",
},
},
},
"pyramid-chart": {
type: "pyramid-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [{ id: "medals.gold", aggregation: "sum" }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Pyramid Chart",
},
},
},
},
widgetLayout: {
"funnel-chart": {
xTrack: 0,
yTrack: 0,
xSpan: 12,
ySpan: 16,
},
"cone-funnel-chart": {
xTrack: 12,
yTrack: 0,
xSpan: 12,
ySpan: 16,
},
"pyramid-chart": {
xTrack: 6,
yTrack: 16,
xSpan: 12,
ySpan: 16,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Hierarchical Chart Copy Link
Hierarchical Chart Widgets show nested part-to-whole relationships, sized by value.
Treemap Chart nests rectangles to show a hierarchy of part-to-whole proportions, sized by value.
Sunburst Chart uses concentric rings to show the same kind of hierarchy, sized by value.
The example below shows the same hierarchy as both a Treemap and a Sunburst, comparing a rectangular layout against a radial one.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"treemap-chart": {
type: "treemap-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }, { id: "medals.sport" }],
valueKey: [{ id: "medals.gold", aggregation: "sum" }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Treemap Chart",
},
},
},
"sunburst-chart": {
type: "sunburst-chart",
dataMapping: {
categoryKey: [{ id: "medals.country" }, { id: "medals.sport" }],
valueKey: [{ id: "medals.gold", aggregation: "sum" }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Sunburst Chart",
},
},
},
},
widgetLayout: {
"treemap-chart": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 16,
},
"sunburst-chart": {
xTrack: 0,
yTrack: 16,
xSpan: 24,
ySpan: 16,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Hierarchical Chart Setup Copy Link
Both Widgets share the same Setup panel:
- Hierarchy Levels - an ordered list of grouping fields, outermost first. Each level nests inside the one before it.
- Value - a numeric field that sizes each tile (Treemap) or segment (Sunburst).
The example below shows sales sized by Net Sales and grouped by Region, then Subcategory.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
import { getMainDemoData } from "./data.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "a",
widgets: {
"sales-treemap": {
type: "treemap-chart",
dataMapping: {
categoryKey: [
{ id: "stores.region" },
{ id: "products.subcategory" },
],
valueKey: [{ id: "net_sales" }],
},
format: {
title: {
enabled: true,
text: "Net Sales by Region and Subcategory",
},
},
sort: [{ field: { id: "products.subcategory" }, direction: "asc" }],
},
},
widgetLayout: {
"sales-treemap": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 32,
},
},
selection: { type: "widget", id: "sales-treemap" },
},
],
selectedPageId: "a",
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
panels: {
edit: {
right: ["edit"],
},
},
data: getMainDemoData("https://www.ag-grid.com/studio/archive/3.0.0/example-assets"),
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
import type { AgDataSourcesDefinition, AgExpressionFieldDefinition, AgFieldDefinition } from 'ag-studio';
// =============================================================================
// Field Definitions
// =============================================================================
const storesFields: AgFieldDefinition[] = [
{
id: 'store_id',
name: 'Store ID',
format: 'textFormat',
},
{ id: 'store_name', name: 'Store', format: 'textFormat', notBlank: true },
{ id: 'region', name: 'Region', format: 'textFormat', notBlank: true },
{ id: 'city', name: 'City', format: 'textFormat', notBlank: true },
{
id: 'opened_date',
name: 'Opened Date',
format: 'dateFormat',
},
{ id: 'store_type', name: 'Store Type', format: 'textFormat', notBlank: true },
];
const productsFields: AgFieldDefinition[] = [
{
id: 'product_id',
name: 'Product ID',
format: 'textFormat',
hide: false,
},
{
id: 'product_name',
name: 'Product',
format: 'textFormat',
},
{ id: 'category', name: 'Category', format: 'textFormat', notBlank: true },
{
id: 'subcategory',
name: 'Subcategory',
format: 'textFormat',
},
{ id: 'brand', name: 'Brand', format: 'textFormat', notBlank: true },
{ id: 'launch_date', name: 'Launch Date', format: 'dateFormat', notBlank: true },
{
id: 'list_price',
name: 'List Price',
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
},
{
id: 'unit_cost',
name: 'Unit Cost',
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
},
{
id: 'is_discontinued',
name: 'Discontinued',
format: 'booleanFormat',
},
];
const customersFields: AgFieldDefinition[] = [
{
id: 'customer_id',
name: 'Customer ID',
format: 'textFormat',
hide: false,
},
{
id: 'customer_name',
name: 'Customer',
format: 'textFormat',
},
{ id: 'signup_date', name: 'Signup Date', format: 'dateFormat', notBlank: true },
{ id: 'region', name: 'Region', format: 'textFormat', notBlank: true },
{ id: 'segment', name: 'Segment', format: 'textFormat', notBlank: true },
{ id: 'is_active', name: 'Active', format: 'booleanFormat', notBlank: true },
{
id: 'marketing_opt_in',
name: 'Marketing Opt-in',
format: 'booleanFormat',
},
{
id: 'lifetime_orders',
name: 'Lifetime Orders',
format: 'integerFormat',
formatOptions: { format: '#,##0' },
},
];
const ordersFields: AgFieldDefinition[] = [
{
id: 'order_id',
name: 'Order ID',
format: 'textFormat',
hide: false,
},
{
id: 'customer_id',
name: 'Customer ID',
format: 'textFormat',
},
{
id: 'store_id',
name: 'Store ID',
format: 'textFormat',
},
{
id: 'order_datetime',
name: 'Order Date/Time',
format: 'dateTimeFormat',
},
{ id: 'channel', name: 'Channel', format: 'textFormat', notBlank: true },
{ id: 'status', name: 'Status', format: 'textFormat', notBlank: true },
{
id: 'payment_method',
name: 'Payment Method',
format: 'textFormat',
},
{
id: 'currency',
name: 'Currency',
format: 'textFormat',
hide: false,
},
{
id: 'promo_code',
name: 'Promo Code',
format: 'textFormat',
hide: false,
},
{ id: 'notes', name: 'Notes', format: 'textFormat', hide: false },
{
id: 'order_month',
name: 'Order Month',
format: 'textFormat',
hide: false,
accessor: (row: any) => {
const d = new Date(row.order_datetime);
if (Number.isNaN(d.getTime())) return null;
return `${String(d.getUTCFullYear())}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
},
},
];
const orderItemsFields: AgFieldDefinition[] = [
{
id: 'order_item_id',
name: 'Order Item ID',
format: 'textFormat',
},
{
id: 'order_id',
name: 'Order ID',
format: 'textFormat',
},
{
id: 'product_id',
name: 'Product ID',
format: 'textFormat',
},
{
id: 'quantity',
name: 'Qty',
format: 'integerFormat',
formatOptions: { format: '#,##0' },
},
{
id: 'unit_price',
name: 'Unit Price',
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
},
{
id: 'discount_pct',
name: 'Discount',
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
{
id: 'tax_rate',
name: 'Tax Rate',
format: 'percentageFormat',
formatOptions: { format: '#,##0%' },
},
{ id: 'returned', name: 'Returned', format: 'booleanFormat', notBlank: true },
{
id: 'return_reason',
name: 'Return Reason',
format: 'textFormat',
},
];
const shipmentsFields: AgFieldDefinition[] = [
{
id: 'shipment_id',
name: 'Shipment ID',
format: 'textFormat',
},
{
id: 'order_id',
name: 'Order ID',
format: 'textFormat',
},
{
id: 'ship_datetime',
name: 'Shipped Date/Time',
format: 'dateTimeFormat',
},
{
id: 'delivery_datetime',
name: 'Delivered Date/Time',
format: 'dateTimeFormat',
},
{ id: 'carrier', name: 'Carrier', format: 'textFormat', notBlank: true },
{ id: 'delayed', name: 'Delayed', format: 'booleanFormat' },
];
// =============================================================================
// JSON Loading & Per-Source Caches
// =============================================================================
async function loadJson(baseUrl: string, filename: string): Promise<any[]> {
const url = `${baseUrl}/${filename}`;
const response = await fetch(url);
if (!response.ok) {
console.error(`Failed to load ${filename}: ${response.status}`);
return [];
}
const data = await response.json();
return data;
}
// =============================================================================
// Data Parsing & Cached Loaders
// =============================================================================
const parseBool = (v: unknown): boolean | undefined =>
v === true || v === 'True' ? true : v === false || v === 'False' ? false : undefined;
// Each loader caches its promise so the file is fetched and parsed at most once.
let storesCache: Promise<any[]> | null = null;
const getStores = (baseUrl: string) => (storesCache ??= loadJson(baseUrl, 'stores.json'));
let productsCache: Promise<any[]> | null = null;
const getProducts = (baseUrl: string) =>
(productsCache ??= loadJson(baseUrl, 'products.json').then((rows) =>
rows.map((row) => ({
...row,
list_price: Number(row.list_price),
unit_cost: Number(row.unit_cost),
is_discontinued: parseBool(row.is_discontinued),
}))
));
let customersCache: Promise<any[]> | null = null;
const getCustomers = (baseUrl: string) =>
(customersCache ??= loadJson(baseUrl, 'customers.json').then((rows) =>
rows.map((row) => ({
...row,
lifetime_orders: row.lifetime_orders !== '' ? Number(row.lifetime_orders) : null,
is_active: parseBool(row.is_active),
marketing_opt_in: parseBool(row.marketing_opt_in),
}))
));
let ordersCache: Promise<any[]> | null = null;
const getOrders = (baseUrl: string) => (ordersCache ??= loadJson(baseUrl, 'orders.json'));
let orderItemsCache: Promise<any[]> | null = null;
const getOrderItems = (baseUrl: string) =>
(orderItemsCache ??= loadJson(baseUrl, 'order_items.json').then((rows) =>
rows.map((row) => ({
...row,
quantity: Number(row.quantity),
unit_price: Number(row.unit_price),
discount_pct: Number(row.discount_pct),
tax_rate: Number(row.tax_rate),
returned: parseBool(row.returned),
}))
));
let shipmentsCache: Promise<any[]> | null = null;
const getShipments = (baseUrl: string) =>
(shipmentsCache ??= loadJson(baseUrl, 'shipments.json').then((rows) =>
rows.map((row) => {
const ship_datetime = row.ship_datetime == null || row.ship_datetime === '' ? null : row.ship_datetime;
const delivery_datetime =
row.delivery_datetime == null || row.delivery_datetime === '' ? null : row.delivery_datetime;
// Always guarantee boolean: default to false if not true.
const delayed =
row.delayed === true || row.delayed === 'True'
? true
: row.delayed === false || row.delayed === 'False'
? false
: false;
return { ...row, ship_datetime, delivery_datetime, delayed };
})
));
// =============================================================================
// Expressions (Calculated Columns & Measures)
// =============================================================================
const expressions: AgExpressionFieldDefinition[] = [
// -------------------------------------------------------------------------
// Pre-aggregation expressions (row-level calculations on order_items)
// -------------------------------------------------------------------------
// line_gross = quantity * unit_price
{
id: 'line_gross',
isMeasure: false,
name: 'Line Gross',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }],
},
},
// line_discount_amount = line_gross * discount_pct
{
id: 'line_discount_amount',
isMeasure: false,
name: 'Discount Amount',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'multiply',
inputs: [
{ operator: 'multiply', inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }] },
{ id: 'order_items.discount_pct' },
],
},
},
// line_net = line_gross - line_discount_amount
{
id: 'line_net',
isMeasure: false,
name: 'Line Net',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'subtract',
inputs: [
{ operator: 'multiply', inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }] },
{
operator: 'multiply',
inputs: [
{
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }],
},
{ id: 'order_items.discount_pct' },
],
},
],
},
},
// line_cogs = quantity * unit_cost (from products via join)
{
id: 'line_cogs',
isMeasure: false,
name: 'Line COGS',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'products.unit_cost' }],
},
},
// line_margin = line_net - line_cogs
{
id: 'line_margin',
isMeasure: false,
name: 'Line Margin',
hide: false,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'subtract',
inputs: [
// line_net
{
operator: 'subtract',
inputs: [
{
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }],
},
{
operator: 'multiply',
inputs: [
{
operator: 'multiply',
inputs: [{ id: 'order_items.quantity' }, { id: 'order_items.unit_price' }],
},
{ id: 'order_items.discount_pct' },
],
},
],
},
// line_cogs
{ operator: 'multiply', inputs: [{ id: 'order_items.quantity' }, { id: 'products.unit_cost' }] },
],
},
},
// return_flag = returned IS TRUE (for filtering/counting)
{
id: 'return_flag',
isMeasure: false,
name: 'Return Flag',
hide: true,
expression: {
operator: 'isTrue',
inputs: [{ id: 'order_items.returned' }],
},
},
// returned_line_flag = IF(return_flag, 1, 0) - numeric flag for summing
{
id: 'returned_line_flag',
isMeasure: false,
name: 'Returned Line Flag',
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'return_flag' }, { type: 'number', value: 1 }, { type: 'number', value: 0 }],
},
},
// returned_line_net = IF(return_flag, line_net, 0) - line net only for returned items
{
id: 'returned_line_net',
isMeasure: false,
name: 'Returned Line Net',
hide: true,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'if',
inputs: [{ id: 'return_flag' }, { id: 'line_net' }, { type: 'number', value: 0 }],
},
},
// returned_line_margin = IF(return_flag, line_margin, 0) - margin only for returned items
{
id: 'returned_line_margin',
isMeasure: false,
name: 'Returned Line Margin',
hide: true,
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
expression: {
operator: 'if',
inputs: [{ id: 'return_flag' }, { id: 'line_margin' }, { type: 'number', value: 0 }],
},
},
// -------------------------------------------------------------------------
// Post-aggregation expressions (measures for KPIs)
// For now, use pre-agg expressions with aggregation in widgets directly
// -------------------------------------------------------------------------
// Gross Margin % = (SUM(line_net) - SUM(line_cogs)) / SUM(line_net)
{
id: 'gross_margin_pct',
isMeasure: true,
name: 'Gross Margin %',
hide: false,
expression: {
operator: 'divide',
inputs: [
{
operator: 'subtract',
inputs: [
{ id: 'line_net', aggregation: 'sum' },
{ id: 'line_cogs', aggregation: 'sum' },
],
},
{ id: 'line_net', aggregation: 'sum' },
],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
// order_status_group = IF(status = "Processing", "Open", "Closed")
{
id: 'order_status_group',
isMeasure: false,
name: 'Status Group',
expression: {
operator: 'if',
inputs: [
{ operator: 'equals', inputs: [{ id: 'orders.status' }, { type: 'string', value: 'Processing' }] },
{ type: 'string', value: 'Open' },
{ type: 'string', value: 'Closed' },
],
},
},
// is_closed = status IN ("Completed", "Returned", "Cancelled") via chained OR
{
id: 'is_closed',
isMeasure: false,
name: 'Is Closed',
expression: {
operator: 'or',
inputs: [
{ operator: 'equals', inputs: [{ id: 'orders.status' }, { type: 'string', value: 'Completed' }] },
{
operator: 'or',
inputs: [
{
operator: 'equals',
inputs: [{ id: 'orders.status' }, { type: 'string', value: 'Returned' }],
},
{
operator: 'equals',
inputs: [{ id: 'orders.status' }, { type: 'string', value: 'Cancelled' }],
},
],
},
],
},
},
// ship_to_delivery_days = DATEDIFF(day, ship_datetime, delivery_datetime)
{
id: 'ship_to_delivery_days',
isMeasure: false,
name: 'Ship to Delivery Days',
expression: {
operator: 'datediff',
inputs: [
{ type: 'string', value: 'day' },
{ id: 'shipments.ship_datetime' },
{ id: 'shipments.delivery_datetime' },
],
},
},
// order_to_ship_hours = DATEDIFF(hour, order_datetime, ship_datetime)
{
id: 'order_to_ship_hours',
isMeasure: false,
name: 'Order to Ship Hours',
expression: {
operator: 'datediff',
inputs: [
{ type: 'string', value: 'hour' },
{ id: 'orders.order_datetime' },
{ id: 'shipments.ship_datetime' },
],
},
},
// is_shipped = ship_datetime IS NOT NULL
{
id: 'is_shipped',
isMeasure: false,
name: 'Is Shipped',
hide: true,
expression: {
operator: 'isNotNull',
inputs: [{ id: 'shipments.ship_datetime' }],
},
},
// is_delivered = delivery_datetime IS NOT NULL
{
id: 'is_delivered',
isMeasure: false,
name: 'Is Delivered',
hide: true,
expression: {
operator: 'isNotNull',
inputs: [{ id: 'shipments.delivery_datetime' }],
},
},
// is_on_time = IF(is_delivered, delayed = FALSE, FALSE)
// Use IF+EQUALS to guarantee a boolean result.
{
id: 'is_on_time',
isMeasure: false,
name: 'Is On Time',
hide: true,
expression: {
operator: 'if',
inputs: [
{ id: 'is_delivered' },
{ operator: 'equals', inputs: [{ id: 'shipments.delayed' }, { type: 'boolean', value: false }] },
{ type: 'boolean', value: false },
],
},
},
// is_delayed = IF(is_delivered, delayed = TRUE, FALSE)
// Use IF+EQUALS to guarantee a boolean result.
{
id: 'is_delayed',
isMeasure: false,
name: 'Is Delayed',
hide: true,
expression: {
operator: 'if',
inputs: [
{ id: 'is_delivered' },
{ operator: 'equals', inputs: [{ id: 'shipments.delayed' }, { type: 'boolean', value: true }] },
{ type: 'boolean', value: false },
],
},
},
// delayed_shipments = IF(is_delayed, 1, 0) - numeric flag for stacking
{
id: 'delayed_shipments',
isMeasure: false,
name: 'Delayed Shipments',
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_delayed' }, { type: 'number', value: 1 }, { type: 'number', value: 0 }],
},
},
// on_time_shipments = IF(is_on_time, 1, 0) - numeric flag for stacking
{
id: 'on_time_shipments',
isMeasure: false,
name: 'On-time Shipments',
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_on_time' }, { type: 'number', value: 1 }, { type: 'number', value: 0 }],
},
},
// delivered_shipments = IF(is_delivered, 1, 0) - numeric flag for rate denominators
{
id: 'delivered_shipments',
isMeasure: false,
name: 'Delivered Shipments',
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_delivered' }, { type: 'number', value: 1 }, { type: 'number', value: 0 }],
},
},
// delay_rate = SUM(delayed_shipments) / SUM(delivered_shipments)
// Use delivered shipments as the denominator so pending (not delivered) rows don't dilute the rate.
{
id: 'delay_rate',
isMeasure: true,
name: 'Delay Rate',
hide: false,
expression: {
operator: 'divide',
inputs: [
{ id: 'delayed_shipments', aggregation: 'sum' },
{ id: 'delivered_shipments', aggregation: 'sum' },
],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
// shipped_order_id = IF(is_shipped, order_id, NULL)
{
id: 'shipped_order_id',
isMeasure: false,
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_shipped' }, { id: 'shipments.order_id' }, { type: 'string', value: null }],
},
},
// on_time_order_id = IF(is_on_time, order_id, NULL)
{
id: 'on_time_order_id',
isMeasure: false,
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_on_time' }, { id: 'shipments.order_id' }, { type: 'string', value: null }],
},
},
// delivered_order_id = IF(is_delivered, order_id, NULL)
{
id: 'delivered_order_id',
isMeasure: false,
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'is_delivered' }, { id: 'shipments.order_id' }, { type: 'string', value: null }],
},
},
// delivery_status = IF(NOT is_shipped, "Not shipped", IF(delayed = true, "Delayed", "On time"))
{
id: 'delivery_status',
isMeasure: false,
name: 'Delivery Status',
expression: {
operator: 'if',
inputs: [
{ operator: 'not', inputs: [{ id: 'is_shipped' }] },
{ type: 'string', value: 'Not shipped' },
{
operator: 'if',
inputs: [
{ id: 'shipments.delayed' },
{ type: 'string', value: 'Delayed' },
{ type: 'string', value: 'On time' },
],
},
],
},
},
// returned_order_id = IF(return_flag, order_id, NULL)
// Used for counting orders that have at least one returned line.
{
id: 'returned_order_id',
isMeasure: false,
hide: true,
expression: {
operator: 'if',
inputs: [{ id: 'return_flag' }, { id: 'order_items.order_id' }, { type: 'string', value: null }],
},
},
// Aggregated calculations for KPIs (using pre-aggregation expressions as inputs)
{
id: 'net_sales',
isMeasure: true,
name: 'Net Sales',
expression: {
id: 'line_net',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,,\\M',
},
},
{
id: 'gross_sales',
isMeasure: true,
name: 'Gross Sales',
expression: {
id: 'line_gross',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,,\\M',
},
},
{
id: 'discount_amount',
isMeasure: true,
name: 'Discount Amount',
expression: {
id: 'line_discount_amount',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,K',
},
},
{
id: 'COGS',
isMeasure: true,
name: 'COGS',
expression: {
id: 'line_cogs',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,,\\M',
},
},
{
id: 'gross_margin',
isMeasure: true,
name: 'Gross Margin',
expression: {
id: 'line_margin',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,,\\M',
},
},
{
id: 'gross_margin_percentage',
isMeasure: true,
name: 'Gross Margin %',
expression: {
operator: 'divide',
inputs: [{ id: 'gross_margin' }, { id: 'net_sales' }],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
// --- Orders ---
{
id: 'order_count',
isMeasure: true,
name: 'Order Count',
expression: {
id: 'orders.order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'average_order_value',
isMeasure: true,
name: 'Average Order Value',
expression: {
operator: 'divide',
inputs: [{ id: 'net_sales' }, { id: 'order_count' }],
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.00',
},
},
{
id: 'active_customers',
isMeasure: true,
name: 'Active Customers',
expression: {
id: 'orders.customer_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
// --- Returns ---
{
id: 'returned_lines',
isMeasure: true,
name: 'Returned Lines',
expression: {
id: 'returned_line_flag',
aggregation: 'sum',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0',
},
},
{
id: 'returned_orders',
isMeasure: true,
name: 'Returned Orders',
expression: {
id: 'returned_order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0',
},
},
{
id: 'return_rate',
isMeasure: true,
name: 'Return Rate (Lines)',
expression: {
operator: 'divide',
inputs: [{ id: 'returned_lines' }, { id: 'order_items.order_item_id', aggregation: 'count' }],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
{
id: 'return_value',
isMeasure: true,
name: 'Return Value',
expression: {
id: 'returned_line_net',
aggregation: 'sum',
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,K',
},
},
// return_margin_impact = -SUM(returned_line_margin)
// Display as a negative number to represent margin lost due to returns.
{
id: 'return_margin_impact',
isMeasure: true,
name: 'Return Margin Impact',
expression: {
operator: 'subtract',
inputs: [
{ type: 'number', value: 0 },
{ id: 'returned_line_margin', aggregation: 'sum' },
],
},
format: 'currencyFormat',
formatOptions: {
format: '£#,##0.0,K',
},
},
// --- Delivery ---
{
id: 'shipped_orders',
isMeasure: true,
name: 'Shipped Orders',
expression: {
id: 'shipped_order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'delivered_orders',
isMeasure: true,
name: 'Delivered Orders',
expression: {
id: 'delivered_order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'delivered_not_returned_orders',
isMeasure: true,
name: 'Delivered (Not Returned)',
expression: {
operator: 'subtract',
inputs: [{ id: 'delivered_orders' }, { id: 'returned_orders' }],
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'on_time_deliveries',
isMeasure: true,
name: 'On-time Deliveries',
expression: {
id: 'on_time_order_id',
aggregation: 'countd',
},
format: 'integerFormat',
formatOptions: {
format: '#,##0,K',
},
},
{
id: 'on_time_rate',
isMeasure: true,
name: 'On-time Rate',
expression: {
operator: 'divide',
inputs: [{ id: 'on_time_deliveries' }, { id: 'delivered_orders' }],
},
format: 'percentageFormat',
formatOptions: { format: '#,##0.0%' },
},
{
id: 'avg_ship_to_delivery_days',
isMeasure: true,
name: 'Avg Ship to Delivery Days',
expression: {
id: 'ship_to_delivery_days',
aggregation: 'avg',
},
format: 'decimalFormat',
formatOptions: { format: '#,##0.0' },
},
];
// =============================================================================
// Data Source Definition
// =============================================================================
export function getMainDemoData(baseUrl: string): AgDataSourcesDefinition {
const url = `${baseUrl}/main-demo`;
return {
sources: [
{
id: 'stores',
name: 'Stores',
dataShape: 'row',
tables: [{ id: 'stores', name: 'Stores', fields: storesFields }],
getData: async () => ({ data: await getStores(url) }),
},
{
id: 'products',
name: 'Products',
dataShape: 'row',
tables: [{ id: 'products', name: 'Products', fields: productsFields }],
getData: async () => ({ data: await getProducts(url) }),
},
{
id: 'customers',
name: 'Customers',
dataShape: 'row',
tables: [{ id: 'customers', name: 'Customers', fields: customersFields }],
getData: async () => ({ data: await getCustomers(url) }),
},
{
id: 'orders',
name: 'Orders',
dataShape: 'row',
tables: [{ id: 'orders', name: 'Orders', fields: ordersFields }],
getData: async () => ({ data: await getOrders(url) }),
},
{
id: 'order_items',
name: 'Order Items',
dataShape: 'row',
tables: [{ id: 'order_items', name: 'Order Items', fields: orderItemsFields }],
getData: async () => ({ data: await getOrderItems(url) }),
},
{
id: 'shipments',
name: 'Shipments',
dataShape: 'row',
tables: [{ id: 'shipments', name: 'Shipments', fields: shipmentsFields }],
getData: async () => ({ data: await getShipments(url) }),
},
],
relationships: [
{
id: 'orders-customers',
source: { tableId: 'orders', fieldId: 'customer_id' },
target: { tableId: 'customers', fieldId: 'customer_id' },
type: 'many-to-one',
},
{
id: 'orders-stores',
source: { tableId: 'orders', fieldId: 'store_id' },
target: { tableId: 'stores', fieldId: 'store_id' },
type: 'many-to-one',
},
{
id: 'order_items-orders',
source: { tableId: 'order_items', fieldId: 'order_id' },
target: { tableId: 'orders', fieldId: 'order_id' },
type: 'many-to-one',
},
{
id: 'order_items-products',
source: { tableId: 'order_items', fieldId: 'product_id' },
target: { tableId: 'products', fieldId: 'product_id' },
type: 'many-to-one',
// line_cogs/line_margin multiply order_items.quantity (many-side, varies per row) by
// products.unit_cost (one-side) before summing - grain-invariant-safe, not a real fan-out.
acceptFanout: true,
},
{
id: 'shipments-orders',
source: { tableId: 'shipments', fieldId: 'order_id' },
target: { tableId: 'orders', fieldId: 'order_id' },
type: 'many-to-one',
},
],
expressions,
};
}
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Static Content Copy Link
Static Content Widgets add narrative or supporting material to a report and do not require a data input.
Text displays rich text content for titles, descriptions, or annotations, written by the report author rather than bound to a data field.
Image displays an image from a URL, for logos, backgrounds, or other visual context, and is likewise not bound to a data field.
The example below shows a Text Widget and an Image Widget placed side by side, neither of them mapped to a field.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
text: {
type: "text",
dataMapping: {},
format: {
title: {
enabled: true,
text: "Text",
},
style: {
text: "The Text Widget shows static content such as a heading, a note or an explanation. It is not bound to a data field.",
},
},
},
image: {
type: "image",
dataMapping: {},
format: {
title: {
enabled: true,
text: "Image",
},
style: {
src: "https://www.ag-grid.com/studio/images/ag-studio-logo.svg",
objectFit: "contain",
},
},
},
},
widgetLayout: {
text: {
xTrack: 0,
yTrack: 0,
xSpan: 12,
ySpan: 16,
},
image: {
xTrack: 12,
yTrack: 0,
xSpan: 12,
ySpan: 16,
},
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Filter Copy Link
Filter Widgets give report viewers on-canvas controls for narrowing down the data, without opening the Filters panel.
Button Filter shows clickable toggle buttons for quick single or multi-value filtering. It should be used when the list of values is short enough to display as buttons.
List Filter shows a scrollable list for selecting one or more values from many options. It should be used when there are too many values to show as buttons.
Date Filter shows a date picker for filtering by date range or specific dates.
The example below shows all three Filter Widgets alongside a KPI Widget; selecting a value in any Filter updates the KPI, since it is driven by the combined filter selection.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"button-filter": {
type: "button-filter",
dataMapping: {
value: [{ id: "medals.country" }],
},
format: {
title: {
enabled: true,
text: "Button Filter",
},
},
},
"list-filter": {
type: "list-filter",
dataMapping: {
value: [{ id: "medals.sport" }],
},
format: {
title: {
enabled: true,
text: "List Filter",
},
},
},
"date-filter": {
type: "date-filter",
dataMapping: {
value: [{ id: "medals.date" }],
},
format: {
title: {
enabled: true,
text: "Date Filter",
},
},
},
value: {
type: "value",
dataMapping: {
value: [{ id: "medals.gold", aggregation: "sum" }],
},
format: {
title: {
enabled: true,
text: "Total Gold Medals",
},
},
},
},
widgetLayout: {
"button-filter": {
xTrack: 0,
yTrack: 0,
xSpan: 8,
ySpan: 16,
},
"list-filter": {
xTrack: 8,
yTrack: 0,
xSpan: 8,
ySpan: 16,
},
"date-filter": {
xTrack: 16,
yTrack: 0,
xSpan: 8,
ySpan: 16,
},
value: {
xTrack: 0,
yTrack: 16,
xSpan: 24,
ySpan: 8,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Combo Copy Link
Combo Chart Widgets pair columns with a line series on the same chart, useful when a report needs to show a trend alongside the values that make it up.
Grouped Column / Line Chart shows adjacent columns for direct comparison across categories, with a line overlaid to trace a trend across the same categories.
Stacked Column / Line Chart shows stacked columns for part-to-whole comparison across categories, with a line overlaid to trace a trend across the same categories.
The columns of either variant can also be split by a Legend field, giving one column series per value in that field while the line stays a single trend across all of them.
The example below shows both Combo Chart variants plotting the same columns and trend line, so the grouped and stacked layouts can be compared.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState = {
pages: [
{
id: "page1",
widgets: {
"combo-chart-grouped-column-line": {
type: "combo-chart-grouped-column-line",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
secondaryValueKey: [{ id: "medals.total", aggregation: "sum" }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Grouped Column / Line Chart",
},
},
},
"combo-chart-stacked-column-line": {
type: "combo-chart-stacked-column-line",
dataMapping: {
categoryKey: [{ id: "medals.country" }],
valueKey: [
{ id: "medals.gold", aggregation: "sum" },
{ id: "medals.silver", aggregation: "sum" },
{ id: "medals.bronze", aggregation: "sum" },
],
secondaryValueKey: [{ id: "medals.total", aggregation: "sum" }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: "Stacked Column / Line Chart",
},
},
},
},
widgetLayout: {
"combo-chart-grouped-column-line": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 16,
},
"combo-chart-stacked-column-line": {
xTrack: 0,
yTrack: 16,
xSpan: 24,
ySpan: 16,
},
},
filter: {
page: [
{
field: { id: "medals.total" },
model: {
operator: "greaterThan",
value: 3,
},
},
],
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const studioProperties: AgStudioProperties = {
mode: "edit",
initialState,
};
let studioApi: AgStudioApi;
// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
fetch("https://www.ag-grid.com/studio/archive/3.0.0/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>