A powerful development environment for creating, testing, and packaging custom charts for Luzmo dashboards.
- Overview
- Quick start
- Project structure
- Creating your own Custom Chart
- Adding third party libraries
- Building and packaging
- Troubleshooting
- License
- Resources
This Luzmo Custom Chart Builder provides a complete workflow for developing custom Luzmo visualizations. This toolkit includes:
- An interactive development environment with live preview
- Configurable data slots for chart customization
- Automatic build and refresh on code changes
- Schema validation for chart configuration
- Production-ready packaging tools
- Node.js (v16+)
- npm
-
Clone the repository:
git clone https://github.com/luzmo-official/custom-chart-builder.git cd custom-chart-builder -
Install dependencies:
npm install
This will automatically install dependencies for both the root project and the custom chart component.
Start the development environment with a single command:
npm run startThis command launches:
- The Angular development server
- A bundle server for custom chart assets
- A file watcher that rebuilds on changes
Your development environment will be available at http://localhost:4200
When you first access the development environment, you'll be presented with a login page. You'll need to log in with your Luzmo account credentials
After successful authentication, you'll be redirected to the chart builder environment where you can access and use your Luzmo datasets to test your custom chart
custom-chart-builder/
├── custom-chart-build-output/ # Production build files
├── projects/
│ ├── builder/ # Angular application for the chart builder UI
│ └── custom-chart/ # Custom chart implementation
│ └── src/
│ ├── chart.ts # Main chart rendering logic
│ ├── chart.css # Chart styles
│ ├── manifest.json # Chart configuration and slot definitions
│ ├── icon.svg # Chart icon
│ └── index.ts # Entry point
To create your own chart, you'll primarily need to edit these three files:
- manifest.json - define your chart's data slots and configuration.
- chart.ts - this is where you'll implement the chart rendering logic.
- chart.css - add styles for your chart's visual appearance.
The manifest.json file defines the data slots of your custom chart. A data slot can receive one or multiple columns from your datasets. These slot definitions determine what type of columns are accepted by your chart and how these options are displayed in Luzmo's dashboard editor.
When a user adds a column to one of the data slots in the chart, Luzmo will automatically query the aggregated data, respecting any applied filters.
This manifest file is validated against a Zod schema to ensure compatibility with the Luzmo platform.
| Parameter | Description |
|---|---|
nameSTRING |
Internal identifier for the slot. Note: within one chart, all slots must have unique names! Must be one of: x-axis, y-axis, category, measure, coordinates, legend, geo, image, color, levels, slidermetric, dimension, destination, source, time, identifier, target, size, name, columns, column, row, evolution, close, open, low, high, order, route |
labelSTRING |
User-facing name displayed in the interface. Can be a string or a localized string. |
| Parameter | Description |
|---|---|
descriptionSTRING |
Short explanation of the slot's purpose. |
positionSTRING |
Position of the slot in the chart overlay in the dashboard editor. Must be one of: top-left, top, top-right, right, bottom-right, bottom, bottom-left, left, middle. |
typeSTRING |
Data type. Must be 'categorical' or 'numeric'. If set to 'categorical', columns in this slot will be added to the dimensions part of the query. If set to 'numeric', columns in this slot will be added to the measures part of the query. This is used to determine how data is aggregated. |
rotateBOOLEAN |
Whether the axis should be rotated. |
orderNUMBER |
Display order in the interface. |
isRequiredBOOLEAN |
Whether the slot must be filled. |
acceptableColumnTypesARRAY |
Array of allowed column types. Must be one of: 'numeric', 'hierarchy', 'datetime', 'spatial'. |
acceptableColumnSubtypesARRAY |
Array of specific column subtypes. Must be one of: 'duration', 'currency', 'coordinates', 'topography'. |
canAcceptFormulaBOOLEAN |
Whether this slot can accept a formula-based column. |
canAcceptMultipleColumnsBOOLEAN |
Whether multiple columns can be placed in this slot. |
requiredMinimumColumnsCountNUMBER |
Minimum number of columns required. |
noMultipleIfSlotsFilledARRAY |
Array of slot names that prevent multiple columns when filled. |
optionsOBJECT |
Additional options for the slot. See Slot options properties below. |
| Parameter | Description |
|---|---|
isBinningDisabledBOOLEAN |
Disable binning for categorical fields. |
areDatetimeOptionsEnabledBOOLEAN |
Enable date/time-based options. |
isAggregationDisabledBOOLEAN |
Disable aggregation functions. |
areGrandTotalsEnabledBOOLEAN |
Enable grand totals. |
showOnlyFirstSlotGrandTotalsBOOLEAN |
Only show grand totals for first slot. |
isCumulativeSumEnabledBOOLEAN |
Enable cumulative sum calculations. |
showOnlyFirstSlotContentOptionsBOOLEAN |
Only apply content options to first slot. |
Example configuration of a custom chart with two slots, Category and Measure:
{
"slots": [
{
"name": "category",
"rotate": false,
"label": "Category",
"type": "categorical",
"order": 1,
"options": {
"isBinningDisabled": true,
"areDatetimeOptionsEnabled": true
},
"isRequired": true,
"position": "bottom"
},
{
"name": "measure",
"rotate": false,
"label": "Measure",
"type": "numeric",
"order": 2,
"options": {
"isAggregationDisabled": false
},
"isRequired": true,
"position": "middle"
}
]
}The chart.ts file contains the core logic for your chart. You need to implement three main functions:
The render() function creates and draws your chart:
// Import required types
import { Slot, SlotConfig, ItemQuery, ItemQueryDimension, ItemQueryMeasure, ThemeConfig } from '@luzmo/dashboard-contents-types';
import * as d3 from 'd3';
// Define the complete interface for chart parameters
interface ChartParams {
container: HTMLElement; // The DOM element where your chart will be rendered
data: any[][]; // The data rows from the server
slots: Slot[]; // The filled slots with column mappings
slotConfigurations: SlotConfig[]; // The configuration of available slots
options: Record<string, any> & { theme: ThemeConfig }; // Additional options passed to the chart
language: string; // Current language code (e.g., 'en')
dimensions: { // Width and height of the chart container in pixels
width: number;
height: number;
};
}
// Render function implementation
export function render({
container,
data = [],
slots = [],
slotConfigurations = [],
options = {},
language = 'en',
dimensions: { width = 600, height = 400 } = {}
}: ChartParams): void {
// 1. Clear the container
container.innerHTML = '';
// 2. Check if data exists
const hasData = data && data.length > 0;
// 3. Extract and process data
const chartData = hasData ? data.map(row => ({
category: String(row[0]?.name?.en || row[0] || 'Unknown'),
value: Number(row[1] || 0)
})) : [];
// 4. Create visualization (SVG, Canvas, etc.)
const svg = d3.select(container)
.append('svg')
.attr('width', width)
.attr('height', height);
// 5. Add your chart elements here...
// 6. Store state for resize
(container as any).__chartData = chartData;
}The resize() function handles responsive behavior when the chart container changes size:
export function resize({
container,
slots = [],
slotConfigurations = [],
options = {},
language = 'en',
dimensions: { width = 600, height = 400 } = {}
}: ChartParams): void {
// 1. Retrieve stored data from previous render
const chartData = (container as any).__chartData || [];
// 2. Clear container but preserve data
container.innerHTML = '';
// 3. Re-render with new dimensions
const svg = d3.select(container)
.append('svg')
.attr('width', width)
.attr('height', height);
// 4. Redraw with stored data
// Your chart rendering code using the chartData
// 5. Maintain state for future resizes
(container as any).__chartData = chartData;
}IMPORTANT: The
buildQuery()method is completely optional. If you don't implement this method, Luzmo will automatically generate and run the appropriate query for your chart based on the slots configuration. You only need to implement this method if you want to customize the query behavior.
Example implementation:
interface BuildQueryParams {
slots: Slot[];
slotConfigurations: SlotConfig[];
}
export function buildQuery({
slots = [],
slotConfigurations = []
}: BuildQueryParams): ItemQuery {
const dimensions: ItemQueryDimension[] = [];
const measures: ItemQueryMeasure[] = [];
// Extract category dimension
const categorySlot = slots.find(slot => slot.name === 'category');
const categoryContent = categorySlot?.content;
if (categoryContent?.length > 0) {
const [category] = categoryContent;
dimensions.push({
dataset_id: category.datasetId || category.set,
column_id: category.columnId || category.column,
level: category.level || 1
});
}
// Extract measure
const measureSlot = slots.find(slot => slot.name === 'measure');
const measureContent = measureSlot?.content;
if (measureContent?.length > 0) {
const [measure] = measureContent;
// Handle different types of measures
if (measure.aggregationFunc && ['sum', 'average', 'min', 'max', 'count'].includes(measure.aggregationFunc)) {
measures.push({
dataset_id: measure.datasetId || measure.set,
column_id: measure.columnId || measure.column,
aggregation: { type: measure.aggregationFunc }
});
}
else {
measures.push({
dataset_id: measure.datasetId || measure.set,
column_id: measure.columnId || measure.column
});
}
}
// Add ordering by category, if category slot is filled.
const order = categoryContent?.[0]
?
[{
dataset_id: categoryContent[0].datasetId || categoryContent[0].set,
column_id: categoryContent[0].columnId || categoryContent[0].column,
order: 'asc'
}]
: [];
// Add default limit of 10000 rows for performance reasons.
const limit = { by: 10000, offset: 0 };
const query: ItemQuery = {
dimensions,
measures,
order,
limit
};
return query;
}For more information about the query syntax and available options, see the Luzmo Query Syntax Documentation.
You can notify the dashboard that the query has been updated by sending a queryLoaded event to the parent window. More information about this event can be found in the Query loaded event section.
window.parent.postMessage({ type: 'queryLoaded', query }, '*');Luzmo provides a powerful formatter utility that helps format your data based on the format configured for the column (which can be changed by the user in the dashboard editor). You can import this utility from @luzmo/analytics-components-kit/utils:
import { formatter } from '@luzmo/analytics-components-kit/utils';It takes a slot content (i.e. a column) as an argument and returns a function that formats the data based on the format configured for the column.
The formatter function automatically handles:
- Number formatting (thousands separators, decimal places)
- Date/time formatting
- Currency formatting
- Percentage formatting
Example usage in your chart:
import { formatter } from '@luzmo/analytics-components-kit/utils';
export function render({ data, slots }: ChartParams): void {
// Create formatters for your slots
const measureFormatter = slots.find(s => s.name === 'measure')?.content[0]
? formatter(slots.find(s => s.name === 'measure')!.content[0])
: (val: any) => String(val);
const categoryFormatter = slots.find(s => s.name === 'category')?.content[0]
? formatter(slots.find(s => s.name === 'category')!.content[0])
: (val: any) => String(val);
const categoryValue = row[0]?.name?.en || row[0] || 'Unknown';
const formattedCategory = categoryFormatter(
categorySlot.content[0].type === 'datetime'
? new Date(categoryValue)
: categoryValue
);
// Use the formatters
const formattedData = data.map(row => ({
category: formattedCategory,
value: measureFormatter(row[1])
}));
}Your custom chart can be styled dynamically based on the chart or dashboard theme configured by the user. The options object passed to the render() function always contains a theme property that you can use to customize the chart's appearance.
This theme property is of type ThemeConfig (available from the @luzmo/dashboard-contents-types library) and contains following properties.
interface ThemeConfig {
axis?: Record<'fontSize', number> // Font size of the axis labels.
background?: string; // Background color of the dashboard canvas.
borders?: {
'border-color'?: string; // Color of the border
'border-radius'?: string; // Radius of the border
'border-style'?: string; // Style of the border
'border-top-width'?: string; // Top width of the border
'border-right-width'?: string; // Right width of the border
'border-bottom-width'?: string; // Bottom width of the border
'border-left-width'?: string; // Left width of the border
}; // Border styling.
boxShadow?: {
size?: 'S' | 'M' | 'L' | 'none'; // Size of the boxshadow.
color?: string; // Color of the boxshadow.
}; // Box shadow styling.
colors?: string[]; // Custom color palette, an array of colors used when a chart needs multiple colors (e.g. donut chart).
font?: {
fontFamily?: string; // Font family used in the chart.
fontSize?: number; // Font size in px.
'font-weight'?: number; // Font weight.
'font-style'?: 'normal'; // Font style.
}; // Font styling.
itemsBackground?: string; // Background color of the chart.
itemSpecific?: {
rounding?: number; // Rounding of elements in the chart.
padding?: number; // Padding between elements in the chart.
};
legend?: {
type?: 'normal' | 'line' | 'circle'; // Display type of the legend.
fontSize?: number; // Font size of the legend in px.
lineHeight?: number; // Line height of the legend in px.
}; // Legend styling, applied if a legend is displayed.
mainColor?: string; // Main color of the theme.
title?: {
align?: 'left' | 'center' | 'right'; // Alignment of the title
bold?: boolean; // Whether the title is bold
border?: boolean; // Whether the title has a bottom border
fontSize?: number; // Font size of the title in px
italic?: boolean; // Whether the title is italic
lineHeight?: number; // Line height of the title in px
underline?: boolean; // Whether the title is underlined
}; // Title styling, applied if a title is displayed.
tooltip?: {
fontSize?: number; // Font size of the tooltip in px
background?: string; // Background color of the tooltip
lineHeight?: number; // Line height of the tooltip in px
opacity?: number; // Opacity of the tooltip
}; // Tooltip styling, applied if a tooltip is displayed (e.g. on hover over a bar in a bar chart).
}Example usage:
import { ThemeConfig } from '@luzmo/dashboard-contents-types';
// In your chart.ts file
export function render({
container,
data = [],
slots = [],
slotConfigurations = [],
options = {},
language = 'en',
dimensions: { width = 600, height = 400 } = {}
}: ChartParams): void {
// Extract theme from options
const theme: ThemeConfig = options.theme;
// Clear container and set background
container.innerHTML = '';
container.style.backgroundColor = theme.itemsBackground;
// Create main chart container with dynamic theme properties
const chartContainer = document.createElement('div');
chartContainer.className = 'chart-container';
chartContainer.style.fontFamily = theme.font?.fontFamily || 'system-ui, sans-serif';
chartContainer.style.fontSize = (theme.font?.fontSize || 13) + 'px';
// Add a title that uses mainColor
const titleElement = document.createElement('h2');
titleElement.textContent = 'Chart Title';
titleElement.style.color = theme.mainColor;
chartContainer.appendChild(titleElement);
}The chart.css file allows you to add custom styles to your chart elements. The CSS is bundled with your chart and isolated from the dashboard styles.
Example:
.bar-chart-container {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.chart-title {
font-size: 16px;
font-weight: 600;
text-align: center;
}
.axis path,
.axis line {
stroke: #e0e0e0;
}
.axis text {
font-size: 12px;
fill: #666;
}
.bar {
transition: opacity 0.2s;
}
.bar:hover {
opacity: 0.8;
}
.legend-item {
display: inline-flex;
align-items: center;
margin-right: 10px;
font-size: 12px;
}Your CSS will be minified during the build process and included in the final chart package.
Your custom chart can interact with the dashboard and other items in the dashboard by sending events to the parent window. There are three types of events you can send:
Filter events allow your chart to filter data in other dashboard items. The filter structure must match the ItemFilter type from the @luzmo/dashboard-contents-types library.
import { ItemFilter } from '@luzmo/dashboard-contents-types';
// Example of sending a filter event
function sendFilterEvent(filters: ItemFilter[]): void {
const eventData = {
type: 'setFilter', // Must always be 'setFilter'
filters: filters
};
// Post message to parent window
window.parent.postMessage(eventData, '*');
}
// Example usage in a click handler
function onBarClick(category: string, value: number): void {
const filters: ItemFilter[] = [
{
expression: '? = ?', // Filter expression
parameters: [
{
columnId: 'category-column-id', // Column to filter on
datasetId: 'dataset-id' // Dataset containing the column
},
category // Value to filter by
]
}
];
sendFilterEvent(filters);
}The ItemFilter interface has the following structure:
interface ItemFilter {
// Filter expression from a predefined list
expression: '? = ?' | '? != ?' | '? in ?' | '? not in ?' | '? like ?' | '? not like ?' |
'? starts with ?' | '? not starts with ?' | '? ends with ?' | '? not ends with ?' |
'? < ?' | '? <= ?' | '? > ?' | '? >= ?' | '? between ?' | '? is null' | '? is not null';
// Filter parameters
parameters: [
{
columnId?: string; // Column to filter on
datasetId?: string; // Dataset containing the column
level?: number; // Optional level for hierarchical or datetime data
},
number | string // Value to filter by (depends on column type)
];
}The exact type of parameters[1] depends on the column type you are filtering on:
- Numeric column: use a number (example:
100) - Datetime column: use an ISO8601 datetime string (example:
'2025-01-01T00:00:00.000Z') - Hierarchy column: use a string that matches the
idfield of the hierarchyItemDataobject (example:'North America')
const numericFilter: ItemFilter = {
expression: '? >= ?',
parameters: [
{
columnId: '< revenue column id >',
datasetId: '< sales dataset id >'
},
100
]
};
const datetimeFilter: ItemFilter = {
expression: '? >= ?',
parameters: [
{
columnId: '< created_at column id > ',
datasetId: '< sales dataset id >'
},
'2025-01-01T00:00:00.000Z'
]
};
const hierarchyFilter: ItemFilter = {
expression: '? = ?',
parameters: [
{
columnId: '< region column id >',
datasetId: '< sales dataset id >'
},
'North America'
]
};Custom events allow your chart to send any data from your chart to the dashboard for custom handling. This custom event can then further travel from the dashboard to your own application (if the dashboard is embedded), allowing you to create flexible and powerful workflows in your own application.
The event type must always be 'customEvent', but you can include any data structure you need.
// Example of sending a custom event
function sendCustomEvent(eventType: string, data: any): void {
const eventData = {
type: 'customEvent', // Must always be 'customEvent'
data: {
eventType: eventType, // Your custom event type
...data // Any additional data you want to send
}
};
// Post message to parent window
window.parent.postMessage(eventData, '*');
}
// Example usage in a click handler
function onDataPointClick(category: string, value: number): void {
sendCustomEvent('dataPointSelected', {
category: category,
value: value,
timestamp: new Date().toISOString()
});
}Notify the dashboard that the query of the custom chart has been updated by sending a queryLoaded event to the parent window.
The dashboard will then use the updated query to refetch the data and rerender the chart.
window.parent.postMessage({ type: 'queryLoaded', query }, '*');You can use the buildQuery() function to build the query. See the Implementing your chart in chart.ts section for more information.
The query must be sent as an object of type ItemQuery (available from the @luzmo/dashboard-contents-types library).
This is useful if you want to update the query dynamically, for example if the users sorts data in an interactive chart or when you want to implement pagination in a custom table.
You can install and use third party libraries in your chart. Add them to the package.json file of the custom-chart project and import them in your chart.ts file to start using them.
For example, interesting libraries you can use to develop your chart are:
- D3.js
- Chart.js
- Tanstack Table
- ...
To create a distribution-ready package that can be uploaded to Luzmo:
npm run buildThis command:
- Builds the chart
- Validates the manifest.json against the schema
- Creates a ZIP archive (bundle.zip) containing all required files, ready for upload to Luzmo
To validate your manifest.json without building:
npm run validate- Manifest validation errors: Check your slot configuration against the schema
- Chart not rendering: Verify your data structure matches what your render function expects
- Build errors: Check the console for detailed error messages
- Builder logs appear with the [ANGULAR] prefix
- Bundle server logs appear with the [BUNDLE] prefix
- Chart watcher logs appear with the [WATCHER] prefix