DEV Community

Cover image for Data-Driven Components
ng-conf
ng-conf

Posted on

Data-Driven Components

Jim Armstrong | ng-conf | Feb 2021

Runtime Templates Using String Identifiers

Many of the applications I develop for clients most often involve mathematical sciences, ranging from physics and engineering to financial modeling to data analytics. These applications regularly employ components in which the majority of the component’s runtime display is driven by external data. We are all familiar with using binding to alter some portion of a component. A fully data-driven component will also have other aspects of its display controlled by external data.

For example, it is common in the utility industry to display, analyze, and even edit load profiles. Each profile contains one or more time series such as ‘raw data,’ ‘cleansed data’, ‘gross’, ‘AMI’, etc. Each of these series has some meaning to the utility company and each profile requires time series to be displayed in a certain order in a chart.

Display order can affect more than just a chart. In one application, changing the series display also required modifying the order in which data is displayed in a table (along with headers) and the order in which data is exported to CSV or JSON. So, a simple change in the order time series were displayed in a chart affected multiple areas of an already large Angular component.

As new profiles come online and change requests are processed, it is much easier to specify everything pertaining to profile display in JSON data. Changing the order in which series are displayed in a single profile, for example, becomes a simple data modification, not an Angular component modification.

Making a component data-driven reduces file size and complexity as well as greatly simplifies code changes — even to the point where they could be made by a non-programmer. This actually happened while I was on vacation; a last-second change request was implemented by a manager who edited the relevant data file before I even saw the change request being discussed on Slack :)

There is another level of data-driven component that uses a technique I’ve employed for a couple years that I wish to discuss in this article. This involves invoking a template at runtime using a string identifier. The identifier is part of the JSON file that drives the component display.

The use case to illustrate this technique is a map legend.

Map Legends

Map applications are often thought of as one-off developments, with each new application being independent of others. I have two clients, in fact, that make multiple uses of maps inside the same application and they wish to develop multiple variations of a core application for different clients. Some maps may require legends and others do not.

Different maps inside the same application may use different icons to represent the same items across different sections (routes) of the application. Combine that with a desire for multi-tenancy and a developer might be called upon to develop numerous maps and legends all of which share common segments of code and template. A typical approach, however, is to develop a single component/template for each map and each legend.

The ideal situation with so many possible use cases is to drive them with a single component and a single template.

The Process

Following is a high-level outline of the overall process to keep in mind while reading the remainder of the article. The specific use case is a general-purpose map legend with SVG icons. These icons can be assigned data to control their display and then arbitrarily associated with any legend item entirely through external data.

1 — Create an attribute directive with a single (string) Input that represents a template name. Inject a TemplateRef<any> into the constructor and provide an accessor to return the template reference.

2 — Create an icon component with an ng-template for each SVG icon, including all variables required to assign SVG attributes. This component should assign the attribute directive in step 1 along with a unique string identifier for each icon template. This component should have a method to return a template reference given a string identifier.

3 — Create metadata in JSON to define a concrete implementation of an example legend.

4 — Create a map legend component that parses the legend metadata. This component should pass the template identifiers for the SVG icons through its own template.

5 — Use the ngTemplateOutlet and ngTemplateOutletContext Inputs along with the template reference accessor to dynamically instantiate the icon template specified in metadata and assign the necessary attribute values for each icon.

Dynamic Template Directive and Icons Component

First, let’s start with a relatively simple Angular directive,

import {
  Directive,
  Input,
  TemplateRef
} from '@angular/core';
@Directive({
  selector: '[dynamicTemplate]'
})
export class DynamicTemplateDirective
{
  @Input()
  public templateName: string;
  constructor(protected _template: TemplateRef<any>){}
  public get template(): TemplateRef<any> | null
  {
    return this._template ? this._template : null;
  }
}
Enter fullscreen mode Exit fullscreen mode

I’ve removed documentation and compacted the code a bit to save space. As an former assembly-language programmer, I love space and comments :)

