Skip to main content

How to Add a Component to Unovis

This guide will take you through the steps of adding a new component to our library.

Prerequisites

Before you start, make sure you familiarize yourself with our library's architecture. You should also be comfortable working with TypeScript and D3.

Getting Started

SVG vs HTML components

SVG components are rendered as g elements, and therefore are designed to exist inside of a svg container (XYContainer or SingleContainer). HTML elements are designed to stand alone. In general, "core" components that display data (i.e. charts, plots and diagrams) will be SVGs and auxiliary components (i.e. annotations, legends and tooltips) will be HTML. Note the following exceptions:

  • A core component will be type HTML in special cases, for example when HTML canvas is needed for rendering (i.e. LeafletMap)
  • An auxiliary component should be SVG if it is designed to exclusively work with XY data, in which case it should extend the XYComponentCore superclass which uses SVG type

Data Types

An important thing to consider beforehand is your component's expected data type. This will determine the component's superclass (if applicable), the corresponding datamodel and generics.

We use the Series data model for components that expect an array of arbitrary values (type any[]). This is the model used by XY Components and simpler charts like Donut. For more complex types, we have Graph and MapGraph datamodels, which expect links that connect arbitrary data values through source and target properties.

Data modeltypeof data
Seriesany[]
Graph{ nodes: N[]; links: L[] }
MapGraph{ areas?: any[]; points?: N[]; links?: L[]; }

where N extends NodeDatum and Link extends LinkDatum as defined below:

export interface NodeDatum {
id?: string;
}

export interface LinkDatum {
id?: number | string;
source: number | string | NodeDatum;
target: number | string | NodeDatum;
}
note

These data formats are not strictly defined because we also utilize accessor functions throughout the library in config files. For example, NodeDatum could contain an id property, but to allow for flexibility it would also be recommended to add a nodeId property to the component's configuration with the type StringAccessor<NodeDatum>.

XY/Core vs Standalone components

Data-bound components are considered Core and belong in a container. There are two subtypes: XY Components are for plotting data on XY axes/coordinate system and should extend XYComponentCoreInterface and Core Components are for plotting any other kind of data and should extend ComponentCoreInterface.

Auxiliary components should not extend from a superclass unless there is a valid reason to do so (for example if it is meant to work with XY data).

Component Code

File Structure

Your component will exist in the @unovis/ts library. Once you decide on a name for the component, create a new directory inside src/components called your-component-name. Note that we use kebab case naming convention for all files in this package.

The component directories follow this structure:

component-name
├── modules
│ └── # any extraneous logic/helper functions belong here in separate files
├── config.ts # ┓
├── index.ts # ┠ core files
├── style.ts # ┛
├── types.ts # any component-specific custom types go here

Configuration

The logic for component configuration is contained in config.ts. This file defines the configuration properties and sets default values for those properties. The general structure looks like:

config.ts
export interface ConfigInterface {
/** Property JSDoc. Default value: `` */
}
export class Config implements ConfigInterface {
// Default values
}

In most cases (i.e. for any non stand-alone component), Config and ConfigInterface should also extend their corresponding super classes. (ComponentConfig and ComponentConfigInterface for Core components, XYComponentConfig and XYComponentConfigInterface for XY components).

Selectors and Styling

The style.ts file declares selectors and sets default styles. Think of selectors as the structural building blocks that make up your component. (For example, Scatter contains a selector labeled "point", each of which are rendered as a SVG circle elements and have various SVG properties). Any CSS variables will also be initialized in this file.

The general template for a style.ts file looks like:

style.ts
import { css } from '@emotion/css'
import { getCssVarNames, injectGlobalCssVariables } from 'utils/style'

export const root = css`
label: component-name;
`
const cssVarDefaults = {
/* Mapping of CSS variables and their default values go here */
}

export const variables = getCssVarNames(cssVarDefaults)
injectGlobaleCSSVariables(cssVarDefault, root)

export const selectorName = css`
label: selector-name;
/* Style declarations for selector-name go here */
`

CSS Variables

Any component level variables you wish to introduce should be included as keys in cssVarDefaults object. Each one should be named accordingly:

--vis-component-name-selector-name-property-name

Any color variable should have a corresponding dark theme variable with the prefix --vis-dark.

ThengetCSSVarNames converts each variable name into a camel case format so you can use them with ease in your TypeScript files.

Example
A small illustration of how CSS variables and selectors are applied in Donut's style file.
donut/style.ts
export const root = css`
label: donut-component;
`

export const cssVarDefaults = {
'--vis-donut-background-color': '#E7E9F3',
'--vis-dark-donut-background-color': '#18160C',
}

export const variables = getCssVarNames(cssVarDefaults)
injectGlobaleCSSVariables(cssVarDefault, root)

export const background = css`
label: background;
fill: var(${variables.donutBackgroundColor});
`

Class Overview

The logic for rendering the component takes place in the index.ts file, which exports your component class. The general structure looks like:

