infusion-code/angular-maps

View on GitHub
src/components/map-polygon-layer.ts

Summary

Maintainability
F
1 wk
Test Coverage
import {
    Directive, SimpleChange, Input, Output, OnDestroy, OnChanges,
    EventEmitter, ContentChild, AfterContentInit, ViewContainerRef, NgZone,
    SimpleChanges
} from '@angular/core';
import { Subscription } from 'rxjs';
import { IPoint } from '../interfaces/ipoint';
import { ISize } from '../interfaces/isize';
import { ILatLong } from '../interfaces/ilatlong';
import { IPolygonEvent } from '../interfaces/ipolygon-event';
import { IPolygonOptions } from '../interfaces/ipolygon-options';
import { ILayerOptions } from '../interfaces/ilayer-options';
import { ILabelOptions } from '../interfaces/ilabel-options';
import { LayerService } from '../services/layer.service';
import { MapService } from '../services/map.service';
import { Layer } from '../models/layer';
import { Polygon } from '../models/polygon';
import { MapLabel } from '../models/map-label';
import { CanvasOverlay } from '../models/canvas-overlay';

/**
 * internal counter to use as ids for polygons.
 */
let layerId = 1000000;

/**
 * MapPolygonLayerDirective performantly renders a large set of polygons on a {@link MapComponent}.
 *
 * ### Example
 * ```typescript
 * import {Component} from '@angular/core';
 * import {MapComponent} from '...';
 *
 * @Component({
 *  selector: 'my-map-cmp',
 *  styles: [`
 *   .map-container {
 *     height: 300px;
 *   }
 * `],
 * template: `
 *   <x-map [Latitude]="lat" [Longitude]="lng" [Zoom]="zoom">
 *      <x-map-polygon-layer [PolygonOptions]="_polygons"></x-map-polygon-layer>
 *   </x-map>
 * `
 * })
 * ```
 *
 * @export
 */
@Directive({
    selector: 'x-map-polygon-layer'
})
export class MapPolygonLayerDirective implements OnDestroy, OnChanges, AfterContentInit {

    ///
    /// Field declarations
    ///
    private _id: number;
    private _layerPromise: Promise<Layer>;
    private _service: LayerService;
    private _canvas: CanvasOverlay;
    private _labels: Array<{loc: ILatLong, title: string}> = new Array<{loc: ILatLong, title: string}>();
    private _tooltip: MapLabel;
    private _tooltipSubscriptions: Array<Subscription> = new Array<Subscription>();
    private _tooltipVisible: boolean = false;
    private _defaultOptions: ILabelOptions = {
        fontSize: 11,
        fontFamily: 'sans-serif',
        strokeWeight: 2,
        strokeColor: '#000000',
        fontColor: '#ffffff'
    };
    private _streaming: boolean = false;
    private _polygons: Array<IPolygonOptions> = new Array<IPolygonOptions>();
    private _polygonsLast: Array<IPolygonOptions> = new Array<IPolygonOptions>();

    /**
     * Set the maximum zoom at which the polygon labels are visible. Ignored if ShowLabel is false.
     * @memberof MapPolygonLayerDirective
     */
    @Input() public LabelMaxZoom: number = Number.MAX_SAFE_INTEGER;

    /**
     * Set the minimum zoom at which the polygon labels are visible. Ignored if ShowLabel is false.
     * @memberof MapPolygonLayerDirective
     */
    @Input() public LabelMinZoom: number = -1;

