Custom widgets add application-defined widgets to AG Studio, for cases the provided widgets do not cover.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
AgWidgetFormParams,
createStudio,
createWidgets,
enableStudioDevValidations,
} from "ag-studio";
import { CustomWidget } from "./customWidget.ts";
import { CustomDef, MyRegistry } from "./interfaces.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState<MyRegistry> = {
pages: [
{
id: "page1",
widgets: {
"1": {
type: "customWidget",
dataMapping: {
value: [
{
id: "medals.gold",
aggregation: "sum",
},
],
},
},
},
widgetLayout: {
"1": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 12,
},
},
selection: {
type: "widget",
id: "1",
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const widgets = createWidgets<MyRegistry>({
additionalTypes: [
{
id: "customWidget",
icon: {
url: "https://www.ag-grid.com/studio/images/brandmark.svg",
},
label: "Custom Widget",
dataMapping: {
value: {
type: "field",
supportedRoles: ["numeric"],
requires: { cardinality: "one" },
required: true,
},
},
form: (params: AgWidgetFormParams<CustomDef>) => {
const defaultForm = params.createDefaults({
dataMappingItems: [
{
key: "value",
label: "Value",
},
],
});
defaultForm.items[1].items.push({
type: "number",
id: "format.style.valueFontSize",
label: "Value Font Size",
defaultValue: 48,
});
return defaultForm;
},
comp: CustomWidget,
defaultSize: {
width: 400,
height: 300,
},
minSize: {
width: 200,
height: 100,
},
ai: {
description:
"Custom widget used for displaying values in an interesting way.",
},
},
],
menu: [
{
label: "Custom",
widgetIds: ["customWidget"],
},
],
});
const studioProperties: AgStudioProperties<MyRegistry> = {
mode: "edit",
initialState,
widgets,
};
let studioApi: AgStudioApi<MyRegistry>;
// 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/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
.custom-widget-container {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
}
.custom-widget-desc {
margin: 16px;
font-size: 24px;
align-content: center;
}
.custom-widget-value {
margin: 16px;
border: 2px solid var(--ag-border-color);
border-radius: var(--ag-border-radius);
text-align: center;
align-content: center;
font-weight: bold;
font-size: var(--custom-widget-value-font-size, 48px);
}
import type { AgBaseRegistry, AgBaseWidgetDefinition, AgWidgetDataFormat, AgWidgetFieldReference } from 'ag-studio';
interface CustomWidgetStyle {
valueFontSize?: number;
}
export interface CustomDef {
type: 'customWidget';
dataMapping: {
value: AgWidgetFieldReference[];
};
format?: AgWidgetDataFormat<CustomWidgetStyle>;
}
export interface MyRegistry extends AgBaseRegistry {
widgets: readonly AgBaseWidgetDefinition<'customWidget', CustomDef>[];
}
import type { AgTypeScriptComponent, AgWidgetParams } from 'ag-studio';
import type { CustomDef } from './interfaces.ts';
export class CustomWidget implements AgTypeScriptComponent<AgWidgetParams<CustomDef>> {
private eGui!: HTMLDivElement;
private eValue!: HTMLDivElement;
private hasLoaded = false;
init(params: AgWidgetParams<CustomDef>): Promise<void> {
const eGui = document.createElement('div');
this.eGui = eGui;
eGui.classList.add('custom-widget-container');
const eDesc = document.createElement('div');
eDesc.classList.add('custom-widget-desc');
eDesc.textContent = 'Custom widget';
const eValue = document.createElement('div');
eValue.classList.add('custom-widget-value');
eGui.appendChild(eDesc);
eGui.appendChild(eValue);
this.eValue = eValue;
return this.updateData(params);
}
refresh(params: AgWidgetParams<CustomDef>): Promise<void> {
return this.updateData(params);
}
private async updateData({ widgetApi, dataMapping, format }: AgWidgetParams<CustomDef>): Promise<void> {
const valueFontSize = format?.style?.valueFontSize;
this.eGui.style.setProperty('--custom-widget-value-font-size', valueFontSize ? `${valueFontSize}px` : null);
const field = dataMapping.value?.at(0);
if (!field) {
widgetApi.setDisplayState('incompleteDataMapping');
return;
}
widgetApi.setDisplayState('loading', { prominent: !this.hasLoaded });
const response = await widgetApi.getData({ fields: [field] });
const data = response.results.rows;
const value = data.at(0)?.[field.key];
const hasData = value != null;
this.hasLoaded = hasData;
this.eValue.textContent = widgetApi.formatFieldValue(field, value);
widgetApi.setDisplayState(hasData ? 'displayed' : 'noData');
}
getGui() {
return this.eGui;
}
}
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
The example above demonstrates a custom widget showing a single data value.
Creating a Custom Widget Copy Link
To provide a custom widget, implement the AgTypeScriptWidgetDefinition interface.
Custom component. Either: string that matches a custom component in the studio components property |
Unique widget identifier (e.g., 'grid', 'value', 'column-chart-grouped').
|
Display label, or localisation key.
|
Optional icon for widget display. One of: string - an SVG string { className: string } - A CSS class name { url: string } - A URL to an SVG |
Optional data mappings for the widget (if required). This defines the type of fields and how they are used by the widget.
|
Optional format shape. The shape created by this function will be used for parsing the format configuration. If provided, format state will be passed through the parse() method before being loaded into Studio. If using AI, the shape is required, as it uses the schema.
|
Form configuration using typed form builder.
|
Optional default state. If provided, this will override the default values in the form. It also allows defaults to be set for the data mapping and sort.
|
Optional default widget ID that this definition extends. Use this if overriding or pre-configuring one of the default widgets. This ensure that any values that no longer appear in the form are still correctly mapped / set to the right default value. For custom widgets, this should not be set.
|
Default widget size when created.
|
Minimum widget size constraints.
|
Optional toolbar configuration. Can be a built-in item ( 'delete' or 'duplicate') or a custom item. If the item provides an action, that will be performed, otherwise it will be dispatched as a 'toolbarAction' event to the widget. If providing a custom value, 'duplicate' and 'delete' must be provided if required. |
Optional feature configuration.
|
Optional options passed directly to widget.
|
Optional structured AI metadata for this widget type.
|
Custom widgets are provided to the widgets property, similar to the Available Widgets configuration.
Provide the custom widget definitions to the createWidgets(params) helper function, and add them to the menu.
const studioProperties = {
widgets: (widgetConfig) => createWidgets<CustomRegistry>({
additionalTypes: [customWidgetDefinition],
menu: [
...widgetConfig.menu,
{
label: 'Custom',
widgetIds: ['customWidget'],
},
]
}),
// other studio properties ...
}For the types to work correctly, the custom widgets should be defined in the Registry Type.
interface CustomRegistry extends AgBaseRegistry {
widgets: readonly (AgDefaultWidgetDefinition | CustomWidgetDefinition)[]
} Form Copy Link
The widget form configures the form displayed in the edit panel. The values from the form are passed in the widget params.
See the Form Configuration page for more details.
AI Integration Copy Link
For a custom widget to work with AI, the formatShape and ai properties must be defined in the widget definition.
The shape returned by formatShape is used to provide the AI with the schema for the format property of the widget, and to validate the format value the AI sets via state.
Data Mapping Copy Link
The dataMapping property defines the fields or fieldsets that are required to configure the widget, and the required relationships between them.
For example, to configure a line chart, the data mapping might look like this:
const dataMapping = {
xAxisKey: {
type: 'field', // Only a single field allowed
// All types of value allowed
supportedRoles: ['category', 'numeric', 'temporal'],
requires: { cardinality: 'many' }, // Many values are accepted
required: true, // Required field - widget cannot be displayed without it
sort: true, // Show the sort menu
aiDescription: 'Field for the x axis.', // Description when used with AI
},
yAxisKey: {
type: 'fieldset', // Multiple fields allowed
supportedRoles: ['numeric'], // Only numeric values allowed
// For each `xAxisKey`, this must map to a single value
requires: { per: 'dataMapping.xAxisKey', cardinality: 'one' },
required: true, // Required field - widget cannot be displayed without it
sort: true, // Show the sort menu
// Description when used with AI
aiDescription: 'Field(s) for the y axis. Each field becomes a separate series.',
},
};A widget does not have to rely on a data mapping to choose its fields. See Reading the Report Schema.
Widget Component Copy Link
The custom component is a class that implements the AgTypeScriptComponent interface.
Return the DOM element of your component, this is what Studio puts into the DOM
|
Gets called once by Studio when the component is being removed; if your component needs to do any cleanup, do it here
|
Called once on init.
|
Called for every subsequent params update.
|
The init and refresh methods receive params of type AgWidgetParams.
Custom Widget Params Copy Link
Studio API.
|
Application context as set on context Studio property.
|
Widget ID.
|
Widget type.
|
Widget format as constructed from the widget form.
|
Widget data mapping values.
|
Widget sort if defined.
|
Widget configuration.
|
Widget API. Provides access to retrieve data.
|
Display State Copy Link
Widgets have four different display states that can be set via widgetApi.setDisplayState(state, metadata?). These will trigger different overlays to be displayed by the layout on top of the widget. The states are:
displayed- The widget has data and is ready to display. Studio will show no overlay; the widget is rendered normally.loading- The widget is loading. Metadata defaults to{ prominent: true }for a solid loading overlay; use{ prominent: false }for an unobtrusive refresh indicator that keeps the previous content visible.noData- The widget has no data (e.g. everything is filtered out or the data is empty). Studio will show an overlay with "No data to display".incompleteDataMapping- The widget does not have all of the required fields set. Studio will show an overlay with the field selection inputs.
Each time the widget updates, set the relevant status as needed.
widgetApi.setDisplayState('loading');
const response = await widgetApi.getData(request);
// ... process the response
widgetApi.setDisplayState('displayed'); Loading Data Copy Link
Data is loaded via widgetApi.getData(request). The request can be constructed from the data mapping values in the params.
Flat row query. Omit for backwards compatibility.
|
List of fields to return in the query. Unaggregated fields will automatically be used for grouping.
|
Sort the result based on the provided fields.
|
Additional filter to apply. Page-level filters and widget-level filters (including from filter widgets and cross filters) will be automatically applied.
|
Limit the number of rows returned, or for pagination.
|
If the widget is making multiple independent data requests, then pass a second options argument to getData(request, options) where options contains a distinct query ID per request (e.g. { queryId: 'request1' }). Otherwise the later requests will cancel the earlier requests. If your widget only makes one data request (per call to refresh), this is not required.
Reading the Report Schema Copy Link
A widget does not have to rely on a data mapping to choose its fields. Call getSchema() on the widget API to list every table and field in the report's schema. Tables, and the fields within each table, come back in the order the data panel lists them.
The example below defines a widget with no data mapping. It reads the schema, then shows a grid with one column per field.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
createStudio,
createWidgets,
enableStudioDevValidations,
} from "ag-studio";
import { CustomWidget } from "./customWidget.ts";
import { MyRegistry } from "./interfaces.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState<MyRegistry> = {
pages: [
{
id: "page1",
widgets: {
"1": {
type: "schemaWidget",
dataMapping: {},
},
},
widgetLayout: {
"1": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 24,
},
},
selection: {
type: "widget",
id: "1",
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
const widgets = createWidgets<MyRegistry>({
additionalTypes: [
{
id: "schemaWidget",
icon: {
url: "https://www.ag-grid.com/studio/images/brandmark.svg",
},
label: "Schema Widget",
form: (params) =>
params.createDefaults({
dataMappingItems: [],
}),
comp: CustomWidget,
defaultSize: {
width: 800,
height: 400,
},
minSize: {
width: 400,
height: 200,
},
ai: {
description:
"Shows every field in the report schema, with data for each one, in a grid.",
},
},
],
menu: [
{
label: "Custom",
widgetIds: ["schemaWidget"],
},
],
});
const studioProperties: AgStudioProperties<MyRegistry> = {
mode: "edit",
initialState,
widgets,
};
let studioApi: AgStudioApi<MyRegistry>;
// 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/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
.schema-widget-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
}
.schema-widget-grid {
/* `ag-grid-angular` and `ag-grid-vue` are custom elements, so they need an explicit box for
the flex sizing below to resolve against. */
display: block;
flex: 1;
min-height: 0;
}
import type { AgBaseRegistry, AgBaseWidgetDefinition } from 'ag-studio';
export interface SchemaDef {
type: 'schemaWidget';
dataMapping: Record<string, never>;
}
export interface MyRegistry extends AgBaseRegistry {
widgets: readonly AgBaseWidgetDefinition<'schemaWidget', SchemaDef>[];
}
import type { AgTypeScriptComponent, AgWidgetParams } from 'ag-studio';
import { studioGridTheme } from 'ag-studio';
import type { ColDef, GridApi, GridOptions } from 'ag-grid-community';
import { AllCommunityModule, ModuleRegistry, createGrid } from 'ag-grid-community';
import type { SchemaDef } from './interfaces.ts';
ModuleRegistry.registerModules([AllCommunityModule]);
const ROW_LIMIT = 100;
export class CustomWidget implements AgTypeScriptComponent<AgWidgetParams<SchemaDef>> {
private eGui!: HTMLDivElement;
private grid: GridApi | undefined;
private hasLoaded = false;
init(params: AgWidgetParams<SchemaDef>): Promise<void> {
this.eGui = document.createElement('div');
this.eGui.classList.add('schema-widget-container');
const eGridContainer = document.createElement('div');
eGridContainer.classList.add('schema-widget-grid');
this.eGui.appendChild(eGridContainer);
const gridOptions: GridOptions = {
columnDefs: [],
rowData: [],
theme: studioGridTheme,
defaultColDef: { sortable: true, resizable: false, suppressMovable: true },
// Result field keys are dotted strings (e.g. `medals.athlete`) - without this,
// AG Grid treats the dot as a nested-path accessor rather than a literal key.
suppressFieldDotNotation: true,
rowHeight: 24,
};
this.grid = createGrid(eGridContainer, gridOptions);
return this.updateData(params);
}
refresh(params: AgWidgetParams<SchemaDef>): Promise<void> {
return this.updateData(params);
}
getGui(): HTMLElement {
return this.eGui;
}
destroy(): void {
this.grid?.destroy();
}
private async updateData({ widgetApi }: AgWidgetParams<SchemaDef>): Promise<void> {
const fields = widgetApi.getSchema().flatMap((table) => table.fields);
if (fields.length === 0) {
widgetApi.setDisplayState('noData');
return;
}
widgetApi.setDisplayState('loading', { prominent: !this.hasLoaded });
const response = await widgetApi.getData({ fields, limit: { count: ROW_LIMIT } });
const rows = response.results.rows;
this.hasLoaded = rows.length > 0;
const columnDefs: ColDef[] = fields.map((field) => ({
field: field.key,
headerName: widgetApi.getFieldName(field),
}));
this.grid?.setGridOption('columnDefs', columnDefs);
this.grid?.setGridOption('rowData', rows);
widgetApi.setDisplayState(rows.length > 0 ? 'displayed' : 'noData');
}
}
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
Popups Copy Link
If a custom widget creates its own popup that is anchored outside of the custom widget DOM element (e.g. like a third-party date picker), then the popup element needs to have the 'ag-custom-component-popup' CSS class. This allows Studio to determine correctly when focus is within a widget.
Cross-Filtering Copy Link
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
AgWidgetFormParams,
AgWidgetsConfig,
createStudio,
createWidgets,
enableStudioDevValidations,
} from "ag-studio";
import { CustomWidget } from "./customWidget.ts";
import { CustomDef, MyRegistry } from "./interfaces.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const initialState: AgReportState<MyRegistry> = {
pages: [
{
id: "page1",
widgets: {
"1": {
type: "customWidget",
dataMapping: {
category: [
{
id: "medals.country",
},
],
value: [
{
id: "medals.gold",
aggregation: "sum",
},
],
},
},
"2": {
type: "grid",
dataMapping: {
cols: [
{
id: "medals.country",
},
{
id: "medals.athlete",
},
{
id: "medals.gold",
aggregation: "sum",
},
],
},
},
"3": {
type: "column-chart-grouped",
dataMapping: {
categoryKey: [
{
id: "medals.year",
},
],
valueKey: [
{
id: "medals.gold",
aggregation: "sum",
},
],
},
},
},
widgetLayout: {
"1": {
xTrack: 0,
yTrack: 0,
xSpan: 12,
ySpan: 36,
},
"2": {
xTrack: 12,
yTrack: 18,
xSpan: 12,
ySpan: 18,
},
"3": {
xTrack: 12,
yTrack: 0,
xSpan: 12,
ySpan: 18,
},
},
},
],
selectedPageId: "page1",
panels: {
filters: {
collapsed: true,
},
},
};
const widgets = (widgetConfig: AgWidgetsConfig<MyRegistry>) =>
createWidgets<MyRegistry>({
additionalTypes: [
{
id: "customWidget",
icon: {
url: "https://www.ag-grid.com/studio/images/brandmark.svg",
},
label: "Custom Widget",
dataMapping: {
category: {
type: "field",
supportedRoles: ["category"],
requires: { cardinality: "many" },
required: true,
},
value: {
type: "field",
supportedRoles: ["numeric"],
requires: { per: "dataMapping.category", cardinality: "one" },
required: true,
},
},
form: (params: AgWidgetFormParams<CustomDef>) => {
return params.createDefaults({
dataMappingItems: [
{
key: "category",
label: "Category",
},
{
key: "value",
label: "Value",
},
],
supportsCrossHighlight: true,
});
},
comp: CustomWidget,
defaultSize: {
width: 400,
height: 300,
},
minSize: {
width: 200,
height: 100,
},
featureConfig: {
crossFilter: {
supportsHighlight: true,
},
},
},
],
menu: [
{
label: "Custom",
widgetIds: ["customWidget"],
},
...widgetConfig.menu,
],
});
const studioProperties: AgStudioProperties<MyRegistry> = {
mode: "view",
initialState,
widgets,
};
let studioApi: AgStudioApi<MyRegistry>;
// 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/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data) =>
studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
);
.custom-widget-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
padding: 16px;
overflow: auto;
gap: 8px;
}
.custom-widget-row {
display: flex;
flex-direction: row;
width: 100%;
justify-content: space-between;
background-color: transparent;
border: 1px solid var(--ag-border-color);
&:hover {
background-color: var(--ag-accent-color);
}
> div {
height: 32px;
align-content: center;
}
}
.custom-widget-cross-filtered {
background-color: var(--ag-accent-color);
}
import type {
AgBaseRegistry,
AgBaseWidgetDefinition,
AgDefaultWidgetDefinition,
AgWidgetFieldReference,
} from 'ag-studio';
export interface CustomDef {
type: 'customWidget';
dataMapping: {
category: AgWidgetFieldReference[];
value: AgWidgetFieldReference[];
};
}
export interface MyRegistry extends AgBaseRegistry {
widgets: readonly (AgDefaultWidgetDefinition | AgBaseWidgetDefinition<'customWidget', CustomDef>)[];
}
import type { AgTypeScriptComponent, AgWidgetParams } from 'ag-studio';
import type { CustomDef } from './interfaces.ts';
export class CustomWidget implements AgTypeScriptComponent<AgWidgetParams<CustomDef>> {
private eGui!: HTMLDivElement;
private hasLoaded = false;
init(params: AgWidgetParams<CustomDef>): Promise<void> {
const eGui = document.createElement('div');
this.eGui = eGui;
eGui.classList.add('custom-widget-container');
eGui.addEventListener('click', (e) => {
params.widgetApi.resetCrossFilter();
e.stopPropagation();
});
return this.updateData(params);
}
refresh(params: AgWidgetParams<CustomDef>): Promise<void> {
return this.updateData(params);
}
private async updateData({ widgetApi, dataMapping }: AgWidgetParams<CustomDef>): Promise<void> {
while (this.eGui.firstChild) {
this.eGui.firstChild.remove();
}
const categoryField = dataMapping.category?.at(0);
const valueField = dataMapping.value?.at(0);
if (!categoryField || !valueField) {
widgetApi.setDisplayState('incompleteDataMapping');
return;
}
widgetApi.setDisplayState('loading', { prominent: !this.hasLoaded });
const response = await widgetApi.getData({
fields: [categoryField, valueField],
sort: [{ field: categoryField, direction: 'asc' }],
});
const data = response.results.rows;
const hasData = data.length > 0;
this.hasLoaded = hasData;
const crossFilterSelections = widgetApi.getCrossFilterSelections();
const crossFilterCategories = new Set();
crossFilterSelections?.forEach((selection) => {
if (selection.type === 'value') {
selection.values.forEach((value) => {
crossFilterCategories.add(value);
});
}
});
const hasCrossFilter = !!response.crossFilter;
const crossFilterData = response.crossFilter?.rows ?? [];
const crossFilterDataMap = new Map();
for (const row of crossFilterData) {
crossFilterDataMap.set(row[categoryField.key], row[valueField.key]);
}
data.forEach((row) => {
const eRow = document.createElement('button');
eRow.classList.add('custom-widget-row');
const category = row[categoryField.key];
const value = row[valueField.key];
const eCategory = document.createElement('div');
eCategory.textContent = widgetApi.formatFieldValue(categoryField, category);
const eValue = document.createElement('div');
const crossFilterValue = crossFilterDataMap.get(category);
let valueFormatted = widgetApi.formatFieldValue(valueField, value);
if (hasCrossFilter && crossFilterValue !== value) {
valueFormatted = `${widgetApi.formatFieldValue(valueField, crossFilterValue ?? 0)} / ${valueFormatted}`;
}
eValue.textContent = valueFormatted;
eRow.appendChild(eCategory);
eRow.appendChild(eValue);
eRow.addEventListener('click', (e) => {
widgetApi.toggleCrossFilter({
type: 'value',
value: category,
field: categoryField,
group: 0,
reset: !(e.metaKey || e.ctrlKey),
});
e.stopPropagation();
});
if (crossFilterCategories.has(category)) {
eRow.classList.add('custom-widget-cross-filtered');
}
this.eGui.appendChild(eRow);
});
widgetApi.setDisplayState(hasData ? 'displayed' : 'noData');
}
getGui() {
return this.eGui;
}
}
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
To implement cross-filtering from within a custom widget, the cross filter methods can be used from the widget API.
Set a cross filter.
|
Clear cross filter.
|
Get the current cross filter for this widget.
|
Every selection belongs to a group, a zero-based number identifying which of the widget's selections is being set. A widget holds one selection per group, and selections in different groups are independent of each other. A widget that holds a single selection should pass group: 0; a widget that selects on several fields at once should give each field its own group.
A widget that selects on several fields together - a sankey link or a matrix cell, where one click means "this channel and this product" - should use a multi value selection instead of a group per field. Each toggleCrossFilter call with type: 'multiValue' contributes one combination of field and value pairs; the pairs within a combination must all match, and a row is included when it matches any one of the combinations the selection holds. Toggling the same set of pairs again removes that combination.
api.toggleCrossFilter({
type: 'multiValue',
values: [
{ field: channelField, value: 'Online' },
{ field: productField, value: 'Clothing' },
],
group: 0,
});To support the cross filter highlight behaviour (similar to some of the default charts, e.g. column charts), enable it in the widget definition.
const widgetDefinition = {
// ...
featureConfig: {
crossFilter: {
supportsHighlight: true
}
}
};When this is enabled, the data response will contain two datasets. The original data (response.results), and the cross-filtered data (response.crossFilter).
AG Grid & AG Charts in Widgets Copy Link
As well as the built-in AG Grid and AG Charts widgets, it is possible to create your own custom widgets using AG Grid and AG Charts.
To match the AG Grid theming in Studio, use studioGridTheme and pass it to the theme grid option.
For AG Charts, set the following in chart options (where api is the Studio API in the widget params):
const chartOptions = {
// ... other options
theme: getChartTheme(api),
background: {
fill: 'transparent'
}
};Using AG Grid Enterprise or AG Charts Enterprise in a custom widget requires the relevant AG Grid Enterprise or AG Charts Enterprise licence.
Custom Widget Examples Copy Link
Sankey Chart Copy Link
A Sankey diagram visualises flow between two sets of nodes, sized by a numeric measure. This example maps a sales dataset (channel â product category) alongside a bar chart of the same revenue measure.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
AgWidgetFormParams,
AgWidgetsConfig,
createStudio,
createWidgets,
enableStudioDevValidations,
} from "ag-studio";
import { CustomWidget } from "./customWidget.ts";
import { SOURCE_ID, ordersData } from "./data.ts";
import { MyRegistry, SankeyDef } from "./interfaces.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const widgets = (widgetConfig: AgWidgetsConfig<MyRegistry>) =>
createWidgets<MyRegistry>({
additionalTypes: [
{
id: "sankey-widget",
label: "Sankey",
icon: { url: "https://www.ag-grid.com/studio/images/brandmark.svg" },
dataMapping: {
from: {
type: "field",
supportedRoles: ["category"],
requires: { cardinality: "one" },
required: true,
aiDescription: "Source nodes of the flow.",
},
to: {
type: "field",
supportedRoles: ["category"],
requires: { cardinality: "one" },
required: true,
aiDescription: "Target nodes of the flow.",
},
value: {
type: "field",
supportedRoles: ["numeric"],
requires: { cardinality: "one" },
required: true,
aiDescription: "Numeric measure that sizes each flow.",
},
},
form: (params: AgWidgetFormParams<SankeyDef>) =>
params.createDefaults({
dataMappingItems: [
{ key: "from", label: "From" },
{ key: "to", label: "To" },
{ key: "value", label: "Value" },
],
}),
comp: CustomWidget,
defaultSize: { width: 600, height: 400 },
minSize: { width: 300, height: 200 },
},
],
menu: [
{ label: "Custom", widgetIds: ["sankey-widget"] },
...widgetConfig.menu,
],
});
const initialState: AgReportState<MyRegistry> = {
panels: {
filters: { collapsed: true },
edit: { collapsed: true },
data: { collapsed: true },
},
pages: [
{
id: "page-1",
widgets: {
sankey: {
type: "sankey-widget",
dataMapping: {
from: [{ id: `${SOURCE_ID}.channel` }],
to: [{ id: `${SOURCE_ID}.product` }],
value: [{ id: `${SOURCE_ID}.revenue`, aggregation: "sum" }],
},
},
"revenue-by-channel": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: `${SOURCE_ID}.channel` }],
valueKey: [{ id: `${SOURCE_ID}.revenue`, aggregation: "sum" }],
},
format: {
title: { enabled: true, text: "Revenue by Channel" },
},
},
},
widgetLayout: {
sankey: { xTrack: 0, yTrack: 0, xSpan: 16, ySpan: 29 },
"revenue-by-channel": { xTrack: 16, yTrack: 0, xSpan: 8, ySpan: 29 },
},
},
],
selectedPageId: "page-1",
};
const studioProperties: AgStudioProperties<MyRegistry> = {
mode: "view",
initialState,
widgets,
data: ordersData,
};
let studioApi: AgStudioApi<MyRegistry>;
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
import type { AgDataSourcesDefinition } from 'ag-studio';
export const SOURCE_ID = 'orders';
export const ordersData: AgDataSourcesDefinition = {
sources: [
{
id: SOURCE_ID,
fields: [
{ id: 'channel', format: 'textFormat' },
{ id: 'product', format: 'textFormat' },
{ id: 'region', format: 'textFormat' },
{ id: 'revenue', format: 'decimalFormat' },
],
data: [
{ channel: 'Online', product: 'Electronics', region: 'North', revenue: 4000 },
{ channel: 'Online', product: 'Electronics', region: 'South', revenue: 4100 },
{ channel: 'Online', product: 'Clothing', region: 'North', revenue: 3200 },
{ channel: 'Online', product: 'Clothing', region: 'South', revenue: 3100 },
{ channel: 'Online', product: 'Furniture', region: 'North', revenue: 2050 },
{ channel: 'Online', product: 'Furniture', region: 'South', revenue: 1950 },
{ channel: 'Online', product: 'Garden', region: 'North', revenue: 1800 },
{ channel: 'Online', product: 'Garden', region: 'South', revenue: 2000 },
{ channel: 'Online', product: 'Sports', region: 'North', revenue: 1700 },
{ channel: 'Online', product: 'Sports', region: 'South', revenue: 1600 },
{ channel: 'In-Store', product: 'Electronics', region: 'North', revenue: 6600 },
{ channel: 'In-Store', product: 'Electronics', region: 'South', revenue: 7200 },
{ channel: 'In-Store', product: 'Clothing', region: 'North', revenue: 5700 },
{ channel: 'In-Store', product: 'Clothing', region: 'South', revenue: 6300 },
{ channel: 'In-Store', product: 'Furniture', region: 'North', revenue: 4300 },
{ channel: 'In-Store', product: 'Furniture', region: 'South', revenue: 4900 },
{ channel: 'In-Store', product: 'Garden', region: 'North', revenue: 2700 },
{ channel: 'In-Store', product: 'Garden', region: 'South', revenue: 2500 },
{ channel: 'In-Store', product: 'Sports', region: 'North', revenue: 2900 },
{ channel: 'In-Store', product: 'Sports', region: 'South', revenue: 3300 },
{ channel: 'Partner', product: 'Electronics', region: 'North', revenue: 3100 },
{ channel: 'Partner', product: 'Electronics', region: 'South', revenue: 2900 },
{ channel: 'Partner', product: 'Clothing', region: 'North', revenue: 1650 },
{ channel: 'Partner', product: 'Clothing', region: 'South', revenue: 1450 },
{ channel: 'Partner', product: 'Furniture', region: 'North', revenue: 2300 },
{ channel: 'Partner', product: 'Furniture', region: 'South', revenue: 2500 },
{ channel: 'Partner', product: 'Garden', region: 'North', revenue: 900 },
{ channel: 'Partner', product: 'Garden', region: 'South', revenue: 800 },
{ channel: 'Partner', product: 'Sports', region: 'North', revenue: 1250 },
{ channel: 'Partner', product: 'Sports', region: 'South', revenue: 1150 },
{ channel: 'Telesales', product: 'Electronics', region: 'North', revenue: 1450 },
{ channel: 'Telesales', product: 'Electronics', region: 'South', revenue: 1350 },
{ channel: 'Telesales', product: 'Clothing', region: 'North', revenue: 620 },
{ channel: 'Telesales', product: 'Clothing', region: 'South', revenue: 580 },
{ channel: 'Telesales', product: 'Furniture', region: 'North', revenue: 1050 },
{ channel: 'Telesales', product: 'Furniture', region: 'South', revenue: 1000 },
{ channel: 'Telesales', product: 'Garden', region: 'North', revenue: 330 },
{ channel: 'Telesales', product: 'Garden', region: 'South', revenue: 300 },
{ channel: 'Telesales', product: 'Sports', region: 'North', revenue: 530 },
{ channel: 'Telesales', product: 'Sports', region: 'South', revenue: 490 },
],
},
],
};
import type {
AgBaseRegistry,
AgBaseWidgetDefinition,
AgDefaultWidgetDefinition,
AgWidgetFieldReference,
} from 'ag-studio';
export interface SankeyDef {
type: 'sankey-widget';
dataMapping: {
from: AgWidgetFieldReference[];
to: AgWidgetFieldReference[];
value: AgWidgetFieldReference[];
};
}
export interface MyRegistry extends AgBaseRegistry {
widgets: readonly (AgDefaultWidgetDefinition | AgBaseWidgetDefinition<'sankey-widget', SankeyDef>)[];
}
import type { AgChartInstance, AgChartOptions } from 'ag-charts-enterprise';
import { AgCharts, ModuleRegistry, SankeySeriesModule } from 'ag-charts-enterprise';
import { getChartTheme } from 'ag-studio';
import type { AgTypeScriptComponent, AgWidgetParams } from 'ag-studio';
import type { SankeyDef } from './interfaces.ts';
ModuleRegistry.registerModules([SankeySeriesModule]);
export class CustomWidget implements AgTypeScriptComponent<AgWidgetParams<SankeyDef>> {
private eGui!: HTMLDivElement;
private chart: AgChartInstance | null = null;
private hasLoaded = false;
init(params: AgWidgetParams<SankeyDef>): Promise<void> {
this.eGui = document.createElement('div');
this.eGui.style.cssText = 'width:100%;height:100%';
return this.updateData(params);
}
refresh(params: AgWidgetParams<SankeyDef>): Promise<void> {
return this.updateData(params);
}
private async updateData(params: AgWidgetParams<SankeyDef>): Promise<void> {
const { api, widgetApi, dataMapping } = params;
const fromField = dataMapping.from?.at(0);
const toField = dataMapping.to?.at(0);
const valueField = dataMapping.value?.at(0);
if (!fromField || !toField || !valueField) {
widgetApi.setDisplayState('incompleteDataMapping');
return;
}
widgetApi.setDisplayState('loading', { prominent: !this.hasLoaded });
// Opts out of cross filtering, so the flow diagram covers the whole dataset.
const response = await widgetApi.getData({ fields: [fromField, toField, valueField] }, { crossFilter: 'none' });
const rows = response.results.rows;
if (!rows.length) {
widgetApi.setDisplayState('noData');
return;
}
const sankeyData = rows.map((row) => ({
from: widgetApi.formatFieldValue(fromField, row[fromField.key]),
to: widgetApi.formatFieldValue(toField, row[toField.key]),
size: row[valueField.key] as number,
}));
this.hasLoaded = true;
const options: AgChartOptions = {
container: this.eGui,
theme: getChartTheme(api),
background: { fill: 'transparent' },
data: sankeyData,
series: [
{
type: 'sankey',
fromKey: 'from',
toKey: 'to',
sizeKey: 'size',
sizeName: 'Revenue',
tooltip: {
renderer: ({ datum }: any) => {
if (datum == null) {
return '';
}
const label = datum.id ?? `${datum.from} â ${datum.to}`;
const size = widgetApi.formatFieldValue(valueField, datum.size);
return `<div style="padding:6px 10px"><div class="ag-charts-tooltip-title">${label}</div><div class="ag-charts-tooltip-content">${size}</div></div>`;
},
},
},
],
};
if (this.chart == null) {
this.chart = AgCharts.create(options) as unknown as AgChartInstance;
} else {
this.chart.update(options);
}
widgetApi.setDisplayState('displayed');
}
getGui(): HTMLDivElement {
return this.eGui;
}
destroy(): void {
this.chart?.destroy();
this.chart = null;
}
}
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
The widget takes no part in cross filtering: it requests its data with crossFilter: 'none', so the flow diagram always covers the whole dataset and its node set never reflows. For a custom widget that does cross filter, see the Custom Widget with Cross Filter example above.
Choropleth Map Copy Link
A choropleth map shades geographic regions by a numeric measure. This example renders UK county boundaries using AG Charts' map-shape series and supports click-to-cross-filter alongside a regional bar chart.
import {
AgReportState,
AgStudioApi,
AgStudioProperties,
AgWidgetFormParams,
AgWidgetsConfig,
createStudio,
createWidgets,
enableStudioDevValidations,
} from "ag-studio";
import { CustomWidget } from "./customWidget.ts";
import { SOURCE_ID, storeData } from "./data.ts";
import { MapDef, MyRegistry } from "./interfaces.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const widgets = (widgetConfig: AgWidgetsConfig<MyRegistry>) =>
createWidgets<MyRegistry>({
additionalTypes: [
{
id: "map-widget",
label: "Choropleth Map",
icon: { url: "https://www.ag-grid.com/studio/images/brandmark.svg" },
dataMapping: {
county: {
type: "field",
supportedRoles: ["category"],
requires: { cardinality: "one" },
required: true,
aiDescription: "Category field whose values match UK county names.",
},
value: {
type: "field",
supportedRoles: ["numeric"],
requires: { cardinality: "one" },
required: true,
aiDescription:
"Numeric measure that drives the colour intensity of each county.",
},
},
form: (params: AgWidgetFormParams<MapDef>) =>
params.createDefaults({
dataMappingItems: [
{ key: "county", label: "County" },
{ key: "value", label: "Value" },
],
}),
comp: CustomWidget,
defaultSize: { width: 500, height: 500 },
minSize: { width: 300, height: 300 },
},
],
menu: [
{ label: "Custom", widgetIds: ["map-widget"] },
...widgetConfig.menu,
],
});
const initialState: AgReportState<MyRegistry> = {
panels: {
filters: { collapsed: true },
edit: { collapsed: true },
data: { collapsed: true },
},
pages: [
{
id: "page-1",
widgets: {
map: {
type: "map-widget",
dataMapping: {
county: [{ id: `${SOURCE_ID}.county` }],
value: [{ id: `${SOURCE_ID}.revenue`, aggregation: "sum" }],
},
},
"revenue-by-region": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: `${SOURCE_ID}.region` }],
valueKey: [{ id: `${SOURCE_ID}.revenue`, aggregation: "sum" }],
},
format: {
crossFilter: "highlight",
title: { enabled: true, text: "Revenue by Region" },
},
},
},
widgetLayout: {
map: { xTrack: 0, yTrack: 0, xSpan: 14, ySpan: 35 },
"revenue-by-region": { xTrack: 14, yTrack: 0, xSpan: 10, ySpan: 35 },
},
},
],
selectedPageId: "page-1",
};
const studioProperties: AgStudioProperties<MyRegistry> = {
mode: "view",
initialState,
widgets,
data: storeData,
};
let studioApi: AgStudioApi<MyRegistry>;
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
import type { AgDataSourcesDefinition } from 'ag-studio';
export const SOURCE_ID = 'stores';
// Annual store revenue (ÂŁ000s) by UK county.
// County names match the ONS topology features used by the map widget.
export const storeData: AgDataSourcesDefinition = {
sources: [
{
id: SOURCE_ID,
fields: [
{ id: 'county', format: 'textFormat' },
{ id: 'region', format: 'textFormat' },
{ id: 'revenue', format: 'decimalFormat' },
],
data: [
{ county: 'GREATER LONDON', region: 'London', revenue: 9800 },
{ county: 'WEST MIDLANDS', region: 'Midlands', revenue: 4200 },
{ county: 'GREATER MANCHESTER', region: 'North West', revenue: 3900 },
{ county: 'WEST YORKSHIRE', region: 'Yorkshire', revenue: 3400 },
{ county: 'MERSEYSIDE', region: 'North West', revenue: 2800 },
{ county: 'SOUTH YORKSHIRE', region: 'Yorkshire', revenue: 2500 },
{ county: 'TYNE AND WEAR', region: 'North East', revenue: 2200 },
{ county: 'ESSEX', region: 'East of England', revenue: 2900 },
{ county: 'KENT', region: 'South East', revenue: 2800 },
{ county: 'HAMPSHIRE', region: 'South East', revenue: 2700 },
{ county: 'HERTFORDSHIRE', region: 'East of England', revenue: 2600 },
{ county: 'SURREY', region: 'South East', revenue: 2500 },
{ county: 'LANCASHIRE', region: 'North West', revenue: 2400 },
{ county: 'DEVON', region: 'South West', revenue: 2100 },
{ county: 'NOTTINGHAMSHIRE', region: 'Midlands', revenue: 2000 },
{ county: 'LEICESTERSHIRE', region: 'Midlands', revenue: 1900 },
{ county: 'DERBYSHIRE', region: 'Midlands', revenue: 1850 },
{ county: 'NORFOLK', region: 'East of England', revenue: 1800 },
{ county: 'SUFFOLK', region: 'East of England', revenue: 1700 },
{ county: 'STAFFORDSHIRE', region: 'Midlands', revenue: 1700 },
{ county: 'OXFORDSHIRE', region: 'South East', revenue: 1650 },
{ county: 'CAMBRIDGESHIRE', region: 'East of England', revenue: 1600 },
{ county: 'NORTH YORKSHIRE', region: 'Yorkshire', revenue: 1550 },
{ county: 'BUCKINGHAMSHIRE', region: 'South East', revenue: 1500 },
{ county: 'EAST SUSSEX', region: 'South East', revenue: 1450 },
{ county: 'WEST SUSSEX', region: 'South East', revenue: 1400 },
{ county: 'GLOUCESTERSHIRE', region: 'South West', revenue: 1350 },
{ county: 'WARWICKSHIRE', region: 'Midlands', revenue: 1300 },
{ county: 'LINCOLNSHIRE', region: 'East Midlands', revenue: 1250 },
{ county: 'WILTSHIRE', region: 'South West', revenue: 1200 },
{ county: 'SOMERSET', region: 'South West', revenue: 1150 },
{ county: 'DORSET', region: 'South West', revenue: 1100 },
{ county: 'CUMBRIA', region: 'North West', revenue: 1050 },
{ county: 'NORTHUMBERLAND', region: 'North East', revenue: 980 },
{ county: 'COUNTY DURHAM', region: 'North East', revenue: 950 },
{ county: 'WORCESTERSHIRE', region: 'Midlands', revenue: 1080 },
{ county: 'HEREFORDSHIRE', region: 'Midlands', revenue: 720 },
{ county: 'SHROPSHIRE', region: 'Midlands', revenue: 760 },
{ county: 'CORNWALL', region: 'South West', revenue: 880 },
{ county: 'BRISTOL', region: 'South West', revenue: 1420 },
{ county: 'NORTHAMPTONSHIRE', region: 'East Midlands', revenue: 1100 },
{ county: 'READING', region: 'South East', revenue: 740 },
{ county: 'BRIGHTON AND HOVE', region: 'South East', revenue: 780 },
{ county: 'YORK', region: 'Yorkshire', revenue: 660 },
{ county: 'MILTON KEYNES', region: 'South East', revenue: 680 },
],
},
],
};
export function toTitleCase(s: string): string {
return s.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
}
import type {
AgBaseRegistry,
AgBaseWidgetDefinition,
AgDefaultWidgetDefinition,
AgWidgetFieldReference,
} from 'ag-studio';
export interface MapDef {
type: 'map-widget';
dataMapping: {
county: AgWidgetFieldReference[];
value: AgWidgetFieldReference[];
};
}
export interface MyRegistry extends AgBaseRegistry {
widgets: readonly (AgDefaultWidgetDefinition | AgBaseWidgetDefinition<'map-widget', MapDef>)[];
}
// UK county outlines for choropleth map demos.
// Source: ONS Open Geography Portal (Counties and Unitary Authorities, April 2019, Ultra Generalised, EW).
// Processing: metropolitan borough polygons dissolved to ceremonial county level, then:
// - Ramer-Douglas-Peucker 5% simplification via mapshaper
// - Coordinates quantised to 4 decimal places (~11 m)
// Result: 110 features, 32 KB.
export const ukTopology = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.1932, 54.6291],
[-1.2428, 54.7223],
[-1.3809, 54.6439],
[-1.1932, 54.6291],
],
],
},
properties: { name: 'HARTLEPOOL' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.1986, 54.5829],
[-1.2349, 54.5103],
[-1.1462, 54.5028],
[-1.1986, 54.5829],
],
],
},
properties: { name: 'MIDDLESBROUGH' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.1462, 54.5028],
[-0.8808, 54.497],
[-0.7943, 54.5584],
[-1.1188, 54.6289],
[-1.1986, 54.5829],
[-1.1462, 54.5028],
],
],
},
properties: { name: 'REDCAR AND CLEVELAND' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.3809, 54.6439],
[-1.4384, 54.5951],
[-1.4349, 54.4875],
[-1.2349, 54.5103],
[-1.1986, 54.5829],
[-1.1932, 54.6291],
[-1.3809, 54.6439],
],
],
},
properties: { name: 'STOCKTON-ON-TEES' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.4384, 54.5951],
[-1.6824, 54.6178],
[-1.6969, 54.536],
[-1.4349, 54.4875],
[-1.4384, 54.5951],
],
],
},
properties: { name: 'DARLINGTON' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.5952, 53.3225],
[-2.6906, 53.3854],
[-2.7452, 53.4021],
[-2.8188, 53.348],
[-2.8267, 53.3317],
[-2.7524, 53.3148],
[-2.5952, 53.3225],
],
],
},
properties: { name: 'HALTON' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.5952, 53.3225],
[-2.5184, 53.3424],
[-2.4266, 53.3875],
[-2.4494, 53.4159],
[-2.4897, 53.4603],
[-2.5767, 53.4461],
[-2.6906, 53.3854],
[-2.5952, 53.3225],
],
],
},
properties: { name: 'WARRINGTON' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.3712, 53.6671],
[-2.5113, 53.627],
[-2.3791, 53.6309],
[-2.3712, 53.6671],
],
],
},
properties: { name: 'BLACKBURN WITH DARWEN' },
},
{ type: 'Feature', geometry: null, properties: { name: 'BLACKPOOL' } },
{ type: 'Feature', geometry: null, properties: { name: 'KINGSTON UPON HULL' } },
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.2517, 53.7329],
[-0.1035, 53.6353],
[0.117, 53.6623],
[-0.1555, 53.9015],
[-0.2125, 54.1576],
[-0.4271, 54.1374],
[-0.687, 54.0308],
[-0.9253, 53.9915],
[-0.9235, 53.8808],
[-0.9626, 53.7008],
[-1.0487, 53.6561],
[-0.8653, 53.6377],
[-0.7025, 53.6775],
[-0.6302, 53.734],
[-0.4192, 53.7196],
[-0.2517, 53.7329],
],
],
},
properties: { name: 'EAST RIDING OF YORKSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.2044, 53.6379],
[-0.2921, 53.6133],
[-0.1078, 53.4699],
[0.0173, 53.5254],
[-0.2044, 53.6379],
],
],
},
properties: { name: 'NORTH EAST LINCOLNSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.8653, 53.6377],
[-0.9356, 53.5025],
[-0.7975, 53.4551],
[-0.4783, 53.4735],
[-0.2921, 53.6133],
[-0.2044, 53.6379],
[-0.2942, 53.7141],
[-0.7025, 53.6775],
[-0.8653, 53.6377],
],
],
},
properties: { name: 'NORTH LINCOLNSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.9253, 53.9915],
[-1.0597, 54.0566],
[-1.1956, 53.9224],
[-0.9235, 53.8808],
[-0.9253, 53.9915],
],
],
},
properties: { name: 'YORK' },
},
{ type: 'Feature', geometry: null, properties: { name: 'DERBY' } },
{ type: 'Feature', geometry: null, properties: { name: 'LEICESTER' } },
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.6641, 52.7567],
[-0.8218, 52.7157],
[-0.7137, 52.525],
[-0.495, 52.6402],
[-0.4945, 52.7097],
[-0.6641, 52.7567],
],
],
},
properties: { name: 'RUTLAND' },
},
{ type: 'Feature', geometry: null, properties: { name: 'NOTTINGHAM' } },
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.9547, 52.3492],
[-3.1359, 52.1379],
[-3.0674, 51.9831],
[-2.8778, 51.9338],
[-2.6504, 51.8261],
[-2.4394, 51.8974],
[-2.4949, 51.9811],
[-2.3514, 52.0214],
[-2.3927, 52.2086],
[-2.618, 52.307],
[-2.8054, 52.3883],
[-2.9547, 52.3492],
],
],
},
properties: { name: 'HEREFORDSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.3156, 52.7329],
[-2.4163, 52.827],
[-2.6634, 52.7604],
[-2.4381, 52.6146],
[-2.3156, 52.7329],
],
],
},
properties: { name: 'TELFORD AND WREKIN' },
},
{ type: 'Feature', geometry: null, properties: { name: 'STOKE-ON-TRENT' } },
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.5105, 51.4288],
[-2.5901, 51.3975],
[-2.6949, 51.3181],
[-2.4517, 51.2743],
[-2.2891, 51.3253],
[-2.2946, 51.4288],
[-2.5105, 51.4288],
],
],
},
properties: { name: 'BATH AND NORTH EAST SOMERSET' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.5105, 51.4288],
[-2.5159, 51.4939],
[-2.6738, 51.5444],
[-2.6846, 51.4805],
[-2.5901, 51.3975],
[-2.5105, 51.4288],
],
],
},
properties: { name: 'BRISTOL' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.5901, 51.3975],
[-2.6846, 51.4805],
[-2.7736, 51.4947],
[-2.9631, 51.3829],
[-2.9941, 51.3209],
[-2.6949, 51.3181],
[-2.5901, 51.3975],
],
],
},
properties: { name: 'NORTH SOMERSET' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.2946, 51.4288],
[-2.2726, 51.5776],
[-2.5408, 51.6824],
[-2.6388, 51.6094],
[-2.6738, 51.5444],
[-2.5159, 51.4939],
[-2.5105, 51.4288],
[-2.2946, 51.4288],
],
],
},
properties: { name: 'SOUTH GLOUCESTERSHIRE' },
},
{ type: 'Feature', geometry: null, properties: { name: 'PLYMOUTH' } },
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.5092, 50.5166],
[-3.628, 50.426],
[-3.5076, 50.3792],
[-3.5092, 50.5166],
],
],
},
properties: { name: 'TORBAY' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.7886, 51.667],
[-1.8538, 51.5463],
[-1.7978, 51.4844],
[-1.6028, 51.5183],
[-1.6831, 51.6901],
[-1.7886, 51.667],
],
],
},
properties: { name: 'SWINDON' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.0313, 52.6615],
[-0.4948, 52.6403],
[-0.4154, 52.5787],
[-0.2933, 52.5069],
[-0.0128, 52.5943],
[-0.0313, 52.6615],
],
],
},
properties: { name: 'PETERBOROUGH' },
},
{ type: 'Feature', geometry: null, properties: { name: 'LUTON' } },
{ type: 'Feature', geometry: null, properties: { name: 'SOUTHEND-ON-SEA' } },
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0.5174, 51.5298],
[0.313, 51.5658],
[0.2106, 51.4902],
[0.4304, 51.4593],
[0.5174, 51.5298],
],
],
},
properties: { name: 'THURROCK' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0.6269, 51.3747],
[0.6999, 51.4727],
[0.4593, 51.4555],
[0.503, 51.3547],
[0.6269, 51.3747],
],
],
},
properties: { name: 'MEDWAY' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.8, 51.4412],
[-0.8373, 51.3529],
[-0.7755, 51.332],
[-0.6676, 51.3846],
[-0.8, 51.4412],
],
],
},
properties: { name: 'BRACKNELL FOREST' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.5847, 51.5249],
[-1.4983, 51.3294],
[-1.4111, 51.3729],
[-0.9861, 51.3628],
[-1.0011, 51.4264],
[-1.0366, 51.4752],
[-1.2607, 51.5378],
[-1.5847, 51.5249],
],
],
},
properties: { name: 'WEST BERKSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.0011, 51.4264],
[-0.9492, 51.4595],
[-1.0366, 51.4752],
[-1.0011, 51.4264],
],
],
},
properties: { name: 'READING' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.6422, 51.5006],
[-0.5244, 51.4715],
[-0.5097, 51.4692],
[-0.49, 51.4947],
[-0.6422, 51.5006],
],
],
},
properties: { name: 'SLOUGH' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.6676, 51.3846],
[-0.5244, 51.4715],
[-0.6422, 51.5006],
[-0.727, 51.5774],
[-0.8427, 51.5448],
[-0.8, 51.4412],
[-0.6676, 51.3846],
],
],
},
properties: { name: 'WINDSOR AND MAIDENHEAD' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.8, 51.4412],
[-0.8427, 51.5448],
[-0.8969, 51.5449],
[-0.9492, 51.4595],
[-1.0011, 51.4264],
[-0.9861, 51.3628],
[-0.8373, 51.3529],
[-0.8, 51.4412],
],
],
},
properties: { name: 'WOKINGHAM' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.6681, 52.195],
[-0.8807, 52.1263],
[-0.8713, 52.0403],
[-0.653, 51.9692],
[-0.5918, 52.1107],
[-0.6681, 52.195],
],
],
},
properties: { name: 'MILTON KEYNES' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.135, 50.8866],
[-0.216, 50.8276],
[-0.0382, 50.7995],
[-0.135, 50.8866],
],
],
},
properties: { name: 'BRIGHTON AND HOVE' },
},
{ type: 'Feature', geometry: null, properties: { name: 'PORTSMOUTH' } },
{ type: 'Feature', geometry: null, properties: { name: 'SOUTHAMPTON' } },
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.3125, 50.7673],
[-1.4845, 50.6668],
[-1.3, 50.5751],
[-1.1851, 50.5972],
[-1.1087, 50.7207],
[-1.3125, 50.7673],
],
],
},
properties: { name: 'ISLE OF WIGHT' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.2428, 54.7223],
[-1.3474, 54.8606],
[-1.5594, 54.882],
[-1.821, 54.9057],
[-2.3121, 54.791],
[-2.3557, 54.6977],
[-2.3045, 54.5962],
[-2.1724, 54.5324],
[-2.1702, 54.4582],
[-1.9425, 54.4534],
[-1.6969, 54.536],
[-1.6824, 54.6178],
[-1.4384, 54.5951],
[-1.3809, 54.6439],
[-1.2428, 54.7223],
],
],
},
properties: { name: 'COUNTY DURHAM' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.5184, 53.3424],
[-2.3941, 53.2668],
[-2.5429, 53.1498],
[-2.7529, 53.0692],
[-2.6993, 52.9954],
[-2.5295, 52.9472],
[-2.3808, 52.9984],
[-2.1556, 53.1596],
[-1.9874, 53.2136],
[-2.0311, 53.3703],
[-2.2408, 53.3596],
[-2.314, 53.3574],
[-2.4266, 53.3875],
[-2.5184, 53.3424],
],
],
},
properties: { name: 'CHESHIRE EAST' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.7524, 53.3148],
[-2.9286, 53.3082],
[-3.1037, 53.3],
[-3.0703, 53.2535],
[-2.9638, 53.1328],
[-2.836, 52.9972],
[-2.7268, 52.9833],
[-2.6993, 52.9954],
[-2.7529, 53.0692],
[-2.5429, 53.1498],
[-2.3941, 53.2668],
[-2.5184, 53.3424],
[-2.5952, 53.3225],
[-2.7524, 53.3148],
],
],
},
properties: { name: 'CHESHIRE WEST AND CHESTER' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.618, 52.307],
[-2.3674, 52.3881],
[-2.2874, 52.4553],
[-2.2477, 52.6831],
[-2.3156, 52.7329],
[-2.4381, 52.6146],
[-2.6634, 52.7604],
[-2.4163, 52.827],
[-2.4708, 52.9059],
[-2.3808, 52.9984],
[-2.5295, 52.9472],
[-2.6993, 52.9954],
[-2.7268, 52.9833],
[-2.982, 52.9592],
[-3.1475, 52.8902],
[-3.1609, 52.7957],
[-2.992, 52.7438],
[-3.1174, 52.5858],
[-3.111, 52.4989],
[-3.2355, 52.4425],
[-3.0402, 52.3443],
[-2.9547, 52.3492],
[-2.8054, 52.3883],
[-2.618, 52.307],
],
],
},
properties: { name: 'SHROPSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-4.546, 50.9284],
[-4.5627, 50.7811],
[-4.8494, 50.5988],
[-5.0242, 50.5387],
[-5.0425, 50.4441],
[-5.1539, 50.3461],
[-5.4344, 50.1923],
[-5.5387, 50.2162],
[-5.7102, 50.1273],
[-5.6732, 50.0347],
[-5.4837, 50.1276],
[-5.3163, 50.0852],
[-5.2195, 49.9714],
[-5.0578, 50.0528],
[-4.9819, 50.1518],
[-4.7568, 50.331],
[-4.4726, 50.3332],
[-4.3081, 50.3614],
[-4.2002, 50.4353],
[-4.3116, 50.5861],
[-4.421, 50.8661],
[-4.546, 50.9284],
],
],
},
properties: { name: 'CORNWALL' },
},
{ type: 'Feature', geometry: null, properties: { name: 'ISLES OF SCILLY' } },
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.2891, 51.3253],
[-2.2453, 51.2539],
[-2.3645, 51.1189],
[-2.3259, 51.0797],
[-2.2424, 51.0713],
[-2.1029, 50.9456],
[-1.9568, 50.9898],
[-1.8358, 51.0094],
[-1.6617, 50.9453],
[-1.6262, 51.1173],
[-1.6897, 51.2148],
[-1.536, 51.2485],
[-1.4983, 51.3294],
[-1.5847, 51.5249],
[-1.6028, 51.5183],
[-1.7978, 51.4844],
[-1.8538, 51.5463],
[-1.7886, 51.667],
[-2.2726, 51.5776],
[-2.2946, 51.4288],
[-2.2891, 51.3253],
],
],
},
properties: { name: 'WILTSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.5918, 52.1107],
[-0.4404, 52.0634],
[-0.2498, 52.1844],
[-0.4654, 52.323],
[-0.6681, 52.195],
[-0.5918, 52.1107],
],
],
},
properties: { name: 'BEDFORD' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.3856, 51.9157],
[-0.1573, 52.0805],
[-0.2498, 52.1844],
[-0.4404, 52.0634],
[-0.5918, 52.1107],
[-0.653, 51.9692],
[-0.7022, 51.9091],
[-0.5536, 51.8267],
[-0.3548, 51.874],
[-0.3856, 51.9157],
],
],
},
properties: { name: 'CENTRAL BEDFORDSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.821, 54.9057],
[-1.7698, 54.981],
[-1.6379, 55.0648],
[-1.4618, 55.0743],
[-1.5731, 55.2753],
[-1.5494, 55.3218],
[-1.6394, 55.5782],
[-1.8132, 55.6337],
[-2.0345, 55.8112],
[-2.2482, 55.6524],
[-2.1881, 55.4621],
[-2.4754, 55.3547],
[-2.6898, 55.189],
[-2.5729, 55.0164],
[-2.5583, 54.8167],
[-2.3121, 54.791],
[-1.821, 54.9057],
],
],
},
properties: { name: 'NORTHUMBERLAND' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.8039, 50.796],
[-1.8071, 50.8633],
[-1.9568, 50.9898],
[-2.1029, 50.9456],
[-2.2424, 51.0713],
[-2.3259, 51.0797],
[-2.4587, 50.9497],
[-2.6034, 50.9763],
[-2.6606, 50.8871],
[-2.9543, 50.8212],
[-2.9478, 50.7183],
[-2.7617, 50.7113],
[-2.4704, 50.5832],
[-2.4298, 50.6336],
[-1.9595, 50.591],
[-2.0249, 50.7294],
[-1.9335, 50.6986],
[-1.6925, 50.7374],
[-1.8039, 50.796],
],
],
},
properties: { name: 'DORSET' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.5113, 53.627],
[-2.6259, 53.5937],
[-2.7305, 53.5206],
[-2.5767, 53.4461],
[-2.4897, 53.4603],
[-2.4494, 53.4159],
[-2.4266, 53.3875],
[-2.314, 53.3574],
[-2.2408, 53.3596],
[-2.0311, 53.3703],
[-2.0263, 53.4299],
[-1.9634, 53.5098],
[-1.9096, 53.5384],
[-2.0095, 53.6168],
[-2.0268, 53.6242],
[-2.1463, 53.6822],
[-2.2718, 53.6145],
[-2.3712, 53.6671],
[-2.3791, 53.6309],
[-2.5113, 53.627],
],
],
},
properties: { name: 'GREATER MANCHESTER' },
},
{
type: 'Feature',
geometry: {
type: 'MultiPolygon',
coordinates: [
[
[
[-2.7452, 53.4021],
[-2.6906, 53.3854],
[-2.5767, 53.4461],
[-2.7305, 53.5206],
[-2.825, 53.4852],
[-2.888, 53.5038],
[-3.0467, 53.543],
[-2.9562, 53.6975],
[-3.1045, 53.5588],
[-3.0088, 53.4384],
[-2.8267, 53.3317],
[-2.8188, 53.348],
[-2.7452, 53.4021],
],
],
[
[
[-2.9286, 53.3082],
[-3.0412, 53.4429],
[-3.2004, 53.3875],
[-3.1037, 53.3],
[-2.9286, 53.3082],
],
],
],
},
properties: { name: 'MERSEYSIDE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.5865, 53.6072],
[-1.8222, 53.5211],
[-1.8015, 53.481],
[-1.5991, 53.3113],
[-1.3247, 53.3288],
[-1.1997, 53.3114],
[-1.116, 53.4073],
[-0.9356, 53.5025],
[-0.8653, 53.6377],
[-1.0487, 53.6561],
[-1.2328, 53.6211],
[-1.3487, 53.5833],
[-1.5865, 53.6072],
],
],
},
properties: { name: 'SOUTH YORKSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.7698, 54.981],
[-1.821, 54.9057],
[-1.5594, 54.882],
[-1.3474, 54.8606],
[-1.364, 54.9441],
[-1.5292, 54.9833],
[-1.4618, 55.0743],
[-1.6379, 55.0648],
[-1.7698, 54.981],
],
],
},
properties: { name: 'TYNE AND WEAR' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.8726, 52.5849],
[-2.0507, 52.6205],
[-2.1335, 52.5541],
[-2.1649, 52.4302],
[-2.017, 52.4327],
[-1.8687, 52.4047],
[-1.872, 52.3676],
[-1.6011, 52.3893],
[-1.5955, 52.4559],
[-1.7535, 52.513],
[-1.7881, 52.5879],
[-1.8726, 52.5849],
],
],
},
properties: { name: 'WEST MIDLANDS' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.0461, 53.8501],
[-2.0613, 53.8256],
[-2.1463, 53.6822],
[-2.0268, 53.6242],
[-2.0095, 53.6168],
[-1.9096, 53.5384],
[-1.8222, 53.5211],
[-1.5865, 53.6072],
[-1.3487, 53.5833],
[-1.2328, 53.6211],
[-1.302, 53.7417],
[-1.433, 53.9108],
[-1.7272, 53.9102],
[-1.9662, 53.9516],
[-2.0461, 53.8501],
],
],
},
properties: { name: 'WEST YORKSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0.1587, 51.5122],
[0.2106, 51.4902],
[0.313, 51.5658],
[0.1382, 51.6235],
[0.0218, 51.6288],
[-0.0123, 51.6462],
[-0.0119, 51.6809],
[-0.1821, 51.6686],
[-0.3045, 51.6364],
[-0.4041, 51.6132],
[-0.5006, 51.5997],
[-0.49, 51.4947],
[-0.5097, 51.4692],
[-0.4586, 51.4563],
[-0.3913, 51.4223],
[-0.3177, 51.3937],
[-0.245, 51.38],
[-0.1565, 51.3215],
[0.0023, 51.3291],
[0.0424, 51.2927],
[0.1489, 51.4085],
[0.2212, 51.4788],
[0.1202, 51.5114],
[-0.0227, 51.4754],
[-0.0324, 51.4931],
[-0.0747, 51.5055],
[0.0046, 51.5083],
[0.0981, 51.515],
[0.1587, 51.5122],
],
],
},
properties: { name: 'GREATER LONDON' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.49, 51.4947],
[-0.5006, 51.5997],
[-0.5051, 51.6731],
[-0.6735, 51.7685],
[-0.5536, 51.8267],
[-0.7022, 51.9091],
[-0.653, 51.9692],
[-0.8713, 52.0403],
[-0.9519, 52.0815],
[-1.1181, 52.0154],
[-1.062, 51.8441],
[-0.9359, 51.7534],
[-0.8969, 51.5449],
[-0.8427, 51.5448],
[-0.727, 51.5774],
[-0.6422, 51.5006],
[-0.49, 51.4947],
],
],
},
properties: { name: 'BUCKINGHAMSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.4154, 52.5787],
[-0.3624, 52.4335],
[-0.4654, 52.323],
[-0.2498, 52.1844],
[-0.1573, 52.0805],
[0.0681, 52.0058],
[0.2035, 52.0927],
[0.4046, 52.0655],
[0.4913, 52.1651],
[0.5048, 52.2847],
[0.4293, 52.4364],
[0.3671, 52.5012],
[0.2061, 52.5196],
[0.1721, 52.7379],
[-0.0313, 52.6615],
[-0.0128, 52.5943],
[-0.2933, 52.5069],
[-0.4154, 52.5787],
],
],
},
properties: { name: 'CAMBRIDGESHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.3121, 54.791],
[-2.5583, 54.8167],
[-2.5729, 55.0164],
[-2.6898, 55.189],
[-3.0259, 55.0365],
[-3.1298, 54.9344],
[-3.2852, 54.9415],
[-3.3998, 54.8675],
[-3.439, 54.7564],
[-3.5717, 54.6509],
[-3.6389, 54.5119],
[-3.4353, 54.3434],
[-3.2421, 54.1093],
[-2.8695, 54.1767],
[-2.6799, 54.1611],
[-2.4609, 54.2267],
[-2.3191, 54.2572],
[-2.3081, 54.4198],
[-2.1702, 54.4582],
[-2.1724, 54.5324],
[-2.3045, 54.5962],
[-2.3557, 54.6977],
[-2.3121, 54.791],
],
],
},
properties: { name: 'CUMBRIA' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.9874, 53.2136],
[-1.8607, 53.1884],
[-1.7589, 53.0373],
[-1.8566, 52.9234],
[-1.6268, 52.8544],
[-1.7042, 52.7321],
[-1.5975, 52.7004],
[-1.3449, 52.8675],
[-1.2679, 52.8734],
[-1.3444, 53.0655],
[-1.3234, 53.1623],
[-1.1969, 53.1848],
[-1.1997, 53.3114],
[-1.3247, 53.3288],
[-1.5991, 53.3113],
[-1.8015, 53.481],
[-1.8222, 53.5211],
[-1.9096, 53.5384],
[-1.9634, 53.5098],
[-2.0263, 53.4299],
[-2.0311, 53.3703],
[-1.9874, 53.2136],
],
],
},
properties: { name: 'DERBYSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-4.123, 50.3467],
[-3.9225, 50.297],
[-3.7203, 50.2019],
[-3.5076, 50.3792],
[-3.628, 50.426],
[-3.5092, 50.5166],
[-3.416, 50.6299],
[-2.9478, 50.7183],
[-2.9543, 50.8212],
[-3.0524, 50.9083],
[-3.1879, 50.9104],
[-3.4157, 51.0278],
[-3.6147, 51.0155],
[-3.8043, 51.1157],
[-3.7208, 51.2331],
[-4.0881, 51.2175],
[-4.2116, 51.1902],
[-4.2148, 51.0751],
[-4.3007, 50.9991],
[-4.5257, 51.0223],
[-4.546, 50.9284],
[-4.421, 50.8661],
[-4.3116, 50.5861],
[-4.2002, 50.4353],
[-4.1902, 50.4275],
[-4.123, 50.3467],
],
],
},
properties: { name: 'DEVON' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.0382, 50.7995],
[0.26, 50.7384],
[0.3775, 50.8203],
[0.8547, 50.9237],
[0.779, 50.9895],
[0.4723, 51.0304],
[0.3252, 51.1229],
[0.05, 51.1427],
[0.0274, 51.1399],
[-0.0246, 50.98],
[-0.135, 50.8866],
[-0.0382, 50.7995],
],
],
},
properties: { name: 'EAST SUSSEX' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0.8213, 51.5407],
[0.9346, 51.6328],
[0.9506, 51.7308],
[1.2048, 51.8039],
[1.2462, 51.9485],
[1.0539, 51.953],
[0.7723, 51.9704],
[0.6842, 52.087],
[0.4046, 52.0655],
[0.2035, 52.0927],
[0.0681, 52.0058],
[0.1461, 51.7962],
[0.0268, 51.7742],
[-0.0119, 51.6809],
[-0.0123, 51.6462],
[0.0218, 51.6288],
[0.1382, 51.6235],
[0.313, 51.5658],
[0.5174, 51.5298],
[0.6272, 51.538],
[0.8213, 51.5407],
],
],
},
properties: { name: 'ESSEX' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.6504, 51.8261],
[-2.6588, 51.6163],
[-2.6388, 51.6094],
[-2.5408, 51.6824],
[-2.2726, 51.5776],
[-1.7886, 51.667],
[-1.6831, 51.6901],
[-1.7195, 51.7832],
[-1.6658, 51.9875],
[-1.7676, 52.1126],
[-1.8634, 52.0534],
[-2.0608, 52.0147],
[-2.3514, 52.0214],
[-2.4949, 51.9811],
[-2.4394, 51.8974],
[-2.6504, 51.8261],
],
],
},
properties: { name: 'GLOUCESTERSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.8373, 51.3529],
[-0.9861, 51.3628],
[-1.4111, 51.3729],
[-1.4983, 51.3294],
[-1.536, 51.2485],
[-1.6897, 51.2148],
[-1.6262, 51.1173],
[-1.6617, 50.9453],
[-1.8358, 51.0094],
[-1.9568, 50.9898],
[-1.8071, 50.8633],
[-1.8039, 50.796],
[-1.6925, 50.7374],
[-1.5795, 50.7177],
[-1.3095, 50.8136],
[-1.4653, 50.9106],
[-1.3651, 50.88],
[-1.1419, 50.7735],
[-1.1141, 50.8449],
[-1.0241, 50.8263],
[-0.9325, 50.846],
[-0.897, 51.022],
[-0.7535, 51.0865],
[-0.7593, 51.1033],
[-0.8489, 51.2107],
[-0.7755, 51.332],
[-0.8373, 51.3529],
],
],
},
properties: { name: 'HAMPSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.3548, 51.874],
[-0.5536, 51.8267],
[-0.6735, 51.7685],
[-0.5051, 51.6731],
[-0.5006, 51.5997],
[-0.4041, 51.6132],
[-0.3045, 51.6364],
[-0.1821, 51.6686],
[-0.0119, 51.6809],
[0.0268, 51.7742],
[0.1461, 51.7962],
[0.0681, 52.0058],
[-0.1573, 52.0805],
[-0.3856, 51.9157],
[-0.3548, 51.874],
],
],
},
properties: { name: 'HERTFORDSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0.4593, 51.4555],
[0.2212, 51.4788],
[0.1489, 51.4085],
[0.0424, 51.2927],
[0.05, 51.1427],
[0.3252, 51.1229],
[0.4723, 51.0304],
[0.779, 50.9895],
[0.8547, 50.9237],
[0.9789, 50.9131],
[0.9973, 51.0251],
[1.3796, 51.1422],
[1.3706, 51.3118],
[1.4421, 51.3872],
[0.9044, 51.343],
[0.6269, 51.3747],
[0.503, 51.3547],
[0.4593, 51.4555],
],
],
},
properties: { name: 'KENT' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.3712, 53.6671],
[-2.2718, 53.6145],
[-2.1463, 53.6822],
[-2.0613, 53.8256],
[-2.0461, 53.8501],
[-2.2303, 53.9815],
[-2.5647, 54.127],
[-2.4609, 54.2267],
[-2.6799, 54.1611],
[-2.8695, 54.1767],
[-2.9061, 54.0396],
[-2.8619, 53.9646],
[-3.048, 53.8757],
[-3.0569, 53.7766],
[-2.9562, 53.6975],
[-3.0467, 53.543],
[-2.888, 53.5038],
[-2.825, 53.4852],
[-2.7305, 53.5206],
[-2.6259, 53.5937],
[-2.5113, 53.627],
[-2.3712, 53.6671],
],
],
},
properties: { name: 'LANCASHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.6641, 52.7567],
[-0.7783, 52.9769],
[-0.9827, 52.8207],
[-1.2619, 52.8105],
[-1.2679, 52.8734],
[-1.3449, 52.8675],
[-1.5975, 52.7004],
[-1.5896, 52.6873],
[-1.5229, 52.5706],
[-1.3059, 52.4934],
[-1.2016, 52.3967],
[-0.8688, 52.527],
[-0.7137, 52.525],
[-0.8218, 52.7157],
[-0.6641, 52.7567],
],
],
},
properties: { name: 'LEICESTERSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.2921, 53.6133],
[-0.4783, 53.4735],
[-0.7975, 53.4551],
[-0.6951, 53.0663],
[-0.7783, 52.9769],
[-0.6641, 52.7567],
[-0.4945, 52.7097],
[-0.495, 52.6402],
[-0.4948, 52.6403],
[-0.0313, 52.6615],
[0.1721, 52.7379],
[0.2688, 52.8158],
[0.0651, 52.9046],
[0.1514, 53.0081],
[0.3399, 53.0973],
[0.3556, 53.1921],
[0.1574, 53.4789],
[0.0173, 53.5254],
[-0.1078, 53.4699],
[-0.2921, 53.6133],
],
],
},
properties: { name: 'LINCOLNSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0.4293, 52.4364],
[0.5547, 52.456],
[0.7847, 52.3862],
[1.2139, 52.3554],
[1.4801, 52.4719],
[1.6593, 52.4684],
[1.7405, 52.5321],
[1.6976, 52.7235],
[1.3011, 52.9328],
[1.0358, 52.9671],
[0.5417, 52.9759],
[0.4449, 52.8533],
[0.2688, 52.8158],
[0.1721, 52.7379],
[0.2061, 52.5196],
[0.3671, 52.5012],
[0.4293, 52.4364],
],
],
},
properties: { name: 'NORFOLK' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.7137, 52.525],
[-0.8688, 52.527],
[-1.2016, 52.3967],
[-1.2548, 52.1989],
[-1.3319, 52.1685],
[-1.1181, 52.0154],
[-0.9519, 52.0815],
[-0.8713, 52.0403],
[-0.8807, 52.1263],
[-0.6681, 52.195],
[-0.4654, 52.323],
[-0.3624, 52.4335],
[-0.4154, 52.5787],
[-0.4948, 52.6403],
[-0.495, 52.6402],
[-0.7137, 52.525],
],
],
},
properties: { name: 'NORTHAMPTONSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.2349, 54.5103],
[-1.4349, 54.4875],
[-1.6969, 54.536],
[-1.9425, 54.4534],
[-2.1702, 54.4582],
[-2.3081, 54.4198],
[-2.3191, 54.2572],
[-2.4609, 54.2267],
[-2.5647, 54.127],
[-2.2303, 53.9815],
[-2.0461, 53.8501],
[-1.9662, 53.9516],
[-1.7272, 53.9102],
[-1.433, 53.9108],
[-1.302, 53.7417],
[-1.2328, 53.6211],
[-1.0487, 53.6561],
[-0.9626, 53.7008],
[-0.9235, 53.8808],
[-1.1956, 53.9224],
[-1.0597, 54.0566],
[-0.9253, 53.9915],
[-0.687, 54.0308],
[-0.4271, 54.1374],
[-0.2125, 54.1576],
[-0.3687, 54.2485],
[-0.5689, 54.4797],
[-0.7943, 54.5584],
[-0.8808, 54.497],
[-1.1462, 54.5028],
[-1.2349, 54.5103],
],
],
},
properties: { name: 'NORTH YORKSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.9356, 53.5025],
[-1.116, 53.4073],
[-1.1997, 53.3114],
[-1.1969, 53.1848],
[-1.3234, 53.1623],
[-1.3444, 53.0655],
[-1.2679, 52.8734],
[-1.2619, 52.8105],
[-0.9827, 52.8207],
[-0.7783, 52.9769],
[-0.6951, 53.0663],
[-0.7975, 53.4551],
[-0.9356, 53.5025],
],
],
},
properties: { name: 'NOTTINGHAMSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.6028, 51.5183],
[-1.5847, 51.5249],
[-1.2607, 51.5378],
[-1.0366, 51.4752],
[-0.9492, 51.4595],
[-0.8969, 51.5449],
[-0.9359, 51.7534],
[-1.062, 51.8441],
[-1.1181, 52.0154],
[-1.3319, 52.1685],
[-1.4878, 52.094],
[-1.5228, 51.9968],
[-1.6658, 51.9875],
[-1.7195, 51.7832],
[-1.6831, 51.6901],
[-1.6028, 51.5183],
],
],
},
properties: { name: 'OXFORDSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.6949, 51.3181],
[-2.9941, 51.3209],
[-2.9978, 51.2254],
[-3.2772, 51.1796],
[-3.4961, 51.2238],
[-3.7208, 51.2331],
[-3.8043, 51.1157],
[-3.6147, 51.0155],
[-3.4157, 51.0278],
[-3.1879, 50.9104],
[-3.0524, 50.9083],
[-2.9543, 50.8212],
[-2.6606, 50.8871],
[-2.6034, 50.9763],
[-2.4587, 50.9497],
[-2.3259, 51.0797],
[-2.3645, 51.1189],
[-2.2453, 51.2539],
[-2.2891, 51.3253],
[-2.4517, 51.2743],
[-2.6949, 51.3181],
],
],
},
properties: { name: 'SOMERSET' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.3156, 52.7329],
[-2.2477, 52.6831],
[-2.2874, 52.4553],
[-2.1649, 52.4302],
[-2.1335, 52.5541],
[-2.0507, 52.6205],
[-1.8726, 52.5849],
[-1.7881, 52.5879],
[-1.6656, 52.5923],
[-1.5896, 52.6873],
[-1.5975, 52.7004],
[-1.7042, 52.7321],
[-1.6268, 52.8544],
[-1.8566, 52.9234],
[-1.7589, 53.0373],
[-1.8607, 53.1884],
[-1.9874, 53.2136],
[-2.1556, 53.1596],
[-2.3808, 52.9984],
[-2.4708, 52.9059],
[-2.4163, 52.827],
[-2.3156, 52.7329],
],
],
},
properties: { name: 'STAFFORDSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0.4046, 52.0655],
[0.6842, 52.087],
[0.7723, 51.9704],
[1.0539, 51.953],
[1.3449, 51.9569],
[1.5801, 52.0911],
[1.6329, 52.276],
[1.7636, 52.4816],
[1.7405, 52.5321],
[1.6593, 52.4684],
[1.4801, 52.4719],
[1.2139, 52.3554],
[0.7847, 52.3862],
[0.5547, 52.456],
[0.4293, 52.4364],
[0.5048, 52.2847],
[0.4913, 52.1651],
[0.4046, 52.0655],
],
],
},
properties: { name: 'SUFFOLK' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.7755, 51.332],
[-0.8489, 51.2107],
[-0.7593, 51.1033],
[-0.7535, 51.0865],
[-0.5392, 51.082],
[-0.1376, 51.1422],
[0.0274, 51.1399],
[0.05, 51.1427],
[0.0424, 51.2927],
[0.0023, 51.3291],
[-0.1565, 51.3215],
[-0.245, 51.38],
[-0.3177, 51.3937],
[-0.3913, 51.4223],
[-0.4586, 51.4563],
[-0.5097, 51.4692],
[-0.5244, 51.4715],
[-0.6676, 51.3846],
[-0.7755, 51.332],
],
],
},
properties: { name: 'SURREY' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-1.7535, 52.513],
[-1.5955, 52.4559],
[-1.6011, 52.3893],
[-1.872, 52.3676],
[-1.9443, 52.1553],
[-1.7676, 52.1126],
[-1.6658, 51.9875],
[-1.5228, 51.9968],
[-1.4878, 52.094],
[-1.3319, 52.1685],
[-1.2548, 52.1989],
[-1.2016, 52.3967],
[-1.3059, 52.4934],
[-1.5229, 52.5706],
[-1.5896, 52.6873],
[-1.6656, 52.5923],
[-1.7881, 52.5879],
[-1.7535, 52.513],
],
],
},
properties: { name: 'WARWICKSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.135, 50.8866],
[-0.0246, 50.98],
[0.0274, 51.1399],
[-0.1376, 51.1422],
[-0.5392, 51.082],
[-0.7535, 51.0865],
[-0.897, 51.022],
[-0.9325, 50.846],
[-0.7854, 50.7228],
[-0.7017, 50.7776],
[-0.216, 50.8276],
[-0.135, 50.8866],
],
],
},
properties: { name: 'WEST SUSSEX' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.3514, 52.0214],
[-2.0608, 52.0147],
[-1.8634, 52.0534],
[-1.7676, 52.1126],
[-1.9443, 52.1553],
[-1.872, 52.3676],
[-1.8687, 52.4047],
[-2.017, 52.4327],
[-2.1649, 52.4302],
[-2.2874, 52.4553],
[-2.3674, 52.3881],
[-2.618, 52.307],
[-2.3927, 52.2086],
[-2.3514, 52.0214],
],
],
},
properties: { name: 'WORCESTERSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-4.3381, 53.1532],
[-4.2186, 53.1856],
[-4.2045, 53.314],
[-4.293, 53.4111],
[-4.4247, 53.4298],
[-4.5551, 53.3739],
[-4.5975, 53.2403],
[-4.4436, 53.155],
[-4.3381, 53.1532],
],
],
},
properties: { name: 'ISLE OF ANGLESEY' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-4.0074, 53.2469],
[-4.1225, 53.2372],
[-4.3466, 53.1143],
[-4.3374, 53.0589],
[-4.5196, 52.9403],
[-4.6013, 52.825],
[-4.2188, 52.919],
[-4.0589, 52.7178],
[-4.1265, 52.6073],
[-3.9429, 52.5585],
[-3.9266, 52.5608],
[-3.8407, 52.6509],
[-3.602, 52.705],
[-3.5865, 52.8277],
[-3.483, 52.8655],
[-3.458, 52.9849],
[-3.6153, 53.0114],
[-3.8132, 52.9494],
[-3.9854, 53.0107],
[-4.0313, 53.1056],
[-4.0074, 53.2469],
],
],
},
properties: { name: 'GWYNEDD' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.458, 52.9849],
[-3.5341, 53.1399],
[-3.461, 53.165],
[-3.5064, 53.3154],
[-3.7752, 53.3283],
[-4.0074, 53.2469],
[-4.0313, 53.1056],
[-3.9854, 53.0107],
[-3.8132, 52.9494],
[-3.6153, 53.0114],
[-3.458, 52.9849],
],
],
},
properties: { name: 'CONWY' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.483, 52.8655],
[-3.375, 52.8925],
[-3.1488, 52.9986],
[-3.1296, 53.0724],
[-3.2712, 53.1575],
[-3.3943, 53.3017],
[-3.3634, 53.3521],
[-3.5064, 53.3154],
[-3.461, 53.165],
[-3.5341, 53.1399],
[-3.458, 52.9849],
[-3.483, 52.8655],
],
],
},
properties: { name: 'DENBIGHSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.0703, 53.2535],
[-3.3634, 53.3521],
[-3.3943, 53.3017],
[-3.2712, 53.1575],
[-3.1296, 53.0724],
[-2.9638, 53.1328],
[-3.0703, 53.2535],
],
],
},
properties: { name: 'FLINTSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.9638, 53.1328],
[-3.1296, 53.0724],
[-3.1488, 52.9986],
[-3.375, 52.8925],
[-3.1475, 52.8902],
[-2.982, 52.9592],
[-2.7268, 52.9833],
[-2.836, 52.9972],
[-2.9638, 53.1328],
],
],
},
properties: { name: 'WREXHAM' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.9429, 52.5585],
[-4.0142, 52.5265],
[-4.2075, 52.2637],
[-4.5186, 52.135],
[-4.6885, 52.1035],
[-4.5598, 52.0437],
[-4.2345, 52.0382],
[-3.9405, 52.1384],
[-3.7581, 52.1282],
[-3.7121, 52.4134],
[-3.9266, 52.5608],
[-3.9429, 52.5585],
],
],
},
properties: { name: 'CEREDIGION' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-4.6885, 52.1035],
[-4.9712, 52.0015],
[-5.3056, 51.9084],
[-5.1378, 51.8639],
[-5.0593, 51.6206],
[-4.9484, 51.597],
[-4.7144, 51.6587],
[-4.6329, 51.7352],
[-4.6356, 51.9186],
[-4.4871, 51.9887],
[-4.5598, 52.0437],
[-4.6885, 52.1035],
],
],
},
properties: { name: 'PEMBROKESHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-4.5598, 52.0437],
[-4.4871, 51.9887],
[-4.6356, 51.9186],
[-4.6329, 51.7352],
[-4.3827, 51.7273],
[-4.3196, 51.6758],
[-4.0816, 51.6849],
[-3.9365, 51.7714],
[-3.8067, 51.788],
[-3.6471, 52.0388],
[-3.7581, 52.1282],
[-3.9405, 52.1384],
[-4.2345, 52.0382],
[-4.5598, 52.0437],
],
],
},
properties: { name: 'CARMARTHENSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-4.0816, 51.6849],
[-4.3092, 51.6096],
[-4.2132, 51.5375],
[-3.8862, 51.6175],
[-3.9365, 51.7714],
[-4.0816, 51.6849],
],
],
},
properties: { name: 'SWANSEA' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.9365, 51.7714],
[-3.8862, 51.6175],
[-3.7607, 51.5356],
[-3.6618, 51.6455],
[-3.5633, 51.6455],
[-3.5913, 51.7546],
[-3.8067, 51.788],
[-3.9365, 51.7714],
],
],
},
properties: { name: 'NEATH PORT TALBOT' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.7607, 51.5356],
[-3.6197, 51.4766],
[-3.4975, 51.5126],
[-3.5633, 51.6455],
[-3.6618, 51.6455],
[-3.7607, 51.5356],
],
],
},
properties: { name: 'BRIDGEND' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.6197, 51.4766],
[-3.559, 51.4013],
[-3.3779, 51.3813],
[-3.1656, 51.4461],
[-3.3357, 51.5084],
[-3.4975, 51.5126],
[-3.6197, 51.4766],
],
],
},
properties: { name: 'VALE OF GLAMORGAN' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.1656, 51.4461],
[-3.0827, 51.5018],
[-3.1189, 51.5456],
[-3.2377, 51.5526],
[-3.3357, 51.5084],
[-3.1656, 51.4461],
],
],
},
properties: { name: 'CARDIFF' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.5633, 51.6455],
[-3.4975, 51.5126],
[-3.3357, 51.5084],
[-3.2377, 51.5526],
[-3.3139, 51.6447],
[-3.4442, 51.8165],
[-3.5913, 51.7546],
[-3.5633, 51.6455],
],
],
},
properties: { name: 'RHONDDA CYNON TAF' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.1189, 51.5456],
[-3.0818, 51.6194],
[-3.1161, 51.6908],
[-3.3101, 51.7943],
[-3.3345, 51.7904],
[-3.3139, 51.6447],
[-3.2377, 51.5526],
[-3.1189, 51.5456],
],
],
},
properties: { name: 'CAERPHILLY' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.1161, 51.6908],
[-3.1342, 51.7928],
[-3.1574, 51.8161],
[-3.3101, 51.7943],
[-3.1161, 51.6908],
],
],
},
properties: { name: 'BLAENAU GWENT' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.0818, 51.6194],
[-2.9589, 51.6288],
[-2.9835, 51.7154],
[-3.1342, 51.7928],
[-3.1161, 51.6908],
[-3.0818, 51.6194],
],
],
},
properties: { name: 'TORFAEN' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.0674, 51.9831],
[-3.0401, 51.8846],
[-3.1574, 51.8161],
[-3.1342, 51.7928],
[-2.9835, 51.7154],
[-2.9589, 51.6288],
[-2.8216, 51.5541],
[-2.6588, 51.6163],
[-2.6504, 51.8261],
[-2.8778, 51.9338],
[-3.0674, 51.9831],
],
],
},
properties: { name: 'MONMOUTHSHIRE' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.0827, 51.5018],
[-2.8216, 51.5541],
[-2.9589, 51.6288],
[-3.0818, 51.6194],
[-3.1189, 51.5456],
[-3.0827, 51.5018],
],
],
},
properties: { name: 'NEWPORT' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-2.9547, 52.3492],
[-3.0402, 52.3443],
[-3.2355, 52.4425],
[-3.111, 52.4989],
[-3.1174, 52.5858],
[-2.992, 52.7438],
[-3.1609, 52.7957],
[-3.1475, 52.8902],
[-3.375, 52.8925],
[-3.483, 52.8655],
[-3.5865, 52.8277],
[-3.602, 52.705],
[-3.8407, 52.6509],
[-3.9266, 52.5608],
[-3.7121, 52.4134],
[-3.7581, 52.1282],
[-3.6471, 52.0388],
[-3.8067, 51.788],
[-3.5913, 51.7546],
[-3.4442, 51.8165],
[-3.3345, 51.7904],
[-3.3101, 51.7943],
[-3.1574, 51.8161],
[-3.0401, 51.8846],
[-3.0674, 51.9831],
[-3.1359, 52.1379],
[-2.9547, 52.3492],
],
],
},
properties: { name: 'POWYS' },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-3.3139, 51.6447],
[-3.3345, 51.7904],
[-3.4442, 51.8165],
[-3.3139, 51.6447],
],
],
},
properties: { name: 'MERTHYR TYDFIL' },
},
],
};
import type { AgChartInstance, AgChartOptions } from 'ag-charts-enterprise';
import { AgCharts, AllMapSeriesModule, GradientLegendModule, ModuleRegistry } from 'ag-charts-enterprise';
import type { AgTypeScriptComponent, AgWidgetApi, AgWidgetField, AgWidgetParams } from 'ag-studio';
import { getChartTheme } from 'ag-studio';
import { toTitleCase } from './format.ts';
import type { MapDef } from './interfaces.ts';
import { ukTopology } from './ukTopology.ts';
ModuleRegistry.registerModules([AllMapSeriesModule, GradientLegendModule]);
// Title-case all topology feature names once so the idKey values in the chart
// data and the topology match without forcing raw uppercase names into the UI.
const topology = {
...ukTopology,
features: ukTopology.features.map((f: any) => ({
...f,
properties: { ...f.properties, name: toTitleCase(f.properties.name as string) },
})),
};
export class CustomWidget implements AgTypeScriptComponent<AgWidgetParams<MapDef>> {
private eGui!: HTMLDivElement;
private chart: AgChartInstance | null = null;
private hasLoaded = false;
private selectedCounties = new Set<string>();
init(params: AgWidgetParams<MapDef>): Promise<void> {
this.eGui = document.createElement('div');
this.eGui.style.cssText = 'width:100%;height:100%';
return this.updateData(params);
}
refresh(params: AgWidgetParams<MapDef>): Promise<void> {
return this.updateData(params);
}
private async updateData(params: AgWidgetParams<MapDef>): Promise<void> {
const { api, widgetApi, dataMapping } = params;
const { selectedCounties } = this;
const [countyField] = dataMapping.county ?? [];
const [valueField] = dataMapping.value ?? [];
if (!countyField || !valueField) {
widgetApi.setDisplayState('incompleteDataMapping');
return;
}
widgetApi.setDisplayState('loading', { prominent: !this.hasLoaded });
const query = { fields: [countyField, valueField] };
// Two parallel queries:
// - base: ignores cross-filters, keeps all counties visible for context
// - filtered: respects cross-filters, drives colour intensity
const [baseResponse, filteredResponse] = await Promise.all([
widgetApi.getData(query, { queryId: 'base', crossFilter: 'none' }),
widgetApi.getData(query, { queryId: 'filtered' }),
]);
const baseRows = baseResponse.results.rows;
if (!baseRows.length) {
widgetApi.setDisplayState('noData');
return;
}
const baseData = baseRows.map((row) => ({
rawName: row[countyField.key] as string,
name: toTitleCase(row[countyField.key] as string),
value: row[valueField.key] as number,
}));
// Build a set of filtered county names for per-datum opacity dimming.
const filteredNames = new Set(
filteredResponse.results.rows.map((row) => toTitleCase(row[countyField.key] as string))
);
const hasCrossFilter = filteredNames.size < baseData.length;
// Place colour stops at min, geometric mean, and max. The geometric mean
// (â(min Ă max)) is the logarithmic midpoint, so equal visual steps on
// the gradient correspond to equal multiplicative steps in the data.
// Using actual values keeps the legend labels in real units.
const values = baseData.map((d) => d.value);
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const geoMid = Math.sqrt(minVal * maxVal);
this.hasLoaded = true;
const options: AgChartOptions = {
container: this.eGui,
theme: getChartTheme(api),
background: { fill: 'transparent' },
gradientLegend: {
enabled: true,
scale: {
label: {
formatter: ({ value }: any) => widgetApi.formatFieldValue(valueField, value),
},
interval: { values: [minVal, maxVal], step: 3000 },
},
},
topology,
listeners: {
seriesNodeClick: (event: any) => {
const { datum } = event;
if (datum?.rawName == null) {
return;
}
const { rawName } = datum;
const isMultiSelect = event.event?.ctrlKey || event.event?.metaKey;
if (isMultiSelect) {
if (selectedCounties.has(rawName)) {
selectedCounties.delete(rawName);
} else {
selectedCounties.add(rawName);
}
widgetApi.toggleCrossFilter({ type: 'value', field: countyField, value: rawName, group: 0 });
return;
}
const wasSole = selectedCounties.size === 1 && selectedCounties.has(rawName);
selectedCounties.clear();
widgetApi.resetCrossFilter();
if (!wasSole) {
selectedCounties.add(rawName);
widgetApi.toggleCrossFilter({ type: 'value', field: countyField, value: rawName, group: 0 });
}
},
click: () => {
selectedCounties.clear();
widgetApi.resetCrossFilter();
},
},
series: [
{ type: 'map-shape-background', strokeWidth: 0.5 },
{
type: 'map-shape' as const,
data: baseData,
idKey: 'name',
colorKey: 'value',
colorScale: {
fills: [
{ color: '#ffffb2', stop: minVal },
{ color: '#fd8d3c', stop: geoMid },
{ color: '#bd0026', stop: maxVal },
],
},
colorName: 'Revenue',
// Dim counties outside the cross-filter selection per-datum so the
// colour scale and gradient legend remain stable on a single series.
itemStyler: hasCrossFilter
? ({ datum }: any) =>
filteredNames.has(datum.name) ? {} : { fillOpacity: 0.15, strokeOpacity: 0.1 }
: undefined,
tooltip: {
renderer: ({ datum }: any) => generateTooltip(datum, widgetApi, valueField),
},
},
],
};
if (this.chart == null) {
this.chart = AgCharts.create(options) as unknown as AgChartInstance;
} else {
this.chart.update(options);
}
widgetApi.setDisplayState('displayed');
}
getGui(): HTMLDivElement {
return this.eGui;
}
destroy(): void {
this.chart?.destroy();
this.chart = null;
}
}
function generateTooltip(datum: any, widgetApi: AgWidgetApi, valueField: AgWidgetField) {
if (datum.rawName === '') {
return '';
}
return `<div style="padding:6px 10px">
<div class="ag-charts-tooltip-title">${datum.name}</div>
<div class="ag-charts-tooltip-content">${widgetApi.formatFieldValue(valueField, datum.value)}</div>
</div>`;
}
<div style="display: flex; flex-direction: column; height: 100%">
<div id="myStudio" class="my-studio-container"></div>
</div>
The colour domain is clamped to the p10-p90 range of the dataset, preventing a single high-value region from compressing all other counties into a narrow band near the minimum. When counties are selected, a background layer renders the full distribution at reduced opacity to preserve geographic context.