src/app/features/location/map/map.component.ts

Implements

AfterViewInit

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

displayedProperties
Type : LocationProperties
entities
Type : Entity[]
Default value : []
expandable
Type : boolean
Default value : false
height
Type : string
Default value : "200px"
highlightedEntities
Type : Entity[]
Default value : []
marked
Type : Coordinates[]
Default value : []

Coordinates to show as extra markers on the map, not linked to any entity.

showMapOnly
Type : boolean
Default value : false

If true, shows only the map with pins and a close button. Hides address search, selected location, and save/cancel actions.

Outputs

displayedProperties
Type : LocationProperties
entityClick
Type : Entity
mapClick
Type : Coordinates

Methods

Async openMapInPopup
openMapInPopup()
Returns : any
openMapPropertiesPopup
openMapPropertiesPopup()
Returns : void

Properties

showPropertySelection
Type : unknown
Default value : signal(false)
import {
  AfterViewInit,
  ChangeDetectionStrategy,
  Component,
  effect,
  ElementRef,
  inject,
  input,
  model,
  output,
  signal,
  ViewChild,
} from "@angular/core";
import { MatDialog } from "@angular/material/dialog";
import { MatButtonModule } from "@angular/material/button";
// default import, not `import * as L`: leaflet.markercluster is a side-effect plugin that adds
// markerClusterGroup to leaflet's live CommonJS exports object. The esbuild application builder
// synthesises a *copied* namespace object for `import * as`, so the plugin's additions would not
// be visible on it and L.markerClusterGroup would be undefined at runtime.
import L from "leaflet";
import "leaflet.markercluster";
import { BehaviorSubject, Subject } from "rxjs";
import { ConfigService } from "../../../core/config/config.service";
import { MAP_CONFIG_KEY, MapConfig } from "../map-config";
import { Entity } from "../../../core/entity/model/entity";
import { Coordinates } from "../coordinates";
import { GeoResult } from "../geo.service";
import { createColoredDivIcon, getLocationProperties } from "../map-utils";
import { MapPopupConfig } from "../map-popup/map-popup.component";
import {
  LocationProperties,
  MapPropertiesPopupComponent,
} from "./map-properties-popup/map-properties-popup.component";
import { FaDynamicIconComponent } from "../../../core/common-components/fa-dynamic-icon/fa-dynamic-icon.component";

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-map",
  templateUrl: "./map.component.html",
  styleUrls: ["./map.component.scss"],
  imports: [FaDynamicIconComponent, MatButtonModule],
})
export class MapComponent implements AfterViewInit {
  private readonly dialog = inject(MatDialog);
  private readonly configService = inject(ConfigService);

  private readonly startLocationDefault: L.LatLngTuple = [
    52.4790412, 13.4319106,
  ];

  @ViewChild("map") private mapElement: ElementRef<HTMLDivElement>;

  height = input<string>("200px");
  expandable = input<boolean>(false);

  /**
   * If true, shows only the map with pins and a close button.
   * Hides address search, selected location, and save/cancel actions.
   */
  showMapOnly = input<boolean>(false);

  /**
   * Coordinates to show as extra markers on the map, not linked to any entity.
   */
  marked = input<Coordinates[]>([]);
  entities = input<Entity[]>([]);
  highlightedEntities = input<Entity[]>([]);

  displayedProperties = model<LocationProperties>({});
  mapClick = output<Coordinates>();
  entityClick = output<Entity>();

  showPropertySelection = signal(false);

  private map: L.Map;
  private markerClusterGroup: L.MarkerClusterGroup;
  private mapInitialized = false;
  private lastMapClickTimestamp = 0;

  private markedState: Coordinates[] = [];
  private entitiesState: Entity[] = [];
  private highlightedEntitiesState: Entity[] = [];
  private displayedPropertiesState: LocationProperties = {};
  private previousEntitiesReference: Entity[] | undefined;

  private openPopupEntities$?: BehaviorSubject<Entity[]>;
  private openPopupHighlightedEntities$?: BehaviorSubject<Entity[]>;

