DEV Community

Cover image for Using Apache ECharts with Lit and TypeScript

Using Apache ECharts with Lit and TypeScript

What are Web Components?

WebComponents.org explains it well:

Web components are a set of web platform APIs that allow you to create new custom, reusable, encapsulated HTML tags to use in web pages and web apps.

Custom components and widgets built on the Web Component standards will work across modern browsers and can be used with any JavaScript library or framework that works with HTML.

According to MDN, the 3 main APIs for Web Components are:

  1. Custom elements
  2. Shadow DOM
  3. HTML templates

For those interested in learning more, this CSS-Tricks.com article series is a great reference.

Since these APIs are all widely available, we can start using them to build custom components in production. However, there are libraries like Lit and Stencil that can simplify the development process.

Why might we need Web Components?

The FAST Project by Microsoft provides an in-depth discussion on the benefits of Web Components.

While better performance may be a reason for some, their framework-agnostic nature was the most important value proposition for our team at Manufac. For instance:

  1. Your team can build standard Web Components to be reused across different web apps without restricting your developers to a particular framework. This benefit is particularly valuable for large enterprises with multiple teams that have diverse skill sets and preferences.

  2. If your end product needs to integrate well with any web application, Web Components are ideal. For example, if you're building a payment solution and want to offer a ready-to-use payment button that developers can easily add to their web apps, would you prefer building separate versions for ReactJS, Angular, and VueJS? Or would you rather have a single framework-agnostic UI component?

Why not simply use JS/TS to create reusable components programmatically?

You certainly can. One way to do this is to write sub-routines that find, say, all divs with specific class names or IDs using methods such as document.getElementById, document.getElementsByClassName, and document.querySelector. You can then replace these elements with the programmatically created UI components of your choice.

However, Web Components offer an additional advantage via its shadow DOM API: encapsulation.

... because a custom element, by definition, is a piece of reusable functionality: it might be dropped into any web page and be expected to work. So it's important that code running in the page should not be able to accidentally break a custom element by modifying its internal implementation. Shadow DOM enables you to attach a DOM tree to an element, and have the internals of this tree hidden from JavaScript and CSS running in the page.

MDN provides an in-depth discussion about this here.

How can we build a portable choropleth component using Apache ECharts and Lit?

A. Import all the needed dependencies.

I am assuming that you have some familiarity with both Lit and Apache ECharts.

If not, then the following should help:
a. An interactive tutorial for Lit
b. Getting started with Apache ECharts

import { MapChart } from "echarts/charts";
import {
  TitleComponent,
  ToolboxComponent,
  TooltipComponent,
  VisualMapComponent,
  GeoComponent,
} from "echarts/components";
import { init, use, registerMap } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LitElement, PropertyValueMap, PropertyValues, html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import type { MapSeriesOption } from "echarts/charts";
import type {
  TitleComponentOption,
  TooltipComponentOption,
  ToolboxComponentOption,
  GeoComponentOption,
  VisualMapComponentOption,
} from "echarts/components";
import type { ComposeOption, ECharts } from "echarts/core";
import { WorldChoroplethMapGeoJSON } from "./utils";
import { ChoroplethStyles } from "./styles";
Enter fullscreen mode Exit fullscreen mode

The WorldChoroplethMapGeoJSON imported above is available here and ChoroplethStyles as shown below can contain your preferred styling:

import { css } from "lit";

export const ChoroplethStyles = css`
  .choropleth {
    width: 100%;
    height: 100%;
  }
`;
Enter fullscreen mode Exit fullscreen mode

B. Next, we'll configure ECharts to do some bundle size optimization:

/**
 * Adapted from Echarts.
 * Ref: https://echarts.apache.org/examples/en/editor.html?c=map-usa-projection&lang=ts
 */
type WorldChoroplethMapOptions = ComposeOption<
  | GeoComponentOption
  | MapSeriesOption
  | TitleComponentOption
  | ToolboxComponentOption
  | TooltipComponentOption
  | VisualMapComponentOption
>;

/**
 * Adapted from Echarts.
 * Ref: https://echarts.apache.org/examples/en/editor.html?c=map-usa-projection&lang=ts
 */
use([
  TitleComponent,
  TooltipComponent,
  VisualMapComponent,
  GeoComponent,
  MapChart,
  ToolboxComponent, // A group of utility tools, which includes export, data view, dynamic type switching, data area zooming, and reset.
  CanvasRenderer, // If you only need to use the canvas rendering mode, the bundle will not include the SVGRenderer module, which is not needed.
]);
Enter fullscreen mode Exit fullscreen mode

C. Now, we are telling ECharts which GeoJSON file to use to render the base map. This is the world map GeoJSON, but if you only need, say, the Indian or the US map, you can switch the GeoJSON file accordingly.

/**
 * Adapted from Echarts.
 * Ref: https://echarts.apache.org/examples/en/editor.html?c=map-usa-projection&lang=ts
 */
type RegisterMapParams = Parameters<typeof registerMap>;
registerMap("WorldChoroplethMap", WorldChoroplethMapGeoJSON as RegisterMapParams[1]);
Enter fullscreen mode Exit fullscreen mode

D. Next, time for some data modeling. How do we want to express the countries and associated data can be specific to your own use case. Nevertheless, at a basic level, we mainly need some way to identify the country and the associated numeric value to be visualized. In the data model below, we've have a no-frills interface which does just that:

interface WorldChoroplethDatum {
  name: string;
  value: number;
}
Enter fullscreen mode Exit fullscreen mode

The input data passed to the choropleth should now be like this: WorldChoroplethDatum[].

E. Finally, we've come to the core component implementation. You may want to read up a bit on the Lit lifecycle methods if the following class methods seem foreign:

/**
 * Using `firstUpdated` alongside `willUpdate` can ensure that the 
 * private variable is set correctly both initially and when properties 
 * change. 
 */
@customElement("world-choropleth")
export class WorldChoropleth extends LitElement {
  #instance: ECharts | undefined;
  #observer: ResizeObserver | undefined;
  #options: WorldChoroplethMapOptions | undefined;

  static styles = [ChoroplethStyles];

  @property({ type: Array })
  value: WorldChoroplethDatum[] = [];

  @property({ type: String })
  title = "";

  // Ref: https://lit.dev/docs/components/shadow-dom/#query
  @query("#choropleth", true)
  private _choropleth: HTMLDivElement | undefined | null;

  protected willUpdate(changedProperties: PropertyValues<this>): void {
    if (changedProperties.has("title")) {
      this.#options = {
        ...this.#options,
        title: { text: changedProperties.get("title") },
      };
    }
    if (changedProperties.has("value")) {
      const newValue = changedProperties.get("value") ?? [];
      const values = newValue.map((ele) => ele.value);
      this.#options = {
        ...this.#options,
        series: {
          ...(this.#options?.series as MapSeriesOption),
          data: newValue,
        },
        visualMap: {
          ...this.#options?.visualMap,
          min: Math.min(...values),
          max: Math.max(...values),
        },
      };
    }
  }

  protected firstUpdated = () => {
    // Initialize echarts instance
    if (this._choropleth !== null && this._choropleth !== undefined) {
      this.#instance = init(this._choropleth);
      this.#observer = new ResizeObserver(() => {
        this.#instance?.resize();
      });
      this.#observer.observe(this._choropleth);
      const values = this.value.map((ele) => ele.value);
      this.#options = {
        title: {
          left: "center",
          text: this.title,
        },
        tooltip: {
          trigger: "item",
          showDelay: 0,
          transitionDuration: 0.2,
        },
        toolbox: {
          show: true,
          left: "right",
          top: "top",
          feature: {
            saveAsImage: {},
            restore: {
              show: true,
              title: "Reset zoom",
            },
          },
        },
        visualMap: {
          id: "visual-map",
          type: "continuous",
          left: "right",
          min: Math.min(...values),
          max: Math.max(...values),
          inRange: {
            color: [
              "#313695",
              "#4575b4",
              "#74add1",
              "#abd9e9",
              "#e0f3f8",
              "#ffffbf",
              "#fee090",
              "#fdae61",
              "#f46d43",
              "#d73027",
              "#a50026",
            ],
          },
          text: ["High", "Low"],
          calculable: true,
          realtime: false,
        },
        series: {
          id: "WorldChoroplethMapSeries",
          name: "WorldChoroplethMap",
          type: "map",
          map: "WorldChoroplethMap", // Should be same as the map registered with 'registerMap'
          roam: true, // Used for zooming and spanning over the map. Ref: https://echarts.apache.org/en/option.html#series-map.roam
          /**
           * Associates individual map polygons to the key defined.
           * Ref: https://echarts.apache.org/en/option.html#series-map.nameProperty
           */
          nameProperty: "name",
          data: this.value,
        },
      };
    }
  };

  protected updated(_changedProperties: PropertyValues<this>): void {
    if (this.#options !== undefined) {
      this.#instance?.setOption(this.#options);
    }
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    this.#observer?.disconnect();
    this.#instance?.dispose();
  }

  render() {
    return html`<div id="choropleth" class="choropleth"></div>`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Why those lifecycle methods were used?

A. Why willUpdate?

It is a lifecycle method that gets called before the component updates. It is often used to perform operations that need to happen just before the update occurs, such as making changes to the state or properties based on the current and upcoming values.

In our case: Before updating, this method checks if the title or value properties have changed. If they have, it updates the #options object with new values. This prepares the component's state before the actual rendering.

B. Why firstUpdated?

It is called after the component's initial render, i.e., after the component has been added to the DOM for the first time. It’s used to perform tasks that require the component to be fully rendered and part of the DOM, such as interacting with the DOM, initializing third-party libraries, or setting up event listeners. It runs only once, immediately after the component's first render.

In our case: After the initial render, this method initializes the ECharts instance, sets up a ResizeObserver to handle resizing, and configures the chart options using the component's properties.

C. Why updated?

It is called after the component's update has been rendered to the DOM. It allows you to perform operations after the component has updated, such as interacting with the DOM based on new properties or states. It runs after each update (initial and subsequent updates).

In our case: After the component updates, this method sets the new options for the ECharts instance, ensuring that the chart reflects the latest data and configuration.

D. Why disconnectedCallback?

It is a lifecycle method that is invoked when a component is removed from the DOM. This method is useful for performing cleanup tasks such as removing event listeners, canceling timers, or cleaning up any other resources that the component might have allocated. It runs when the component is detached from the DOM, such as when it is removed or replaced.

In our case: When the component is removed from the DOM, this method disconnects the ResizeObserver and disposes off the ECharts instance to clean up resources and consequently, prevent memory leaks.

How can you consume such a component in your apps?

You can find some quick instructions in the official Lit docs here. For ReactJS, Lit provides additional support; this integration guide will be very helpful.

If you'd like to consume the same choropleth component share above, it can be easily installed via NPM:

yarn add @manufac/web-components
Enter fullscreen mode Exit fullscreen mode

Further, we have added some example integrations in CodeSandbox to simplify the process:

  1. For a vanilla web app
  2. For a ReactJS web app

These examples should help you get started quickly and more conveniently.


Interested in learning how web components can be added to Wordpress blogs/sites? Let us know in the comments and we'll be happy to share another blog for the same. πŸ§‘β€πŸ’»πŸ˜€πŸ‘‹

Do consider web components for your framework agnostic UI requirements; they just might be what you need!


Top comments (0)