This directive injects a TemplateRef<any>, which provides a direct reference to a containing Template. The directive contains a single Input, which is the string name associated with a Template. I’ve seen cases where authors combine the selector for an attribute directive with an Input of the same name. This allows the Angular Input mechanism, i.e. [selector-name]=”’someString’” to be used to simultaneously declare both an attribute and assign an Input. I think this can be confusing to junior Angular devs, so I tend to never use such a practice. In the above case, the directive’s selector and the string input name are distinct.

To see how this directive is used, consider the following component, which is dynamic-icon.component.ts in my application,

import {
  AfterViewInit,
  Component,
  TemplateRef,
  QueryList,
  ViewChildren,
} from '@angular/core';
import { DynamicTemplateDirective } from '../directives/dynamic-template.directive';
@Component({
  selector: 'dynamic-svg-icons',
  templateUrl: './dynamic-icon.component.html'
})
export class DynamicIconsComponent implements AfterViewInit
{
  @ViewChildren(DynamicTemplateDirective)
  protected _templateRefs: QueryList<DynamicTemplateDirective>;
  protected _templateRefsArr: Array<DynamicTemplateDirective>;
  constructor()
  {
    this._templateRefsArr = [];
  }
  public ngAfterViewInit(): void
  {
    this._templateRefsArr = this._templateRefs.toArray();
  }
  public getTemplate(templateName: string): TemplateRef <any>
  {
    if (this._templateRefsArr.length === 0 ) {
      return null;
    }
    return this._templateRefsArr.find( (x: DynamicTemplateDirective) => x.templateName === templateName ).template;
  }
}
Enter fullscreen mode Exit fullscreen mode

along with its template, dynamic-icon.component.html,

<!-- Some simple SVG icons -->
<ng-template dynamicTemplate [templateName]="'svgCircle'"
             let-iconWidth="iconWidth"
             let-iconHeight="iconHeight"
             let-cx="cx"
             let-cy="cy"
             let-r="r"
             let-fill="fill"
             let-stroke="stroke"
             let-strokeWidth="strokeWidth"
             let-points="points"
>
  <svg [attr.height]="iconHeight" [attr.width]="iconWidth">
    <circle
      [attr.cx]="cx"
      [attr.cy]="cy"
      [attr.r]="r"
      [attr.fill]="fill"
      [attr.stroke]="stroke"
      [attr.stroke-width]="strokeWidth">
    </circle>
  </svg>
</ng-template>
<ng-template dynamicTemplate [templateName]="'svgRect'"
             let-iconWidth="iconWidth"
             let-iconHeight="iconHeight"
             let-fill="fill"
             let-stroke="stroke"
>
  <svg [attr.height]="iconHeight" [attr.width]="iconWidth">
    <rect
      [attr.width]="iconWidth"
      [attr.height]="iconHeight"
      [attr.fill]="fill"
      [attr.stroke]="stroke" />
  </svg>
</ng-template>
<ng-template dynamicTemplate [templateName]="'svgPolygon'"
             let-iconWidth="iconWidth"
             let-iconHeight="iconHeight"
             let-fill="fill"
             let-stroke="stroke"
             let-points="points">
  <svg [attr.height]="iconHeight" [attr.width]="iconWidth">
    <polygon [attr.points]="points" [attr.fill]="fill" [attr.stroke]="stroke" />
  </svg>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

There is nothing to render in this component; it is simply a container for three templates, namely an SVG Circle, SVG Rectangle, and an SVG Polygon. Variables such as those highlighted below will be later defined via a template context,

<ng-template dynamicTemplate [templateName]="'svgRect'"
             let-iconWidth="iconWidth"
             let-iconHeight="iconHeight"
             let-fill="fill"
             let-stroke="stroke"
>
  <svg [attr.height]="iconHeight" [attr.width]="iconWidth">
    <rect
      [attr.width]="iconWidth"
      [attr.height]="iconHeight"
      [attr.fill]="fill"
      [attr.stroke]="stroke" />
  </svg>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

Also note that if you’re new to Angular, you can bind to properties, but not attributes. SVG defines only attributes such as width, height, fill, stroke, etc. Note that we must apply attribute binding as shown above.

You will receive a runtime error upon attempting something like the following,