  constructor() {
    const config = this.configService.getConfig<MapConfig>(MAP_CONFIG_KEY);
    if (config?.start) {
      this.startLocationDefault[0] = config.start[0];
      this.startLocationDefault[1] = config.start[1];
    }

    effect(() => {
      const marked = this.marked() ?? [];
      const entities = this.entities() ?? [];
      const highlighted = this.highlightedEntities() ?? [];
      const displayedProperties = this.displayedProperties() ?? {};

      this.markedState = marked;
      if (entities !== this.previousEntitiesReference) {
        this.previousEntitiesReference = entities;
        this.entitiesState = this.adjustOverlappingCoordinates(entities);
      }
      this.highlightedEntitiesState = highlighted;
      this.displayedPropertiesState =
        this.cloneDisplayedProperties(displayedProperties);
      this.showPropertySelection.set(
        Object.keys(this.displayedPropertiesState).length > 0,
      );

      this.openPopupEntities$?.next(this.entitiesState);
      this.openPopupHighlightedEntities$?.next(this.highlightedEntitiesState);

      this.updateMarkers();
    });
  }

  ngAfterViewInit() {
    this.map = L.map(this.mapElement.nativeElement, {
      center: this.startLocationDefault,
      zoom: 14,
    });

    this.map.addEventListener("click", (res) => {
      if (this.showMapOnly()) {
        return;
      }

      const now = Date.now();
      if (now - this.lastMapClickTimestamp < 400) {
        return;
      }

      this.lastMapClickTimestamp = now;
      this.mapClick.emit({ lat: res.latlng.lat, lon: res.latlng.lng });
    });

    const tiles = L.tileLayer(
      "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
      {
        maxZoom: 18,
        minZoom: 3,
        attribution:
          '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
      },
    );
    tiles.addTo(this.map);

    // Prevent gray tiles when opening directly on map pages.
    setTimeout(() => this.map.invalidateSize());
    this.initializeMap();
  }

  private initializeMap() {
    this.markerClusterGroup = L.markerClusterGroup({
      spiderfyOnMaxZoom: true,
      showCoverageOnHover: false,
      zoomToBoundsOnClick: true,
      maxClusterRadius: 20,
    });
    this.map.addLayer(this.markerClusterGroup);
    this.mapInitialized = true;
    this.updateMarkers();

    this.map.on("zoomend", () => {
      const allMarkers = this.markerClusterGroup.getLayers() as L.Marker[];
      allMarkers.forEach((marker: any) => {
        if (marker["entity"]) {
          this.addMarkerStyle(marker, marker["highlighted"]);
        }
      });
    });
  }

  /**
   * Adjust overlapping entity coordinates in-place so markers do not stack perfectly.
   */
  private adjustOverlappingCoordinates(entities: Entity[]): Entity[] {
    const locationOccurrencesMap = new Map<string, number>();
    const roundTo5Decimals = (n: any) => Number(Number(n).toFixed(5));

    entities.forEach((entity) => {
      const locationProperties = this.getMapProperties(entity);
      locationProperties.forEach((prop) => {
        const location = (entity as any)[prop]?.geoLookup as
          { lat: number; lon: number } | undefined;

        const lat = Number(location?.lat);
        const lon = Number(location?.lon);
        if (isNaN(lat) || isNaN(lon)) {
          return;
        }

        const coordinateKey = `${roundTo5Decimals(lat)}_${roundTo5Decimals(lon)}`;
        const occurrenceCount = locationOccurrencesMap.get(coordinateKey) || 0;

        if (occurrenceCount > 0) {
          const angle = (occurrenceCount * 45 * Math.PI) / 180;
          const dx = (10 / 111320) * Math.cos(angle);
          const dy = (10 / 111320) * Math.sin(angle);
          location.lat = lat + dy;
          location.lon = lon + dx;
        }

        locationOccurrencesMap.set(coordinateKey, occurrenceCount + 1);
      });
    });

    return entities;
  }