index.ts
import { Config, ConfigInterface } from './config'
import * as s from './style'

export class Component<Datum> {
static selectors = s
static cssVariables = s.variables
config: Config<Datum> = new Config()
events = {}

// Private fields

constructor (config?: ConfigInterface<Datum>) {...}

// Class methods
}

Your class declaration will look slightly different depending on the type of component you're writing. XYComponents and CoreComponents should always extend from their corresponding super class. For data-bound components, you should also include your datamodel declaration (except for XY Components, since the superclass already declares it).

File Templates

Index File Templates
// Core
import { XYComponentCore } from 'core/xy-component'

import { Config, ConfigInterface } from './config'
import * as s from './style'

export class YourComponent<Datum> extends XYComponentCore<Datum, Config<Datum>, ConfigInterface<Datum>> {
static selectors = s
static cssVariables = s.variables
config: Config<Datum> = new Config()
events = {}

constructor (config?: ConfigInterface<Datum>) {
super()
if (config) this.config.init(config)

this.g.attr('class', s.root)
}

_render (customDuration?: number): void {

}
}

The render function

Every component should have a render function (or _render if you are extending from a superclass), which is where the logic for rendering the component takes place. It is important to have clearly defined enter, update and exit functionality so that the component can react to data updates correctly. (This is a helpful guide for understanding [how transitions work in D3)

We use the following convention inside render:

const selection  = this.g.selectAll<ElementType, DataType>(s.selectorName)
.data(data, /* unique identifier function */)

const selectionEnter = selection.enter().append(/* element type */)
.attr('class', s.selectorName)
// enter properties here

const selectionMerged = selection.merge(selectionEnter)
// update properties here

const selectionExit = selection.exit()
// exit properties here
.remove()

Using Modules

If you notice your render function becoming too lengthy, we recommend extracting the logic into a separate file in the modules directory for the sake of code clarity. This way you can use the call function on the selection instead of chaining a series of attr or style calls.

There are many examples of this throughout the code base, but here is a small example of how you might use the module approach in your component to render circles:

modules/circle.ts
export function updateCircle<Datum> (
selection: Selection<SVGCircleElement, Datum, SVGGElement, unknown>,
config: Config<Datum>,
duration?: number,
): void {
/** update logic goes here */
}
index.ts
const circlesMerged = circles.merge(circlesEnter)
.call(updateCircle, this.config, duration)

Updating the Wrappers

We have three wrappers for our library for usage in other frameworks: @unovis/angular, @unovis/react, @unovis/svelte. Each can be found in their named directory in packages. Before your component is complete you will have to add it to each wrapper. Luckily we've streamlined this process in the autogen directory of each wrapper. You will just need to do the following steps:

  1. Add the component's info to the components array in autogen/index.ts

  2. In the wrapper's root directory, run the following command:

    npm run generate
  3. Take a look at the generated component to see if it looks right. If not, you may need to manually write the component.

Visual Testing

To visually test library components we use a react app located in the packages/dev directory. To run it, from the root directory run:

npm run dev

and navigate to localhost:9500.

To add your own example page to the dev app, add a .tsx file to the dev/src/examples/ directory in its appropriate category directory. The general structure for each file path is:

examples/category/component/example/index.tsx

The file should include the following at a minimum:

import React, { useState } from 'react'

/* Title and subtitle for the sidebar */
export const title = ''
export const subTitle = ''

export const component = (): JSX.Element => {
/* Your test component here */
}

Adding Documentation

Please add a doc page for your component so others will understand how to use it. You can find our documentation pages in the packages/website/docs directory.

Local Setup

To run a local instance of the website, run:

npm run website

and navigate to localhost:9300.

MDX File Structure

The files for our components generally follow this structure:

Component.mdx
## Basic Configuration
The minimal/most basic way your component can be used.

<!--
This section should contain descriptions of
how to use the properties in your config
--->

## CSS Variables
A list of CSS variables and their default values

## Events
Supported events

## Component Props
A prop table generated from your JSDocs

Resources

Our website is built with Docusaurus. We highly recommend checking out their extensive documentation for reference.

Helper Components

Although not required, we have components available that are designed to streamline the doc-writing process. You can see many examples of how they are used throughout the docs directory, but here is a brief description of what they do:

  • DocWrapper - generates code snippets with rendered example below it (there are a few other variations, all of which can be found in the wrappers directory)
  • PropsTable - generates the Component Props table at the bottom of each doc page
  • CSSVariables - generates a CSS code block of computed CSS variable values

Final Checklist

Your component is considered ready when:

  • The @unovis/ts files are complete and everything compiles
  • It has at least one page in the dev app
  • It is exported by each wrapper library (@unovis/angular, @unovis/react and @unovis/svelte)
  • It has a doc page on the website
  • It is featured in the gallery (see our guide for how to add a gallery example here)

Once is it ready, feel free to make a pull request. We look forward to adding your component to the library!