    /**
     * Sepcifies styleing options for on-map polygon labels.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Input() public LabelOptions: ILabelOptions;

    /**
     * Gets or sets An offset applied to the positioning of the layer.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Input() public LayerOffset: IPoint = null;

    /**
     * An array of polygon options representing the polygons in the layer.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Input()
        public get PolygonOptions(): Array<IPolygonOptions> { return this._polygons; }
        public set PolygonOptions(val: Array<IPolygonOptions>) {
            if (this._streaming) {
                this._polygonsLast.push(...val.slice(0));
                this._polygons.push(...val);
            }
            else {
                this._polygons = val.slice(0);
            }
        }

    /**
     * Whether to show the polygon titles as the labels on the polygons.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Input() public ShowLabels: boolean = false;

    /**
     * Whether to show the titles of the polygosn as the tooltips on the polygons.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Input() public ShowTooltips: boolean = true;

    /**
     * Sets whether to treat changes in the PolygonOptions as streams of new markers. In this mode, changing the
     * Array supplied in PolygonOptions will be incrementally drawn on the map as opposed to replace the polygons on the map.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Input()
        public get TreatNewPolygonOptionsAsStream(): boolean { return this._streaming; }
        public set TreatNewPolygonOptionsAsStream(val: boolean) { this._streaming = val; }

    /**
     * Sets the visibility of the marker layer
     *
     * @memberof MapPolygonLayerDirective
     */
    @Input() public Visible: boolean;

    /**
     * Gets or sets the z-index of the layer. If not used, layers get stacked in the order created.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Input() public ZIndex: number = 0;

    ///
    /// Delegates
    ///

    /**
     * This event emitter gets emitted when the user clicks a polygon in the layer.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Output() public PolygonClick: EventEmitter<IPolygonEvent> = new EventEmitter<IPolygonEvent>();

    /**
     * This event is fired when the DOM dblclick event is fired on a polygon in the layer.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Output() PolygonDblClick: EventEmitter<IPolygonEvent> = new EventEmitter<IPolygonEvent>();

    /**
     * This event is fired when the DOM mousemove event is fired on a polygon in the layer.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Output() PolygonMouseMove: EventEmitter<IPolygonEvent> = new EventEmitter<IPolygonEvent>();

    /**
     * This event is fired on mouseout on a polygon in the layer.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Output() PolygonMouseOut: EventEmitter<IPolygonEvent> = new EventEmitter<IPolygonEvent>();

    /**
     * This event is fired on mouseover on a polygon in a layer.
     *
     * @memberof MapPolygonLayerDirective
     */
    @Output() PolygonMouseOver: EventEmitter<IPolygonEvent> = new EventEmitter<IPolygonEvent>();



    ///
    /// Property declarations
    ///

    /**
     * Gets the id of the marker layer.
     *
     * @readonly
     * @memberof MapPolygonLayerDirective
     */
    public get Id(): number { return this._id; }

    ///
    /// Constructor
    ///

    /**
     * Creates an instance of MapPolygonLayerDirective.
     * @param _layerService - Concreate implementation of a {@link LayerService}.
     * @param _mapService - Concreate implementation of a {@link MapService}.
     * @param _zone - Concreate implementation of a {@link NgZone} service.
     * @memberof MapPolygonLayerDirective
     */
    constructor(
        private _layerService: LayerService,
        private _mapService: MapService,
        private _zone: NgZone) {
        this._id = layerId++;
    }

    ///
    /// Public methods
    ///

    /**
     * Called after Component content initialization. Part of ng Component life cycle.
     *
     * @memberof MapPolygonLayerDirective
     */
    public ngAfterContentInit() {
        const layerOptions: ILayerOptions = {
            id: this._id
        };
        this._zone.runOutsideAngular(() => {
            const fakeLayerDirective: any = {
                Id : this._id,
                Visible: this.Visible,
                LayerOffset: this.LayerOffset,
                ZIndex: this.ZIndex
            };
            this._layerService.AddLayer(fakeLayerDirective);
            this._layerPromise = this._layerService.GetNativeLayer(fakeLayerDirective);

            Promise.all([
                this._layerPromise,
                this._mapService.CreateCanvasOverlay(el => this.DrawLabels(el))
            ]).then(values => {
                values[0].SetVisible(this.Visible);
                this._canvas = values[1];
                this._canvas._canvasReady.then(b => {
                    this._tooltip = this._canvas.GetToolTipOverlay();
                    this.ManageTooltip(this.ShowTooltips);
                });
                if (this.PolygonOptions) {
                    this._zone.runOutsideAngular(() => this.UpdatePolygons());
                }
            });
            this._service = this._layerService;
        });
    }