  private updateMarkers() {
    if (!this.mapInitialized) {
      return;
    }

    this.markerClusterGroup.clearLayers();

    const highlightedIds = new Set(
      this.highlightedEntitiesState?.map((e) => e.getId()) || [],
    );

    const allEntities = this.entitiesState || [];
    const normalEntities = allEntities.filter(
      (e) => !highlightedIds.has(e.getId()),
    );
    const highlightedEntities = allEntities.filter((e) =>
      highlightedIds.has(e.getId()),
    );

    const normalMarkers = this.createEntityMarkers(normalEntities, false);
    const highlightedMarkers = this.createEntityMarkers(
      this.highlightedEntitiesState,
      true,
    );
    const coordinateMarkers = this.createMarkers(this.markedState);

    this.handleMarkerHighlights(
      normalMarkers,
      highlightedMarkers,
      coordinateMarkers,
      highlightedEntities,
    );
  }

  private handleMarkerHighlights(
    normalMarkers: L.Marker[],
    highlightedMarkers: L.Marker[],
    coordinateMarkers: L.Marker[],
    highlightedEntities: Entity[],
  ) {
    const allMarkers = [
      ...normalMarkers,
      ...highlightedMarkers,
      ...coordinateMarkers,
    ];
    this.markerClusterGroup.addLayers(allMarkers);

    const markerByEntityId = new Map<number | string, L.Marker>();
    allMarkers.forEach((marker: any) => {
      if (marker["entity"]) {
        markerByEntityId.set(marker["entity"].getId(), marker);
      }
    });

    const highlightMarkers: L.Marker[] = highlightedEntities
      .map((entity) => markerByEntityId.get(entity.getId()))
      .filter((m): m is L.Marker => !!m);

    if (highlightMarkers.length === 1) {
      const marker = highlightMarkers[0];
      this.map.setView(marker.getLatLng(), Math.max(this.map.getZoom(), 12), {
        animate: true,
      });
      return;
    }

    if (highlightMarkers.length === 2) {
      this.markerClusterGroup.clearLayers();
      highlightMarkers.forEach((marker) =>
        this.markerClusterGroup.addLayer(marker),
      );

      const bounds = L.featureGroup(highlightMarkers).getBounds();
      this.map.fitBounds(bounds, { padding: [50, 50], maxZoom: 18 });
      return;
    }

    const targetMarkers =
      highlightMarkers.length > 0
        ? highlightMarkers
        : [...normalMarkers, ...coordinateMarkers];

    if (targetMarkers.length > 0) {
      const group = L.featureGroup(targetMarkers);
      this.markerClusterGroup.addLayers(targetMarkers);
      const bounds = group.getBounds();
      if (bounds.isValid()) {
        this.map.fitBounds(bounds, {
          padding: [50, 50],
          maxZoom: this.map.getZoom(),
        });
      }
    }
  }

  private createEntityMarkers(
    entities: Entity[],
    highlighted: boolean,
  ): L.Marker[] {
    const markers: L.Marker[] = [];
    entities
      .filter((entity) => !!entity)
      .forEach((entity) => {
        this.getMapProperties(entity)
          .map((prop) => entity[prop]?.geoLookup)
          .filter((loc: GeoResult) => !!loc)
          .forEach((loc: GeoResult) => {
            const entityColor = entity.getColor() || "#808080";
            const marker = L.marker([loc.lat, loc.lon], {
              icon: createColoredDivIcon(entityColor),
            });

            marker.bindTooltip(entity.toString());
            marker.on("click", () => this.entityClick.emit(entity));
            marker["entity"] = entity;
            marker["highlighted"] = highlighted;

            marker.on("add", () => {
              this.addMarkerStyle(marker, highlighted);
              if (highlighted) {
                marker.openTooltip();
                marker.bindTooltip(entity.toString(), { permanent: true });
              }
            });

            markers.push(marker);
          });
      });

    return markers;
  }

  /**
   * Apply opacity styling based on highlight status.
   */
  private addMarkerStyle(marker: L.Marker, highlighted: boolean) {
    const icon = marker["_icon"] as HTMLElement;
    const innerSpan = icon?.querySelector("span") as HTMLElement;

    if (innerSpan) {
      const opacityValue = highlighted ? "1" : "0.5";
      innerSpan.style.setProperty("opacity", opacityValue, "important");
      icon.style.setProperty("opacity", "1", "important");
    }
  }

