Consider using the Vega Chart widget if the Chart XY widget does not enable desired functionality or formatting.
The Chart XY widget is used to visualize Objects as interactive charts. Module builders configuring a Chart XY widget can:
The below screenshot shows an example of three configured Chart XY widgets displaying Flight Alerts data:

In the image below, to the left of the blue arrow you can see a newly added (but not yet configured) Chart XY widget, alongside its initial configuration panel. To the right of the blue arrow in the image below, you can see an individual Layer configuration panel with the backing object set of Flight Alerts already populated:

Configuring a Layer is required to add data to the Chart XY widget. The following configuration options are available for a Layer:
Some Chart XY configuration options are available at runtime through the chart toolbar, rather than in the widget configuration panel.
When a chart layer uses a date or timestamp property as the X axis property with basic rather than function-backed aggregation, a cog (settings) button appears in the chart toolbar at runtime. The button appears only under these conditions. Selecting this icon opens a bucket size selector that allows you to configure how timestamp values are grouped—for example, by day, week, or month.
In addition to the configuration options for a layer described above, the main Chart XY configuration panel contains a number of chart-wide configuration options:
Show title
Enable numerical formatting
Sort by
Configuring a function-backed layer requires writing a function that returns either a TwoDimensionalAggregation or ThreeDimensionalAggregation.
The code examples in this section are available in TypeScript v1, TypeScript v2, and Python. Select the tab that matches your function version. The two worked examples that follow are shown in TypeScript only; Python authors should follow Create a custom aggregation with Python functions. TypeScript v1 defines each function as a method on an exported class, annotated with the @Function() decorator from @foundry/functions-api. TypeScript v2 defines each function as the default export of a file and imports types from @osdk/functions. For a full comparison, review the TypeScript v1 versus TypeScript v2 comparison.
Both aggregation types exist in every function version with the following type parameters: TwoDimensionalAggregation<Key, Value> and ThreeDimensionalAggregation<Key, Segment, Value>. However, TypeScript v1 wraps the buckets in an object under a buckets key while TypeScript v2 returns the bare array of buckets. For a three-dimensional aggregation, TypeScript v2 also holds each inner bucket array under a groups key rather than a value key. Python keeps the wrapper and builds it from the SingleBucket and NestedBucket classes in functions.api. These differences apply only to the literal you construct in the function body: every version serializes a given declared return type to the same output, so the widget needs no change after a migration. A leftover TypeScript v1 { buckets: ... } literal fails inside the function, because it does not satisfy TypeScript v2's array-typed ThreeDimensionalAggregation.
The following minimal function returns the same two-dimensional aggregation in each version:
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13import { Double, Function, TwoDimensionalAggregation } from "@foundry/functions-api"; export class MyFunctions { @Function() public myTwoDimensionalAggregation(): TwoDimensionalAggregation<string, Double> { return { buckets: [ { key: "bucket1", value: 5.0 }, { key: "bucket2", value: 6.0 }, ], }; } }
Copied!1 2 3 4 5 6 7 8 9 10import { Double, TwoDimensionalAggregation } from "@osdk/functions"; function myTwoDimensionalAggregationFunction(): TwoDimensionalAggregation<string, Double> { return [ { key: "bucket1", value: 5.0 }, { key: "bucket2", value: 6.0 }, ]; } export default myTwoDimensionalAggregationFunction;
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15from functions.api import ( function, Double, TwoDimensionalAggregation, SingleBucket ) @function def my_two_dimensional_aggregation_function() -> TwoDimensionalAggregation[str, Double]: return TwoDimensionalAggregation( buckets=[ SingleBucket(key="bucket1", value=Double(5.0)), SingleBucket(key="bucket2", value=Double(6.0)), ] )
A three-dimensional aggregation nests a second list of buckets inside each top-level bucket. TypeScript v1 wraps the top-level buckets under a buckets key and holds the nested list under value. TypeScript v2 changes both levels: it returns a bare array, and each top-level entry holds the nested list under groups, leaving only the innermost level keyed value. In Python, each top-level bucket is a NestedBucket whose buckets argument holds the inner SingleBucket list. See the aggregation types reference for the full shape of each type.
In TypeScript v1, the result of a grouped aggregation is itself a TwoDimensionalAggregation, so .groupBy(...).sum(...) can be returned directly. This is not true in TypeScript v2: .aggregate() returns a flat array of rows, one row per group, and no helper converts those rows into a TwoDimensionalAggregation. Annotating an .aggregate() call with the aggregation type is a type error, so the function must build the return value from the rows itself. Read each group value from row.$group.<groupByKey> and the metric from row.<propertyApiName>.sum, which inherits the property's nullability and can therefore be undefined.
Three further differences shape the TypeScript v2 example below:
IRange<Timestamp>, which carries min and max; the TypeScript v2 equivalent is Range<TimestampISOString>, which carries startValue and endValue. Do not confuse the Range function type with the $ranges group-by input, which is an array of [start, end] tuples.$duration group value is a single scalar marking the start of its bucket rather than a range, so the example derives the end of each one-day bucket itself. The ?? 0 default on each metric is necessary, because a metric is absent when the property it aggregates has no value."unordered" value in each $select entry means the result carries no defined row order, and two separate .aggregate() calls are not guaranteed to return their groups in the same order. The example therefore joins the numerator and denominator results on their group values rather than by position.The TypeScript v2 function has no class, so TypeScript v1's private divide method becomes a module-level function in the same file for TypeScript v2.
Below is a full example that returns a TwoDimensionalAggregation to chart one time series divided by another time series:
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33import { Double, Function, TwoDimensionalAggregation, ThreeDimensionalAggregation, IRange, Timestamp } from "@foundry/functions-api"; import { ObjectSet, MyObjectType } from "@foundry/ontology-api" export class TimeseriesAggregations { @Function() public async percentOfTotal(objects:ObjectSet<MyObjectType>): Promise<TwoDimensionalAggregation<IRange<Timestamp>, Double>> { const numerators = await objects.groupBy(e => e.date.byDays()) .sum(e => e.value); const denominators = await objects.groupBy(e => e.date.byDays()) .sum(e => e.total); return this.divide(numerators, denominators); } private divide(numerators:TwoDimensionalAggregation<IRange<Timestamp>, Double>, denominators: TwoDimensionalAggregation<IRange<Timestamp>, Double>): TwoDimensionalAggregation<IRange<Timestamp>, Double> { const percentage = numerators.buckets.map((bucket, i) => { const numerator = bucket.value; const denominator = denominators.buckets[i].value; if (denominator == 0) { return { key: bucket.key, value: 0 }; } return { key: bucket.key, value: numerator / denominator } }); return { buckets: percentage }; } }
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57import { ObjectSet } from "@osdk/client"; import { Double, Range, TimestampISOString, TwoDimensionalAggregation } from "@osdk/functions"; import { MyObjectType } from "@ontology/sdk"; const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; // A $duration group value marks only the start of its bucket, so the end of the // one-day bucket has to be derived before it can be used as a Range key. function toDayRange(start: Date): Range<TimestampISOString> { const end = new Date(start.getTime() + ONE_DAY_IN_MS); return { startValue: start.toISOString(), endValue: end.toISOString() }; } function divide( numerators: TwoDimensionalAggregation<Range<TimestampISOString>, Double>, denominators: Map<string, Double> ): TwoDimensionalAggregation<Range<TimestampISOString>, Double> { return numerators.map(bucket => { const numerator = bucket.value; const denominator = denominators.get(bucket.key.startValue!) ?? 0; if (denominator === 0) { return { key: bucket.key, value: 0 }; } return { key: bucket.key, value: numerator / denominator }; }); } async function percentOfTotal( objects: ObjectSet<MyObjectType> ): Promise<TwoDimensionalAggregation<Range<TimestampISOString>, Double>> { const numeratorRows = await objects.aggregate({ $select: { "value:sum": "unordered" }, $groupBy: { date: { $duration: [1, "days"] } }, }); const denominatorRows = await objects.aggregate({ $select: { "total:sum": "unordered" }, $groupBy: { date: { $duration: [1, "days"] } }, }); const numerators = numeratorRows .filter(row => row.$group.date != null) .map(row => ({ key: toDayRange(new Date(row.$group.date!)), value: row.value.sum ?? 0, })); // Keyed by bucket start rather than by row position, because the two // aggregations are unordered and may return their groups in different orders. const denominators = new Map<string, Double>( denominatorRows .filter(row => row.$group.date != null) .map(row => [new Date(row.$group.date!).toISOString(), row.total.sum ?? 0]), ); return divide(numerators, denominators); } export default percentOfTotal;
TypeScript v1 produces a second aggregation dimension with .segmentBy(). TypeScript v2 has no separate segment clause; the closest available construct is a second key in the same $groupBy object, which is what the example below uses. A multi-key $groupBy groups on both keys at once rather than nesting a segment inside each date bucket. Once $groupBy holds more than one key, ordering is no longer allowed and every $select value must be "unordered". The result remains a flat array of rows, one row per combination of group values. The TypeScript v2 example nests the rows under their date bucket before returning them and joins the numerator and denominator results on their group values rather than by position.
TypeScript v1 .topValues() converts to the TypeScript v2 "exact" strategy, with a higher default bucket count: 1,000 in TypeScript v1, 10,000 in TypeScript v2. A grouping stays accurate only while the property's distinct values fit inside that count; above it, objects are excluded from the returned groups and the aggregation becomes approximate. The platform reports whether each result was accurate or approximate, but the TypeScript OSDK does not expose that field. For a high-cardinality property, raise the count with { $exactWithLimit: n }.
Below is a full example that returns a ThreeDimensionalAggregation which will chart a separate series for each value returned by segmentBy():
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34import { Double, Function, TwoDimensionalAggregation, ThreeDimensionalAggregation, IRange, Timestamp } from "@foundry/functions-api"; import { ObjectSet, MyObjectType } from "@foundry/ontology-api" export class TimeseriesAggregations { @Function() public async percentOfTotalSegmented(objects:ObjectSet<MyObjectType>): Promise<ThreeDimensionalAggregation<IRange<Timestamp>, string, Double>> { const numerators = await objects.groupBy(e => e.date.byDays()) .segmentBy(e => e.groupId.topValues()) .sum(e => e.value); const denominators = await objects.groupBy(e => e.date.byDays()) .segmentBy(e => e.groupId.topValues()) .sum(e => e.total); return this.divideThreeDimensional(numerators, denominators); } private divideThreeDimensional(numerators:ThreeDimensionalAggregation<IRange<Timestamp>, string, Double>, denominators: ThreeDimensionalAggregation<IRange<Timestamp>, string, Double>): ThreeDimensionalAggregation<IRange<Timestamp>, string, Double> { var percentage = numerators.buckets; //copy for (let i = 0; i < numerators.buckets.length; i++) { for (let j = 0; j < numerators.buckets[i].value.length; j++) { percentage[i].value[j].value = numerators.buckets[i].value[j].value / denominators.buckets[i].value[j].value; } } return { buckets: percentage }; } }
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88import { ObjectSet } from "@osdk/client"; import { Double, Range, ThreeDimensionalAggregation, TimestampISOString } from "@osdk/functions"; import { MyObjectType } from "@ontology/sdk"; const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; // A $duration group value marks only the start of its bucket, so the end of the // one-day bucket has to be derived before it can be used as a Range key. function toDayRange(start: Date): Range<TimestampISOString> { const end = new Date(start.getTime() + ONE_DAY_IN_MS); return { startValue: start.toISOString(), endValue: end.toISOString() }; } // aggregate() returns one flat row per date and segment pair, so the rows have // to be regrouped before they match the nested three-dimensional shape. function nestSegmentsByDate( rows: Array<{ date: Date; segment: string; value: Double }> ): ThreeDimensionalAggregation<Range<TimestampISOString>, string, Double> { const byDate = new Map<number, Array<{ key: string; value: Double }>>(); for (const row of rows) { const segments = byDate.get(row.date.getTime()) ?? []; segments.push({ key: row.segment, value: row.value }); byDate.set(row.date.getTime(), segments); } return Array.from(byDate, ([start, groups]) => ({ key: toDayRange(new Date(start)), groups, })); } function divideThreeDimensional( numerators: ThreeDimensionalAggregation<Range<TimestampISOString>, string, Double>, denominators: Map<string, Double> ): ThreeDimensionalAggregation<Range<TimestampISOString>, string, Double> { return numerators.map(bucket => ({ key: bucket.key, groups: bucket.groups.map(segment => { const denominator = denominators.get(`${bucket.key.startValue}|${segment.key}`) ?? 0; return { key: segment.key, value: denominator === 0 ? 0 : segment.value / denominator, }; }), })); } async function percentOfTotalSegmented( objects: ObjectSet<MyObjectType> ): Promise<ThreeDimensionalAggregation<Range<TimestampISOString>, string, Double>> { const numeratorRows = await objects.aggregate({ $select: { "value:sum": "unordered" }, $groupBy: { date: { $duration: [1, "days"] }, groupId: "exact", }, }); const denominatorRows = await objects.aggregate({ $select: { "total:sum": "unordered" }, $groupBy: { date: { $duration: [1, "days"] }, groupId: "exact", }, }); const numerators = nestSegmentsByDate( numeratorRows .filter(row => row.$group.date != null) .map(row => ({ date: new Date(row.$group.date!), segment: row.$group.groupId, value: row.value.sum ?? 0, })), ); // Keyed by date and segment rather than by row position, because the two // aggregations are unordered and may return their groups in different orders. const denominators = new Map<string, Double>( denominatorRows .filter(row => row.$group.date != null) .map(row => [ `${new Date(row.$group.date!).toISOString()}|${row.$group.groupId}`, row.total.sum ?? 0, ]), ); return divideThreeDimensional(numerators, denominators); } export default percentOfTotalSegmented;
Python authors write the same charts with the TwoDimensionalAggregation and ThreeDimensionalAggregation classes from functions.api. The Python Ontology SDK also provides a bridge that TypeScript v2 does not, supported only when using v2 of the Python Ontology SDK. TwoDimensionalAggregation.from_osdk() and ThreeDimensionalAggregation.from_osdk(result, "date", "groupId") convert a grouped aggregation result into the bucket structure the widget expects. Both take the group-by property API names, which stay camelCase even where the surrounding Python identifiers are snake_case. See Create a custom aggregation with Python functions for a Python example.
For more TSv1 examples, see the Functions documentation on object set aggregations and creating custom aggregations.