Fragmented Thought

Creating dynamic AMCharts 5 themes from Material UI themes

AMCharts 5 splash screen
By

Published

Lance Gliser

I've been working with AMCharts for almost 2 years. The tooling for it is Typescript heaven, and they earned my money on just that at first. The support seen pretty decent support on GitHub, and better if licensed. They do have their own themes including a Material version.

Of course, someone else's work is never quite your own use case. Naturally we started looking to dynamically create an AMCharts theme based on our Material UI (4.x) theme. The 4.x was done a couple of years ago. Our 5.x came with a few changes just this week. I thought I'd post both in case you need to do the same.

AMCharts 4.x

The 4.x branch used returned functions that could be run. We use getMaterialSemanticsTheme to return our theming standards:

import { Theme as MaterialTheme } from "@material-ui/core"; import { ITheme as AMChartsTheme } from "@amcharts/amcharts4/.internal/themes/ITheme"; type GetAMChartsThemeFromMaterialTheme = ( theme: MaterialTheme ) => AMChartsTheme; const getMaterialSemanticsTheme: GetAMChartsThemeFromMaterialTheme = ( theme ) => { return (target) => { if (target instanceof InterfaceColorSet) { // target.setFor("stroke", color(theme.palette.background.default)); // target.setFor("fill", color(theme.palette.text.primary)); target.setFor("primaryButton", color(theme.palette.primary.main)); target.setFor("primaryButtonHover", color(theme.palette.primary.light)); target.setFor("primaryButtonDown", color(theme.palette.primary.dark)); target.setFor("primaryButtonActive", color(theme.palette.primary.dark)); target.setFor( "primaryButtonText", color(theme.palette.primary.contrastText) ); target.setFor("primaryButtonStroke", color(theme.palette.divider)); target.setFor("secondaryButton", color(theme.palette.secondary.main)); target.setFor( "secondaryButtonHover", color(theme.palette.secondary.light) ); target.setFor("secondaryButtonDown", color(theme.palette.secondary.dark)); target.setFor( "secondaryButtonActive", color(theme.palette.secondary.dark) ); target.setFor( "secondaryButtonText", color(theme.palette.secondary.contrastText) ); target.setFor("secondaryButtonStroke", color(theme.palette.divider)); target.setFor("grid", color(theme.palette.text.secondary)); target.setFor("background", color(theme.palette.background.default)); // target.setFor("alternativeBackground", color(theme.palette.text.primary)); target.setFor("text", color(theme.palette.text.primary)); // target.setFor("alternativeText", color(theme.palette.background.paper)); target.setFor( "disabledBackground", color(theme.palette.action.disabledBackground) ); target.setFor("positive", color(theme.palette.success.main)); target.setFor("negative", color(theme.palette.error.main)); } if (target instanceof AxisRenderer) { target.labels.template.fill = color(theme.palette.text.secondary); } }; };

Usage of getMaterialSemanticsTheme

The below is a stripped down example. We used a React.Context provider in the past as the theme elements from AMCharts were applied to all graphs.

import React, { useEffect, useState } from "react"; import { useTheme as useMaterialUITheme } from "@material-ui/core"; import { useTheme, unuseAllThemes, addLicense, InterfaceColorSet, color, } from "@amcharts/amcharts4/core"; export const Provider: React.FunctionComponent<ChartsContextProviderProps> = ({ children, }) => { const theme = useMaterialUITheme(); useEffect(() => { // Clear the existing themes on theme change unuseAllThemes(); if (theme.palette.type === "dark") { useTheme(am4themes_dark); } // Apply the elements of Material UI interface to the charts useTheme(getMaterialSemanticsTheme(theme)); setCurrentTheme(theme); }, [useAnimations, theme]); return ( <ChartsContextProvider.Provider value={{}}> {children} </ChartsContextProvider.Provider> ); };

AMCharts 5.x

In 5.x they've made great improvements in performance. Part of that involves a new statically created root, removing many of the global elements. Working with it has been greatly rewarding, and worth any headache it takes rewriting to gain encapsulation and performance.

Themes mechanism have changed from a returned function to an instantiation of a Theme like all of their static constructors. The standard Theme.new() call doesn't take any arguments, but it's easy enough to extend it with your own class, protected variable, and static method:

SemanticTheme.ts

import { Theme as AMChartsTheme } from "@amcharts/amcharts5/.internal/core/Theme"; import { Theme as MaterialTheme } from "@material-ui/core/styles/createTheme"; import { color, Root } from "@amcharts/amcharts5"; export class SemanticTheme extends AMChartsTheme { protected materialTheme: MaterialTheme | undefined = undefined; // This is basically a copy of what new() does static newFromMaterialTheme(root: Root, materialTheme: MaterialTheme) { const x = new this(root, true); // With one little addition x.materialTheme = materialTheme; x.setupDefaultRules(); return x; } setupDefaultRules() { const theme = this.materialTheme; if (!theme) { throw new Error("this.materialTheme is undefined"); } // Note that these will work non-cooperatively with Dark: this.rule("InterfaceColors").setAll({ // node_modules/@amcharts/amcharts5/.internal/themes/DarkTheme.js // stroke: color(0x000000), // fill: color(0x2b2b2b), // primaryButton: Color.lighten(Color.fromHex(0x6794dc), -0.2), // primaryButtonHover: Color.lighten(Color.fromHex(0x6771dc), -0.2), // primaryButtonDown: Color.lighten(Color.fromHex(0x68dc75), -0.2), // primaryButtonActive: Color.lighten(Color.fromHex(0x68dc76), -0.2), // primaryButtonText: color(0xffffff), // primaryButtonStroke: Color.lighten(Color.fromHex(0x6794dc), -0.2), // secondaryButton: color(0x3b3b3b), // secondaryButtonHover: Color.lighten(Color.fromHex(0x3b3b3b), 0.1), // secondaryButtonDown: Color.lighten(Color.fromHex(0x3b3b3b), 0.15), // secondaryButtonActive: Color.lighten(Color.fromHex(0x3b3b3b), 0.2), // secondaryButtonText: color(0xbbbbbb), // secondaryButtonStroke: Color.lighten(Color.fromHex(0x3b3b3b), -0.2), // grid: color(0xbbbbbb), // background: color(0x000000), // alternativeBackground: color(0xffffff), // text: color(0xffffff), // alternativeText: color(0x000000), // disabled: color(0xadadad), // positive: color(0x50b300), // negative: color(0xb30000), // Material implementation // stroke: color(theme.palette.background.default), // I'm still debating this one // fill: color(theme.palette.text.primary), // I'm still debating this one primaryButton: color(theme.palette.primary.main), primaryButtonHover: color(theme.palette.primary.light), primaryButtonDown: color(theme.palette.primary.dark), primaryButtonActive: color(theme.palette.primary.dark), primaryButtonText: color(theme.palette.primary.contrastText), primaryButtonStroke: color(theme.palette.primary.main), secondaryButton: color(theme.palette.secondary.main), secondaryButtonHover: color(theme.palette.secondary.light), secondaryButtonDown: color(theme.palette.secondary.dark), secondaryButtonActive: color(theme.palette.secondary.dark), secondaryButtonText: color(theme.palette.secondary.contrastText), secondaryButtonStroke: color(theme.palette.secondary.light), grid: color(theme.palette.text.secondary), background: color(theme.palette.background.default), alternativeBackground: color(theme.palette.text.primary), text: color(theme.palette.text.primary), alternativeText: color(theme.palette.background.paper), disabled: color(theme.palette.action.disabled), positive: color(theme.palette.success.main), negative: color(theme.palette.error.main), }); this.rule("AxisRenderer").setAll({ fill: color(theme.palette.text.secondary), }); } }

Usage of SemanticTheme.ts

The new root requirement makes this a lot easier to understand. You make a root, you use some themes on it immediately. If your theme changes, dispose the old one, and create a new root.

To chart theming standard, we created some root helper functions:

import { Root } from "@amcharts/amcharts5"; import am5themes_dark from "@amcharts/amcharts5/themes/Dark"; import am5themes_Animated from "@amcharts/amcharts5/themes/Animated"; import am5themes_Responsive from "@amcharts/amcharts5/themes/Responsive"; import am5themes_Micro from "@amcharts/amcharts5/themes/Micro"; import { Theme as AMChartsTheme } from "@amcharts/amcharts5/.internal/core/Theme"; import { Theme as MaterialTheme } from "@material-ui/core/styles"; import { SemanticTheme } from "../themes/SemanticTheme"; type NewRoot = ( /** The id of a container or an HTMLDivElement */ container: string | HTMLDivElement, settings?: RootSettings ) => Root; export interface RootSettings { /** Provides direct control over animations. If not provided the user's window preference will be used */ useAnimations?: boolean; /** Provides direct control over animations. Default: true */ useResponsive?: boolean; /** Enables the Micro theme - charts stripped down to a bare minimum (no labels, axes, etc.). Suitable creating charts with very minimal space requirements. */ useMicro?: boolean; materialTheme?: MaterialTheme; } /** * Returns a root with all of standard properties applied. * * If not directly specified, and window preferences are available * they will be used for determining animation and dark theme support. */ export const newRoot: NewRoot = (container, settings = {}) => { const root = Root.new(container); const themes = getThemes(root, settings); root.setThemes(themes); return root; }; type GetThemes = (root: Root, settings: RootSettings) => AMChartsTheme[]; const getThemes: GetThemes = (root, settings) => { // Themes const themes: AMChartsTheme[] = []; // Animations let useAnimations: boolean; if (typeof settings.useAnimations === "boolean") { useAnimations = settings.useAnimations; } else { useAnimations = window && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } if (useAnimations) { themes.push(am5themes_Animated.new(root)); } // Responsive const useResponsive: boolean = typeof settings.useResponsive === "boolean" ? settings.useResponsive : true; if (useResponsive) { themes.push(am5themes_Responsive.new(root)); } if (settings.useMicro) { themes.push(am5themes_Micro.new(root)); } getThemesFromMaterialTheme(root, settings.materialTheme).forEach((theme) => { themes.push(theme); }); return themes; }; const getThemesFromMaterialTheme = ( root: Root, materialTheme?: MaterialTheme ): AMChartsTheme[] => { const themes: AMChartsTheme[] = []; // If we weren't given a specific theme if (!materialTheme) { // At least respect the user's color scheme if (window && window.matchMedia("(prefers-color-scheme: dark)").matches) { themes.push(am5themes_dark.new(root)); } // TODO maybe respect their contrast preferences? return themes; } if (materialTheme.palette.type === "dark") { themes.push(am5themes_dark.new(root)); } const semanticTheme = SemanticTheme.newFromMaterialTheme(root, materialTheme); themes.push(semanticTheme); return themes; };

This makes creating theme dependent chart a breeze. Based on their own docs, we end up with a function component like this:

import React, { useRef, useLayoutEffect } from "react"; import { useTheme } from "@material-ui/core"; export const ExampleChart: React.FunctionComponent = () => { const theme = useTheme(); const element = useRef<HTMLDivElement | null>(null); useLayoutEffect(() => { if (!element.current) { throw new Error("element.current is not defined"); } const root = newRoot(element.current, { materialTheme: theme }); // Your own chart logic return () => root.dispose(); }, [theme]); return <div ref={element} />; }; export default ExampleChart;

Working with more complex AMCharts, data, timing, and reuse

The example here provides very over simplified usage. Trying to create a reusable component out of the React.FunctionComponent above would quickly become a snarl of callback props and race conditions. I do not suggest this approach more than a single usage. I'm investigating standardizing work into Context that house the root and standardize as much as we can, with smart children that can manage specifics and data this week. Hopefully that will become its own post in the coming weeks.

Looking forward to seeing you back!

Cheers