<!-- Not good -->
<ng-template dynamicTemplate [templateName]="'svgPolygon'"
             let-iconWidth="iconWidth"
             let-iconHeight="iconHeight"
             let-fill="fill"
             let-stroke="stroke"
             let-points="points">
  <svg [height]="iconHeight" [width]="iconWidth">
    <polygon [points]="points" [fill]="fill" [stroke]="stroke" />
  </svg>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

So, this dynamic icon component is nothing more than a placeholder for some icon templates, Template name as well as draw properties will be supplied from an external context.

This is actually quite cool as we’ve completely separated the rendering of map legend icons from the context in which they are used. Since all the data required to select a template and provide attribute values is external to the map legend, any variety of legends can be created simply by ‘dropping in’ a number of icon templates and adjusting the JSON data that drives the legend component.

But, speaking of that legend component, how do we actually link up our dynamic item components with the legend component? Well, first we need a data model.

Map Legend Data Model

Map Legend data consists of four items

1 — Legend Title

2 — Collection of class names for all sections of the map legend

3 — Collection of main headings or legend items

4 — Collection of optional secondary headings or legend items

Each main heading has a single title such as ‘Meters’. Individual meter types are legend items underneath that heading. Each of these legend items has an SVG icon and optional sub-text.

Here is a gist of the entire data model.

export type IconProps = LegendCircleData | LegendRectData | LegendPolygonData;

export interface MapLegendClasses
{
  legendTitleClass: string;

  legendContainerClass: Array<string>;

  legendHeadingClass: string;

  legendHeadingClass2: string;

  legendItemClass: string;

  legendSubTextClass: string;
}

export interface MapLegendData
{
  legendTitle: string;

  legendClasses: MapLegendClasses;

  headingData: Array<Array<LegendMainHeading>>;

  headingData2?: Array<Array<LegendMainHeading>>;
}

export interface LegendCircleData
{
  cx: number;

  cy: number;

  r: number;

  stroke: string;

  strokeWidth?: string;

  fill: string;
}

export interface LegendRectData
{
  width: number;

  height: number;

  stroke: string;

  fill: string;
}

export interface LegendPolygonData
{
  points: string;

  fill: string;

  stroke: string;
}

export interface LegendItem
{
  iconWidth: string;

  iconHeight: string;

  iconTemplateName: string;

  iconData: IconProps;
}

export interface LegendHeading
{
  title: string;

  item: LegendItem;

  subText?: string;
}

export interface LegendMainHeading
{
  title: string;

  headings: Array<LegendHeading>;
}
Enter fullscreen mode Exit fullscreen mode
map-legend.ts hosted by GitHub

It’s difficult to make sense of the abstract model by itself, so here is a concrete example.

import {
  MapLegendClasses,
  MapLegendData,
  LegendMainHeading
} from './map-legends';

export const legendClasses: MapLegendClasses =
{
  legendTitleClass: "title-text",

  legendContainerClass: ['map-legend', 'card'],

  legendHeadingClass: 'heading-text',

  legendHeadingClass2: 'heading-text-2',

  legendItemClass: 'legend-item',

  legendSubTextClass: 'legend-sub-text'
};

const meterHeadingData: Array<LegendMainHeading> = [
  {
    title: 'Meters',
    headings: [
      {
        title: 'Meter',
        item: {
          iconWidth: "10",
          iconHeight: "10",
          iconTemplateName: 'svgCircle',
          iconData: {
            cx: 5,
            cy: 5,
            r: 4,
            stroke: '#FF8800',
            strokeWidth: '1',
            fill: 'none'
          }
        },
        subText: 'Base Meter'
      },
      {
        title: 'Multiple Meters',
        item: {
          iconWidth: "11",
          iconHeight: "11",
          iconTemplateName: 'svgCircle',
          iconData: {
            cx: 5,
            cy: 5,
            r: 2,
            stroke: '#FF8800',
            strokeWidth: '2',
            fill: 'none'
          }
        },
        subText: '(same location)'
      },
      {
        title: 'Meter',
        item: {
          iconWidth: "10",
          iconHeight: "10",
          iconTemplateName: 'svgCircle',
          iconData: {
            cx: 5,
            cy: 5,
            r: 4,
            fill: 'CCCCCC',
            stroke: 'none'
          }
        },
        subText: '(background)'
      }
    ]
  }
];