    /**
     * Called on component destruction. Frees the resources used by the component. Part of the ng Component life cycle.
     *
     * @memberof MapPolygonLayerDirective
     */
    public ngOnDestroy() {
        this._tooltipSubscriptions.forEach(s => s.unsubscribe());
        this._layerPromise.then(l => {
            l.Delete();
        });
        if (this._canvas) { this._canvas.Delete(); }
    }

    /**
     * Reacts to changes in data-bound properties of the component and actuates property changes in the underling layer model.
     *
     * @param changes - collection of changes.
     * @memberof MapPolygonLayerDirective
     */
    public ngOnChanges(changes: { [key: string]: SimpleChange }) {
        if (changes['PolygonOptions']) {
            this._zone.runOutsideAngular(() => {
                this.UpdatePolygons();
            });
        }
        if (changes['Visible'] && !changes['Visible'].firstChange) {
            this._layerPromise.then(l => l.SetVisible(this.Visible));
        }
        if ((changes['ZIndex'] && !changes['ZIndex'].firstChange) ||
            (changes['LayerOffset'] && !changes['LayerOffset'].firstChange)
        ) {
            throw (new Error('You cannot change ZIndex or LayerOffset after the layer has been created.'));
        }
        if ((changes['ShowLabels'] && !changes['ShowLabels'].firstChange) ||
            (changes['LabelMinZoom'] && !changes['LabelMinZoom'].firstChange) ||
            (changes['LabelMaxZoom'] && !changes['LabelMaxZoom'].firstChange)
        ) {
            if (this._canvas) {
                this._canvas.Redraw(true);
            }
        }
        if (changes['ShowTooltips'] && this._tooltip) {
            this.ManageTooltip(changes['ShowTooltips'].currentValue);
        }
    }

    /**
     * Obtains a string representation of the Marker Id.
     * @returns - string representation of the marker id.
     * @memberof MapPolygonLayerDirective
     */
    public toString(): string { return 'MapPolygonLayer-' + this._id.toString(); }

    ///
    /// Private methods
    ///

    /**
     * Adds various event listeners for the marker.
     *
     * @param p - the polygon for which to add the event.
     *
     * @memberof MapPolygonLayerDirective
     */
    private AddEventListeners(p: Polygon): void {
        const handlers = [
            { name: 'click', handler: (ev: MouseEvent) => this.PolygonClick.emit({Polygon: p, Click: ev}) },
            { name: 'dblclick', handler: (ev: MouseEvent) => this.PolygonDblClick.emit({Polygon: p, Click: ev}) },
            { name: 'mousemove', handler: (ev: MouseEvent) => this.PolygonMouseMove.emit({Polygon: p, Click: ev}) },
            { name: 'mouseout', handler: (ev: MouseEvent) => this.PolygonMouseOut.emit({Polygon: p, Click: ev}) },
            { name: 'mouseover', handler: (ev: MouseEvent) => this.PolygonMouseOver.emit({Polygon: p, Click: ev}) }
        ];
        handlers.forEach((obj) => p.AddListener(obj.name, obj.handler));
    }

    /**
     * Draws the polygon labels. Called by the Canvas overlay.
     *
     * @param el - The canvas on which to draw the labels.
     * @memberof MapPolygonLayerDirective
     */
    private DrawLabels(el: HTMLCanvasElement): void {
        if (this.ShowLabels) {
            this._mapService.GetZoom().then(z => {
                if (this.LabelMinZoom <= z && this.LabelMaxZoom >= z) {
                    const ctx: CanvasRenderingContext2D = el.getContext('2d');
                    const labels = this._labels.map(x => x.title);
                    this._mapService.LocationsToPoints(this._labels.map(x => x.loc)).then(locs => {
                        const size: ISize = this._mapService.MapSize;
                        for (let i = 0, len = locs.length; i < len; i++) {
                            // Don't draw the point if it is not in view. This greatly improves performance when zoomed in.
                            if (locs[i].x >= 0 && locs[i].y >= 0 && locs[i].x <= size.width && locs[i].y <= size.height) {
                                this.DrawText(ctx, locs[i], labels[i]);
                            }
                        }
                    });
                }
            });
        }
    }