  private getMapProperties(entity: Entity) {
    const existing = this.displayedPropertiesState[entity.getType()];
    if (existing) {
      return existing;
    }

    const locationProperties = getLocationProperties(entity.getConstructor());
    const updatedProperties = {
      ...this.displayedPropertiesState,
      [entity.getType()]: locationProperties,
    };
    this.displayedPropertiesState = updatedProperties;
    this.displayedProperties.set(updatedProperties);
    this.showPropertySelection.set(true);

    return locationProperties;
  }

  private createMarkers(coordinates: Coordinates[]): L.Marker[] {
    return (coordinates ?? [])
      .filter((coord) => !!coord)
      .map((coord) => {
        const marker = L.marker([coord.lat, coord.lon]);
        marker["highlighted"] = false;
        return marker;
      });
  }

  private cloneDisplayedProperties(
    properties: LocationProperties = {},
  ): LocationProperties {
    return Object.fromEntries(
      Object.entries(properties).map(([entityType, fields]) => [
        entityType,
        [...(fields ?? [])],
      ]),
    ) as LocationProperties;
  }

  async openMapInPopup() {
    const mapComponent = await import("../map-popup/map-popup.component");

    const entities$ = new BehaviorSubject(this.entitiesState);
    const highlightedEntities$ = new BehaviorSubject(
      this.highlightedEntitiesState,
    );
    this.openPopupEntities$ = entities$;
    this.openPopupHighlightedEntities$ = highlightedEntities$;

    const data: MapPopupConfig = {
      marked: this.markedState,
      entities: entities$,
      highlightedEntities: highlightedEntities$,
      entityClick: {
        next: (entity: Entity) => this.entityClick.emit(entity),
      } as Subject<Entity>,
      displayedProperties: this.cloneDisplayedProperties(
        this.displayedPropertiesState,
      ),
      showMapOnly: this.showMapOnly(),
    };

    this.dialog
      .open(mapComponent.MapPopupComponent, { width: "90%", data })
      .afterClosed()
      .subscribe(() => {
        this.openPopupEntities$ = undefined;
        this.openPopupHighlightedEntities$ = undefined;
        this.updatedDisplayedProperties(data.displayedProperties);
      });
  }

  private updatedDisplayedProperties(
    properties: LocationProperties | undefined,
  ) {
    const nextProperties = this.cloneDisplayedProperties(properties ?? {});
    this.displayedPropertiesState = nextProperties;
    this.displayedProperties.set(nextProperties);
    this.updateMarkers();
  }

  openMapPropertiesPopup() {
    this.dialog
      .open(MapPropertiesPopupComponent, {
        data: this.cloneDisplayedProperties(this.displayedPropertiesState),
      })
      .afterClosed()
      .subscribe((res: LocationProperties) => {
        if (res) {
          this.updatedDisplayedProperties(res);
        }
      });
  }
}
<div class="map-frame" [style.height]="height()">
  <div id="map" #map>
    <div class="action-elements">
      @if (expandable()) {
        <button
          mat-mini-fab
          (click)="openMapInPopup()"
          aria-label="Open map in popup"
          i18n-aria-label
        >
          <app-fa-dynamic-icon
            icon="expand"
            style="color: black"
          ></app-fa-dynamic-icon>
        </button>
      }
      @if (showPropertySelection()) {
        <button
          mat-mini-fab
          (click)="openMapPropertiesPopup()"
          aria-label="Open map properties"
          i18n-aria-label
        >
          <app-fa-dynamic-icon
            icon="location-dot"
            style="color: black"
          ></app-fa-dynamic-icon>
        </button>
      }
    </div>
  </div>
</div>

./map.component.scss

.map-frame {
  border: 1px solid lightgrey;
  border-radius: 2px;
}

#map {
  height: 100%;
}

.action-elements {
  position: absolute;
  bottom: 3px;
  left: 0;
  z-index: 1000;
}

button {
  background-color: white !important;
  margin-left: 3px;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""