const transformerHeadingData: Array<LegendMainHeading> = [
  {
    title: 'Transformers',
    headings: [
      {
        title: 'Transformer',
        item: {
          iconWidth: "10",
          iconHeight: "10",
          iconTemplateName: 'svgPolygon',
          iconData: {
            points: '0,10 5,0 10,10',
            stroke: 'none',
            fill: '#00C0FF'
          }
        }
      },
      {
        title: 'Transformer',
        item: {
          iconWidth: "10",
          iconHeight: "10",
          iconTemplateName: 'svgPolygon',
          iconData: {
            points: '0,10 5,0 10,10',
            stroke: 'none',
            fill: '#cccccc'
          }
        }
      }
    ]
  }
];

const routerHeadingData: Array<LegendMainHeading> = [
  {
    title: 'Routers',
    headings: [
      {
        title: 'Router',
        item: {
          iconWidth: "10",
          iconHeight: "10",
          iconTemplateName: 'svgRect',
          iconData: {
            width: 10,
            height: 10,
            stroke: 'none',
            fill: '#990099'
          }
        }
      },
      {
        title: 'Router',
        item: {
          iconWidth: "10",
          iconHeight: "10",
          iconTemplateName: 'svgRect',
          iconData: {
            width: 10,
            height: 10,
            stroke: 'none',
            fill: '#cccccc'
          }
        },
        subText: '(background)'
      }
    ]
  }
];

export const legendData: MapLegendData =
{
  legendTitle: 'Map Legend',

  legendClasses: legendClasses,

  headingData: [meterHeadingData],

  headingData2: [
    transformerHeadingData,
    routerHeadingData
  ]
};
Enter fullscreen mode Exit fullscreen mode

basic-legend.ts hosted by GitHub

This legend contains a single collection of ‘main’ headers under the general title of ‘Meters.’ There are three types of meters in the main heading, each of which is represented by a different icon.

There are two secondary headers, one for ‘Transformers’ and the other for ‘Routers.’

One of the most interesting pieces of metadata for a single legend item is highlighted below in bold type,

{
  title: 'Router',
  item: {
    iconWidth: "10",
    iconHeight: "10",
    iconTemplateName: 'svgRect',
    iconData: {
      width: 10,
      height: 10,
      stroke: 'none',
      fill: '#990099'
    }
  }
},
Enter fullscreen mode Exit fullscreen mode

This is the string identifier that indicates which SVG icon template is associated with rendering the icon. We’ve already laid the groundwork to implement this functionality, but it needs to be integrated into an actual map legend component.

Map Legend Component

The specific use case covered in this article involves map legends with two levels of display — a main level and an optional second level. The only difference between the two is styling.

Although a map legend seems like a relatively simple component, having to build potentially dozens of them across multiple applications across multiple clients is both time-consuming and inefficient. Even if same map legend component is extend and metadata overridden (i.e. change the template), that’s still a hassle across multiple applications. A mono-repo might ease some of that pain, but I personally prefer to build one component one time and vary the data that drives that component.

Linking Up Dynamic Item Component With Map Legend Component

Here is the absolute minimal implementation of a map legend component and its template. At this first step, it does not do anything other than render the legend title. Additional steps will be added incrementally so that you can create your own sample application and work through the steps yourself. You do not need something as complex as the metadata I’m using for map legends.

map-legend.component.ts

import {
  AfterViewInit,
  ChangeDetectorRef,
  Component,
  Input,
  OnChanges,
  SimpleChanges,
  TemplateRef,
  ViewChild
} from '@angular/core';

import { MapLegendData } from '../models/map-legends';

import { DynamicIconsComponent } from '../components/dynamic-icons.component';

@Component({
  selector: 'map-legend',

  templateUrl: 'map-legend.component.html',

  styleUrls: ['./map-legend.component.scss']
})
export class MapLegendComponent implements AfterViewInit, OnChanges
{
  // for binding
  public mapContainerClasses: string;
  public mapLegendData: MapLegendData;