    /**
     * Draws the label text at the appropriate place on the canvas.
     * @param ctx - Canvas drawing context.
     * @param loc - Pixel location on the canvas where to center the text.
     * @param text - Text to draw.
     */
    private DrawText(ctx: CanvasRenderingContext2D, loc: IPoint, text: string) {
        let lo: ILabelOptions = this.LabelOptions;
        if (lo == null && this._tooltip) { lo = this._tooltip.DefaultLabelStyle; }
        if (lo == null) { lo = this._defaultOptions; }

        ctx.strokeStyle = lo.strokeColor;
        ctx.font = `${lo.fontSize}px ${lo.fontFamily}`;
        ctx.textAlign = 'center';
        const strokeWeight: number = lo.strokeWeight;
        if (text && strokeWeight && strokeWeight > 0) {
                ctx.lineWidth = strokeWeight;
                ctx.strokeText(text, loc.x, loc.y);
        }
        ctx.fillStyle = lo.fontColor;
        ctx.fillText(text, loc.x, loc.y);
    }

    /**
     * Manages the tooltip and the attachment of the associated events.
     *
     * @param show - True to enable the tooltip, false to disable.
     * @memberof MapPolygonLayerDirective
     */
    private ManageTooltip(show: boolean): void {
        if (show && this._canvas) {
            // add tooltip subscriptions
            this._tooltip.Set('hidden', true);
            this._tooltipVisible = false;
            this._tooltipSubscriptions.push(this.PolygonMouseMove.asObservable().subscribe(e => {
                if (this._tooltipVisible) {
                    const loc: ILatLong = this._canvas.GetCoordinatesFromClick(e.Click);
                    this._tooltip.Set('position', loc);
                }
            }));
            this._tooltipSubscriptions.push(this.PolygonMouseOver.asObservable().subscribe(e => {
                if (e.Polygon.Title && e.Polygon.Title.length > 0) {
                    const loc: ILatLong = this._canvas.GetCoordinatesFromClick(e.Click);
                    this._tooltip.Set('text', e.Polygon.Title);
                    this._tooltip.Set('position', loc);
                    if (!this._tooltipVisible) {
                        this._tooltip.Set('hidden', false);
                        this._tooltipVisible = true;
                    }
                }
            }));
            this._tooltipSubscriptions.push(this.PolygonMouseOut.asObservable().subscribe(e => {
                if (this._tooltipVisible) {
                    this._tooltip.Set('hidden', true);
                    this._tooltipVisible = false;
                }
            }));
        }
        else {
            // remove tooltip subscriptions
            this._tooltipSubscriptions.forEach(s => s.unsubscribe());
            this._tooltipSubscriptions.splice(0);
            this._tooltip.Set('hidden', true);
            this._tooltipVisible = false;
        }
    }

    /**
     * Sets or updates the polygons based on the polygon options. This will place the polygons on the map
     * and register the associated events.
     *
     * @memberof MapPolygonLayerDirective
     * @method
     */
    private UpdatePolygons(): void {
        if (this._layerPromise == null) {
            return;
        }
        this._layerPromise.then(l => {
            const polygons: Array<IPolygonOptions> = this._streaming ? this._polygonsLast.splice(0) : this._polygons;
            if (!this._streaming) { this._labels.splice(0); }

            // generate the promise for the markers
            const lp: Promise<Array<Polygon>> = this._service.CreatePolygons(l.GetOptions().id, polygons);

            // set markers once promises are fullfilled.
            lp.then(p => {
                p.forEach(poly => {
                    if (poly.Title != null && poly.Title.length > 0) { this._labels.push({loc: poly.Centroid, title: poly.Title}); }
                    this.AddEventListeners(poly);
                });
                this._streaming ? l.AddEntities(p) : l.SetEntities(p);
                if (this._canvas) { this._canvas.Redraw(!this._streaming); }
            });
        });
    }

}