How to Generate a JPG File from a Leaflet Map in Angular 4 using Typescript

I am developing a custom application using Angular4 that involves integrating leaflet maps. One of the requirements is to export the current view of a map as a JPG image, capturing only the map with markers and polylines - similar to taking a screenshot.

My approach involves adding markers and polylines to the leaflet map first, followed by implementing a functionality where pressing a button exports the current view along with the markers and polylines in either JPG or PNG format. The user should then be prompted to specify the location to save the resulting image.

I am curious if there are any existing solutions or plugins available for achieving this specific requirement. Any guidance or recommendations on how to accomplish this would be greatly appreciated.

Your assistance is invaluable. Thank you!

Answer №1

Below is a basic outline, feel free to insert your relevant code.

The final method saveSvgAsPng() can be found in this repository https://github.com/exupero/saveSvgAsPng, which enables you to convert an <svg> element into a PNG or data URL.

function convertToPng() {
  const mapContainerRect = yourLeafletMapInstance.getContainer().getBoundingClientRect();
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  const mapTiles = document.querySelectorAll('classe-of-map-tile-image');
  const markers = document.querySelectorAll('classe-of-marker');
  const polylines = document.querySelectorAll('polyline-element-class');

  svg.setAttribute('width', mapContainerRect.width;
  svg.setAttribute('height', mapContainerRect.height);

  mapTiles.forEach(tile => {
    const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
    const tileRect = tile.getBoundingClientRect();
    image.setAttribute('width', tileRect.width);
    image.setAttribute('height', tileRect.height);
    image.setAttribute('x', tileRect.left - mapContainerRect.left);
    image.setAttribute('y', tileRect.top - mapContainerRect.top);
    image.setAttribute('xlink:href', tile.src);
    svg.appendChild(image);
  });

  markers.forEach(marker => {
    const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
    const markerRect = marker.getBoundingClientRect();
    image.setAttribute('width', markerRect.width);
    image.setAttribute('height', markerRect.height);
    image.setAttribute('x', markerRect.left - mapContainerRect.left);
    image.setAttribute('y', markerRect.top - mapContainerRect.top);
    image.setAttribute('xlink:href', marker.src);
    svg.appendChild(image);
  });

  polylines.forEach(polyline => {
    const copy = polyline.cloneNode();
    svg.appendChild(copy);
  });


  saveSvgAsPng(svg, "map.png");
}

Answer №2

Encountered a similar issue, as using certain libraries to convert a DOM node to an image led to significant performance degradation due to HTML5 font downloads and processing.

I had to customize the solution provided by @Trash Can above. My specific requirement was also to upload the final PNG as a blob.

 // Save the map as a png image
  async mapToBlob() {
    
    let defaultNameSpace = 'http://www.w3.org/2000/svg';
    let xlinkNameSpace = 'http://www.w3.org/1999/xlink';

    let mapContainerRect = this.map.getContainer().getBoundingClientRect();

    var svg = document.createElementNS(defaultNameSpace,'svg');
    svg.setAttribute('height',mapContainerRect.height);
    svg.setAttribute('width',mapContainerRect.width);
    svg.setAttribute('id','svgMap');
    svg.setAttributeNS(xlinkNameSpace, "xlink:href", "link")

    let mapTiles = document.querySelectorAll('.leaflet-tile-loaded');
    let markers = document.querySelectorAll('.leaflet-marker-icon');

    mapTiles.forEach((tile, index) => {

      const image = document.createElementNS(defaultNameSpace, 'image');
      const tileRect = tile.getBoundingClientRect();
      image.setAttribute('width', tileRect.width.toString());
      image.setAttribute('height', tileRect.width.toString());
      image.setAttribute('x', (tileRect.left - mapContainerRect.left).toString());
      image.setAttribute('y', (tileRect.top - mapContainerRect.top).toString());
      image.setAttributeNS(xlinkNameSpace, 'href', (tile as any)?.src);
      svg.appendChild(image);
    });

    markers.forEach(marker => {

      const image = document.createElementNS(defaultNameSpace, 'image');
      const markerRect = marker.getBoundingClientRect();
      
      image.setAttribute('width', markerRect.width.toString());
      image.setAttribute('height', markerRect.height.toString());
      image.setAttribute('x', (markerRect.left - mapContainerRect.left).toString());
      image.setAttribute('y', (markerRect.top - mapContainerRect.top).toString());
      image.setAttributeNS(xlinkNameSpace, 'href',(marker as any)?.src);
      svg.appendChild(image);
    });

    // Hide the live map, and replace it with the SVG we want to render
    this.hideLiveMap = true;

    // Actually add the svg to the form (useful for debugging to see we have output)
    let form = document.querySelector('#dataForm');
    form.appendChild(svg);

    // Create an element for the conversion library to generate png from
    let svgElement = document.querySelector("#svgMap");
    //await saveSvgAsPng(svgElement, "test.png");
    let data = await svgAsPngUri(svgElement);

    //console.log("data", data);
    return data;
  }

...

async save(form: NgForm) {

    let pngData = await this.mapToBlob();
    
    fetch(pngData)
      .then(res => res.blob())
      .then(async blob => {
        

...

<form (ngSubmit)="save(dataForm)" #dataForm="ngForm" class="sign_in_form" id="dataForm">

            <div class="input_element" [class.hide]="viewOnly">
              <div class="search_box">
                <i class="fa-solid fa-magnifying-glass"></i>
                <input type="text" (focus)="searchTextControlFocussed()" 
                  #searchTextControl placeholder="Search..."
                  data-test-id="location-selector-search-text-input">
              </div>  
              <div class="results-chooser" >
                  <div class="results-chooser-dropdown" [class.hide]="!osmSearchResults?.length || hideSearchResults"
                    data-test-id="location-selector-search-results">                      
                    <a *ngFor="let result of osmSearchResults; let i = index" (click)="searchResultClicked(result)" [attr.data-test-id]="'location-selector-search-result-' + i">                      
                      {{result.display_name}}
                    </a>                            
                  </div>                  
              </div>
            </div>
    
            <div class="map"
                leaflet
                #mapContainer
                [style.display]="hideLiveMap ? 'none' : 'block'"
                [style.opacity]="busy? 0.5 : 1"
                [(leafletCenter)]="leafletCenter"
                [leafletOptions]="options"
                (leafletClick)="mapClicked($event)"
                (leafletMouseDown)="mapMouseDown()"
                (leafletMapReady)="onMapReady($event)">
            </div>
          </form> 

Answer №3

My approach provides a solution that does not rely on the archived saveSvgAsPng library and does not require adding the svg element to the DOM!

I successfully tested this method with OpenStreetMap maps rendered using ngx-leaflet.

HTML:

<div leaflet id="map" class="map" [leafletOptions]="leafletOptions"
         (leafletMapReady)="onMapReady($event)"
         (leafletMapMoveEnd)="updateSvgMap()"
         (leafletMouseUp)="updateSvgMap()">
    </div>

Component:

map: L.Map | undefined;

leafletOptions: MapOptions = {
  layers: this.getLayers(),
  center: [38.8951, -77.0364],
  zoom: 18
}

onMapReady(map: L.Map) {
  this.map = map;
}

updateSvgMap() {
  const map: L.Map | undefined = this.map;
  if (!map) {
    return;
  }

  const mapSvgElement: SVGElement = this.createSvgElement(map);

  this.convertCanvasToBlob(mapSvgElement).then((blob: Blob | null) => {
    console.log(blob);
  }, (error: Error) => {
      console.log(error);
  });
}



//create svg element from Map
private createSvgElement(map: L.Map): SVGElement {
  const defaultNameSpace = 'http://www.w3.org/2000/svg';
  const xlinkNameSpace = 'http://www.w3.org/1999/xlink';

  const mapContainerRect: DOMRect = map.getContainer().getBoundingClientRect();

  const svgElement: SVGElement = document.createElementNS(defaultNameSpace,'svg');
  svgElement.setAttribute('height', mapContainerRect.height.toString());
  svgElement.setAttribute('width', mapContainerRect.width.toString());
  //svgElement.setAttribute('id','svgMap'); //only if needed to reference it later
  svgElement.setAttributeNS(xlinkNameSpace, "xlink:href", "link")

  let mapTiles: NodeListOf<Element> = document.querySelectorAll('.leaflet-tile-loaded');
  let markers: NodeListOf<Element>  = document.querySelectorAll('.leaflet-marker-icon');

  mapTiles.forEach((tile: Element) => {
    const image: SVGImageElement = document.createElementNS(defaultNameSpace, 'image');
    const tileRect: DOMRect = tile.getBoundingClientRect();
    image.setAttribute('width', tileRect.width.toString());
    image.setAttribute('height', tileRect.width.toString());
    image.setAttribute('x', (tileRect.left - mapContainerRect.left).toString());
    image.setAttribute('y', (tileRect.top - mapContainerRect.top).toString());
    image.setAttributeNS(xlinkNameSpace, 'href', (tile as any)?.src);
    svgElement.appendChild(image);
  });

  markers.forEach((marker: Element) => {
    const image: SVGImageElement = document.createElementNS(defaultNameSpace, 'image');
    const markerRect: DOMRect = marker.getBoundingClientRect();

    image.setAttribute('width', markerRect.width.toString());
    image.setAttribute('height', markerRect.height.toString());
    image.setAttribute('x', (markerRect.left - mapContainerRect.left).toString());
    image.setAttribute('y', (markerRect.top - mapContainerRect.top).toString());
    image.setAttributeNS(xlinkNameSpace, 'href',(marker as any)?.src);
    svgElement.appendChild(image);
  });

  return svgElement;
}

//convert svg with all inline images to Image-Blob
private convertCanvasToBlob(svgElement: SVGElement): Promise<Blob | null> {
  return new Promise((resolve, reject) => {
    const parser: DOMParser = new DOMParser();
    const svgString: string = new XMLSerializer().serializeToString(svgElement);
    const svgDoc: Document = parser.parseFromString(svgString, 'image/svg+xml');
    const svgImage: HTMLImageElement = new Image();
    svgImage.onload = () => {
        const canvas: HTMLCanvasElement = document.createElement('canvas');
        const context: CanvasRenderingContext2D | null = canvas.getContext('2d');
        if (!context) {
          return;
        }
        canvas.width = svgImage.width;
        canvas.height = svgImage.height;
        context.drawImage(svgImage, 0, 0);

        //const pngBase64String: string = canvas.toDataURL('image/png');
        //console.log(pngBase64String);

        canvas.toBlob((blob: Blob | null) => {
          resolve(blob);
        }, 'image/png'); //or jpeg or webp
    };
    svgImage.onerror = (error: string | Event) => {
        if (typeof error === 'string') {
          reject(Error(error));
        } else {
          reject(Error('Could not load image' + error.type));
        }
    }

    const imageElements: HTMLCollectionOf<SVGImageElement> = svgDoc.getElementsByTagName('image');
    let imagesLoaded = 0;

    function checkAllImagesLoaded() {
        imagesLoaded++;
        if (imagesLoaded === imageElements.length) {
          svgImage.src = 'data:image/svg+xml;base64,' + btoa(new XMLSerializer().serializeToString(svgDoc));
        }
    }

    for (let i: number = 0; i < imageElements.length; i++) {
        const image: HTMLImageElement = new Image();
        image.crossOrigin = "Anonymous";
        image.onload = () => {
          const canvas: HTMLCanvasElement = document.createElement('canvas');
          const context: CanvasRenderingContext2D | null = canvas.getContext('2d');
          if (!context) {
              checkAllImagesLoaded();
              return;
          }
          canvas.width = image.width;
          canvas.height = image.height;
          context.drawImage(image, 0, 0);
          const imageDataURL = canvas.toDataURL('image/png');
          imageElements[i].setAttributeNS('http://www.w3.org/1999/xlink', 'href', imageDataURL);
          checkAllImagesLoaded();
        };
        image.onerror = (error: string | Event) => {
          if (typeof error === 'string') {
              console.log(error);
          } else {
              console.log('Could not load image' + error.type);
          }
          checkAllImagesLoaded();
        }

        let imageHref: string | null = imageElements[i].getAttribute('xlink:href');
        if (!imageHref) {
          imageHref = imageElements[i].getAttribute('href');
        }

        if (imageHref) {
          image.src = imageHref;
        }
    }
  });
}

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

"Seeking advice on how to nest a service provider within another one in AngularJS 2. Any

I am faced with a product and cart list scenario. Below is the function I have created to iterate through the cart list and retrieve the specific product using its ID. getCartList(){ this.cart = CART; this.cart.forEach((cart: Cart) => ...

The process of automatically formatting Typescript has transformed into an unfortunate auto-discarding action

Typescript autoformatting has become a concerning issue. Whenever I input quoted strings (" or `), the code surrounding it seems to temporarily glitch, with other strings appearing as code. This problem has recently escalated, particularly with strings li ...

The error message "Ionic 3 encountering issues with accessing property 'valid' from undefined or null reference"

As I was setting up a form on a CRUD using Firebase, initially just storing name and number as string types, I decided to add more parameters of the same type. However, I encountered an issue with the error message "Unable to get property 'valid' ...

Retrieving a result from the reduce function in Angular

Is there a way to use the reduce function in order to return a list of objects? I am currently only able to return a single object with keys as project names and values as hours: { Name1: 9, Name2: 10, Name3: 30, } What changes can I make to my code to ac ...

Setting a specific time zone as the default in Flatpickr, rather than relying on the system's time zone, can be

Flatpickr relies on the Date object internally, which defaults to using the local time of the computer. I am currently using Flatpickr version 4.6.6 Is there a method to specify a specific time zone for flatpickr? ...

Defining Array Types in TypeScript JSON

My attempt at coding a class in TypeScript has hit a roadblock. Whenever I try to utilize Jsons in an Array, the system crashes due to a TypeScript error. Here's the class I tried to create: class Api { url: string | undefined; credentials: Ar ...

Explaining the functionality of reserved words like 'delete' within a d.ts file is essential for understanding their

I am currently in the process of generating a d.ts file for codebooks.io, where I need to define the function delete as an exported top-level function. This is what my codebooks-js.d.ts file looks like at the moment: declare module "codehooks-js" ...

Having trouble locating the TypeScript compiler in Visual Studio 2017?

In an attempt to develop an application in Visual Studio using TypeScript, I meticulously followed the instructions outlined here and proceeded to install the necessary TypeScript compiler for version 2.2 of the project via NPM. npm install typescript ...

The functionality of @Output and custom events appears to be malfunctioning

I am a beginner in Angular and attempting to pass data between child and parent components. In the child component.ts file: @Output() doubleClick = new EventEmitter<string>(); onDoubleClick(nameAccount: string){ this.doubleClick.emit(nameAccoun ...

Presenting two arrays simultaneously creates angular duplicates

Encountering an issue while trying to display two arrays containing channel information: List of channels List of subscriptions that users have opted for. channels = [ { "id": 1, "name": "arte", "service&q ...

Utilize the expert capabilities within #dateHeaderTemplate in the Angular component schedule by Syncfusion

I am trying to access the template #dateHeaderTemplate in order to retrieve the ID of the professional who is scheduled to attend the appointment. This information is needed to display the start and end times of the day in the header. By visiting this lin ...

Utilizing NgIf with a Child Component in Angular 6

Hello, I am currently delving into Angular 6 and facing an issue that I hope you can assist with. In my parent component, there is a list of trips and I aim to enable the user to click on a trip and have a google map displaying the location of that specifi ...

Angular2's change detection mechanism does not behave as anticipated after receiving a message from a Worker

Within my angular2 application, I encountered a rather intriguing scenario which I will simplify here. This is AppComponnet export class AppComponent { tabs: any = []; viewModes = [ { label: "List View"}, { label: "Tree View" }, ...

Inferring types in a generic function with multiple parameters

In my attempt to configure a generic with the parameter serving as the key of another object, I have found success using extends keyof when both types are provided as parameters to the function. However, I encountered an issue when the type that provides ...

Tips for storing data across multiple steps in Angular

Imagine having a sophisticated multi-step form in Angular with 6 or 7 steps, allowing users to input data once they log in. What if the user fills out a few steps, logs out or closes their browser, and returns later? The goal is for them to pick up where t ...

Angular 6 and the intricacies of nested ternary conditions

I need help with a ternary condition in an HTML template file: <div *ngFor="let $m of $layer.child; let $childIndex=index" [Latitude]="$m.latitude" [Longitude]="$m.longitude" [IconInfo]="$childIndex== 0 ? _iconInfo1:$c ...

What is the best way to increase the size of an array and populate it with just one specific element in Types

If I want to create an array in Python, like [1, 1, 1, 1, 1, 1, 1, 1, 1, 1], all I have to do is use [1] * 10. >>> [1] * 10 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] Is it possible to achieve the same result in TypeScript? ...

Steps for importing a CommonJS module that exports as a callable into TypeScript

I am dealing with a project that has a mixture of JavaScript and TypeScript files. Within the project, there is a JS library that follows this structure: module.exports = () => { // logic dependent on environment variables // then... return { ...

Unknown error occurred in Eventstore: Unable to identify the BadRequest issue

I'm encountering an error while using Eventstore, specifically: Could not recognize BadRequest; The error message is originating from: game process tick failed UnknownError: Could not recognize BadRequest at unpackToCommandError (\node_modul ...

Creating a typescript object shape without an index signature involves specifying the exact properties

I have a query regarding a potential design gap in TypeScript. Consider a function that accepts objects meeting a specific interface: interface Params { [key: string]: string | number | boolean | undefined | null; } This interface specifies that the k ...