  @Input()
  public legendData: MapLegendData;

  @ViewChild('svgIconsComponent', {static: true})
  protected _svgIconComponent: DynamicIconsComponent;

  constructor(protected _chgDetectorRef: ChangeDetectorRef)
  {
    this.mapContainerClasses = "";
  }

  public ngOnChanges(changes: SimpleChanges): void
  {
    let prop: string;
    for (prop in changes)
    {
      if (prop === 'legendData')
      {
        if (changes[prop].currentValue) {
          this.__parseLegendData(changes[prop].currentValue as MapLegendData);
        }
      }
    }
  }

  protected __parseLegendData(legendData: MapLegendData): void
  {
    this.mapContainerClasses = toSpacedList(legendData.legendClasses.legendContainerClass);

    this.mapLegendData = JSON.parse(JSON.stringify(legendData));
  }
}
Enter fullscreen mode Exit fullscreen mode

map-legends.component.html

<div [ngClass]="mapContainerClasses">
  <div align="center" [ngClass]="mapLegendData.legendClasses.legendTitleClass">{{mapLegendData.legendTitle}}</div>

  <div>
    . 
    .
    .
  </div>

  <dynamic-svg-icons #svgIconsComponent></dynamic-svg-icons>
</div>
Enter fullscreen mode Exit fullscreen mode

and, there is how the map legend is used in a parent component,

<map-legend [legendData]="legendData"></map-legend>
Enter fullscreen mode Exit fullscreen mode

So far, it’s just basic Angular, except that we slipped that dynamic icons component into the map legend template, after everything else (omitted above) is rendered. Remember that that component is just a holder; it does not contribute to any visual display. We just need it inside another template to ensure everything works with AOT compilation.

The Angular ViewChild decorator provides a reference to the embedded dynamic icon component. We can add the following method to the map legend component to reference a template from the dynamic icons component,

public getTemplate(name: string): TemplateRef<any>
{
  return this._svgIconComponent.getTemplate(name);
}
Enter fullscreen mode Exit fullscreen mode

The dynamic item component contains three instances of the dynamic item directive and it searches its ViewChildren to return the appropriate reference. Now, the getTemplate() method in the directive may return null if no such template exists or the ViewChildren are not yet defined. Keep this in mind for later …

To tie everything together in the map legend template, we define a container along with an ngTemplateOutlet Input and an ngTemplateOutletContext Input. We use the map legend’s getTemplate() method to return a template reference that is passed through ngTemplateOutlet, and we use the map legend metadata to fill assign all values used in the template’s context. Here is what it looks like in a standalone setting.

<ng-container
  [ngTemplateOutlet]="getTemplate(heading.item.iconTemplateName)"
  [ngTemplateOutletContext]="{
     iconWidth: heading.item.iconWidth,
     iconHeight: heading.item.iconHeight,
     cx: heading.item.iconData.cx,
     cy: heading.item.iconData.cy,
     r: heading.item.iconData.r,
     fill: heading.item.iconData.fill,
     stroke: heading.item.iconData.stroke,
     strokeWidth: heading.item.iconData.strokeWidth,
     points: heading.item.iconData.points
              }">
</ng-container>
Enter fullscreen mode Exit fullscreen mode

‘heading’ is a template variable that references legend heading data, including all icon data.

Note that not every attribute for every icon will be defined. A circular icon has no need for a points collection, for example. These values will, of course, be undefined in a circular template, but would never be referenced anyway.

Also note that there is a one-to-one correspondence between variables defined in the template context and those defined inside the actual icon template.

<ng-container
  [ngTemplateOutlet]="getTemplate(heading.item.iconTemplateName)"
  [ngTemplateOutletContext]="{
     iconWidth: heading.item.iconWidth,
     iconHeight: heading.item.iconHeight,
     cx: heading.item.iconData.cx,
     cy: heading.item.iconData.cy,
     r: heading.item.iconData.r,
     fill: heading.item.iconData.fill,
     stroke: heading.item.iconData.stroke,
     strokeWidth: heading.item.iconData.strokeWidth,
     points: heading.item.iconData.points
              }">
</ng-container>
Enter fullscreen mode Exit fullscreen mode

dynamic-icon.component.html

<!-- Some simple SVG icons -->
<ng-template dynamicTemplate [templateName]="'svgCircle'"
             let-iconWidth="iconWidth"
             let-iconHeight="iconHeight"
             let-cx="cx"
             let-cy="cy"
             let-r="r"
             let-fill="fill"
             let-stroke="stroke"
             let-strokeWidth="strokeWidth"
>
  <svg [attr.height]="iconHeight" [attr.width]="iconWidth">
    <circle
      [attr.cx]="cx"
      [attr.cy]="cy"
      [attr.r]="r"
      [attr.fill]="fill"
      [attr.stroke]="stroke"
      [attr.stroke-width]="strokeWidth">
    </circle>
  </svg>
</ng-template>

<ng-template dynamicTemplate [templateName]="'svgRect'"
             let-iconWidth="iconWidth"
             let-iconHeight="iconHeight"
             let-fill="fill"
             let-stroke="stroke"
>
  <svg [attr.height]="iconHeight" [attr.width]="iconWidth">
    <rect
      [attr.width]="iconWidth"
      [attr.height]="iconHeight"
      [attr.fill]="fill"
      [attr.stroke]="stroke" />
  </svg>
</ng-template>

<ng-template dynamicTemplate [templateName]="'svgPolygon'"
             let-iconWidth="iconWidth"
             let-iconHeight="iconHeight"
             let-fill="fill"
             let-stroke="stroke"
             let-points="points">
  <svg [attr.height]="iconHeight" [attr.width]="iconWidth">
    <polygon [attr.points]="points" [attr.fill]="fill" [attr.stroke]="stroke" />
  </svg>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

Working our way through the map legend metadata requires nested ngFor directives,

<div [ngClass]="mapContainerClasses">
  <!-- Legend Title -->
  <div align="center" [ngClass]="mapLegendData.legendClasses.legendTitleClass">{{mapLegendData.legendTitle}}</div>

  <!-- Main Legend -->
  <div *ngFor="let headingData of mapLegendData.headingData">
    <div *ngFor="let mainHeadings of headingData"> 

      <div [ngClass]="mapLegendData.legendClasses.legendHeadingClass">{{mainHeadings.title}}</div>

      <!-- Main Legend Items -->
      <div [ngClass]="mapLegendData.legendClasses.legendItemClass" *ngFor="let heading of mainHeadings.headings"> 
        <ng-container [ngTemplateOutlet]="getTemplate(heading.item.iconTemplateName)"
                      [ngTemplateOutletContext]="{
                         iconWidth: heading.item.iconWidth,
                         iconHeight: heading.item.iconHeight,
                         cx: heading.item.iconData.cx,
                         cy: heading.item.iconData.cy,
                         r: heading.item.iconData.r,
                         fill: heading.item.iconData.fill,
                         stroke: heading.item.iconData.stroke,
                         strokeWidth: heading.item.iconData.strokeWidth,
                         points: heading.item.iconData.points
                      }">
        </ng-container>
        {{heading.title}}
        <div [ngClass]="mapLegendData.legendClasses.legendSubTextClass" *ngIf="heading.hasOwnProperty('subText')">{{heading.subText}}</div>
      </div>
    </div>
  </div>

  <!-- Second-level Legend Headings -->

  <div *ngFor="let headingData of mapLegendData.headingData2">
    <div *ngFor="let mainHeadings of headingData"> <!-- mainHeadings: Array<LegendMainHeading> -->

  <div>
    .
    .
    .
  </div>

  <dynamic-svg-icons #svgIconsComponent></dynamic-svg-icons>
</div>
Enter fullscreen mode Exit fullscreen mode

Processing of the second-level legend data has been removed to conserve space.

Yes, that was rather complex and far more involved that you might attempt the first time. If you can get a simple example working with a single ngFor, that’s all you need to understand the general technique.

Ah, but there’s a catch …

That Pesky Angular Component LifeCycle

The getTemplate() method in the dynamic icon component will not return (non-null) results until that component’s after view init stage.

dynamic-icons.component.ts (excerpt)

public getTemplate(templateName: string): TemplateRef <any>
{
  if (this._templateRefsArr.length === 0 ) {
    return null;
  }

  return this._templateRefsArr.find( (x: DynamicTemplateDirective) => x.templateName === templateName ).template;
}
Enter fullscreen mode Exit fullscreen mode

The getTemplate() method will be called in advance of that life cycle stage by the map legend component.

So, you will see the infamous changed after check error (from null to [object Object]). The simplest way to overcome this problem is inside the map legend component’s after view init hander. Here is the completed version of that component.

map-legend.component.ts

import {
  AfterViewInit,
  ChangeDetectorRef,
  Component,
  Input,
  OnChanges,
  SimpleChanges,
  TemplateRef,
  ViewChild
} from '@angular/core';

import { MapLegendData } from '../models/map-legends';

import { toSpacedList } from '../libs/to-spaced-list';

import { DynamicIconsComponent } from '../components/dynamic-icons.component';

@Component({
  selector: 'map-legend',

  templateUrl: 'map-legend.component.html',

  styleUrls: ['./map-legend.component.scss']
})
export class MapLegendComponent implements AfterViewInit, OnChanges
{
  // for binding
  public mapContainerClasses: string;
  public mapLegendData: MapLegendData;

  @Input()
  public legendData: MapLegendData;

  @ViewChild('svgIconsComponent', {static: true})
  protected _svgIconComponent: DynamicIconsComponent;

  constructor(protected _chgDetectorRef: ChangeDetectorRef)
  {
    this.mapContainerClasses = "";
  }

  public ngOnChanges(changes: SimpleChanges): void
  {
    let prop: string;
    for (prop in changes)
    {
      if (prop === 'legendData')
      {
        if (changes[prop].currentValue) {
          this.__parseLegendData(changes[prop].currentValue as MapLegendData);
        }
      }
    }
  }

  public ngAfterViewInit(): void
  {
    this._chgDetectorRef.detectChanges();
  }

  public getTemplate(name: string): TemplateRef<any>
  {
    return this._svgIconComponent.getTemplate(name);
  }

  protected __parseLegendData(legendData: MapLegendData): void
  {
    this.mapContainerClasses = toSpacedList(legendData.legendClasses.legendContainerClass);

    this.mapLegendData = JSON.parse(JSON.stringify(legendData));
  }
}
Enter fullscreen mode Exit fullscreen mode

The toSpacedList() method is just a utility that converts an array of string values for class names to a spaced list for use in an ngClass Input.

And, here is the final result,

Data-driven map legend

Menu labeled "Map Legend". The first section of legends is "Meters". The first legend is an thin circle for "Meter - Base Meter", the second legend is a small thick circle for "Multiple Meters(same location)", and the third legend under "Meters" is a black dot for Meter(background). The second section of the Map Legend is for "Transformers". There are two legend, a blue triangle and a gray triangle. They are both for "Transformer". The last section of the "Map Legend" is for "Routers". The first is a purple square for "Router" and the second is a gray box for "Router(background)". End description.

Spend some time studying how this visual display matches up with the JSON used to define the legend.

You’re right if you are thinking that’s a LOT of work for what appears to be a relatively mundane component. However, it is quite common to change icons even for a simple legend when the same map is delivered to a different client. Colors may differ as part of multi-tenancy. Then, consider the number of legend combinations as different maps are supplied to different clients.

With a data-driven approach, a very small number of icon templates, and a single block of JSON data (which can be delivered from the back end), drives a single legend component and template. This combination can be re-used in an almost unlimited number of applications by only varying the data.

I’ve been using this technique for over two years and have found it to be invaluable. I hope you find something useful in this article and good luck with your Angular efforts!


ng-conf: Join us for the Reliable Web Summit

Come learn from community members and leaders the best ways to build reliable web applications, write quality code, choose scalable architectures, and create effective automated tests. Powered by ng-conf, join us for the Reliable Web Summit this August 26th & 27th, 2021.
https://reliablewebsummit.com/

Top comments (0)