Skip to main content

External Markers

|

This example demonstrates how to render custom markers using external rendering with the Maps SDK for TypeScript. It fetches data from the OpenChargeMap API and displays charging stations as interactive HTML markers.

Live Demo

Overview

The example demonstrates the following features:

  • Creating custom HTML markers using ExternalRendererMarkers
  • Fetching GeoJSON data from an external API
  • Adding marker collections to the map
  • Managing marker visibility and position updates
  • Custom pin rendering with DOM elements

Code Implementation

Initialize GemKit and Setup

index.ts
import type * as GeoJSON from 'geojson';
import {
GemKit,
GemMap,
Coordinates,
PositionService,
ExternalRendererMarkers,
MarkerCollectionRenderSettings
} from '@magiclane/maps-sdk';
import { GEMKIT_TOKEN } from './token';
import { PinManager } from './pinmanager';

// UI icon(s)
const ICONS = {
addLocation: `<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 0 24 24" width="20" fill="currentColor"><path d="M12 2C8.14 2 5 5.14 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.86-3.14-7-7-7zm4 8h-3v3h-2v-3H8V8h3V5h2v3h3v2z"/></svg>`
};

let map: GemMap | null = null;
let externalRender: ExternalRendererMarkers | null = null;
const pinManager = new PinManager('map-container');

// App bootstrap
window.addEventListener('DOMContentLoaded', async () => {
const gemKit = await GemKit.initialize(GEMKIT_TOKEN);
await PositionService.instance;

const container = document.getElementById('map-container');
if (!container) throw new Error('Map container not found');

const viewId = 1;
const wrapper = gemKit.createView(viewId, (gemMap: GemMap) => {
map = gemMap;
externalRender = new ExternalRendererMarkers();
});
if (wrapper) container.appendChild(wrapper);

// Add Markers button
const addMarkersBtn = document.createElement('button');
addMarkersBtn.innerHTML = `${ICONS.addLocation} Add Markers`;
styleButton(addMarkersBtn, '#007bff', '#0056b3');
addMarkersBtn.onclick = () => addMarkers();
document.body.appendChild(addMarkersBtn);
});

Fetch and Convert Data to GeoJSON (API Helper)

index.ts
type OpenChargeMapEntry = {
ID: number;
AddressInfo: {
Latitude: number;
Longitude: number;
Title?: string;
AddressLine1?: string;
Town?: string;
};
};

async function fetchAndConvertToGeoJSON(): Promise<GeoJSON.FeatureCollection> {
const url = 'https://api.openchargemap.io/v3/poi/?output=json&latitude=45.75&longitude=3.15&distance=10&distanceunit=KM&key=58092721-4ce4-4b62-b6fd-5e6840190520';
const response = await fetch(url);
const data: OpenChargeMapEntry[] = await response.json();

const geoJson: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
features: data
.filter(entry => entry.AddressInfo?.Latitude && entry.AddressInfo?.Longitude)
.map(entry => ({
type: "Feature",
geometry: {
type: "Point",
coordinates: [entry.AddressInfo.Longitude, entry.AddressInfo.Latitude]
},
properties: {
id: entry.ID,
title: entry.AddressInfo.Title || "",
town: entry.AddressInfo.Town || ""
}
}))
};
return geoJson;
}

Add Markers to Map

index.ts
function addMarkers() {
if (map === null) return;

showMessage('Fetching charging stations...');

fetchAndConvertToGeoJSON().then((data) => {
const geoJsonString = JSON.stringify(data);
const response = map?.addGeoJsonAsMarkerCollection(geoJsonString, "markers");

if (!response || response.length === 0) {
showMessage('No markers found.');
return;
}

const ms = new MarkerCollectionRenderSettings({});
map?.preferences.markers.add(response[0], { settings: ms, externalRender: externalRender!! });
map?.centerOnArea(response[0].area);

if (externalRender) {
externalRender.onNotifyCustom = (data) => {
if (data === 2) {
pinManager.updatePins(externalRender!!.visiblePoints, map);
}
};
}
showMessage('Markers added successfully!');
}).catch(err => {
console.error(err);
showMessage('Error loading markers.');
});
}

Pin Manager Class

The PinManager class handles the creation, positioning, and lifecycle of HTML marker elements.

pinmanager.ts
import { GemMap } from '@magiclane/maps-sdk';

export class PinManager {
private pins: Map<number, HTMLElement> = new Map();
private container: HTMLElement;

constructor(containerId: string) {
const container = document.getElementById(containerId);
if (!container) throw new Error('Container not found');
this.container = container;
}

updatePins(visiblePoints: Map<number, any>, map: GemMap | null) {
// Remove pins that are no longer visible
const currentIds = new Set(visiblePoints.keys());
this.pins.forEach((pin, id) => {
if (!currentIds.has(id)) {
pin.remove();
this.pins.delete(id);
}
});

const mapWidth = this.container.clientWidth;
const mapHeight = this.container.clientHeight;

// Add or update visible pins
visiblePoints.forEach((value, key) => {
const normalizedX = value.screenCoordinates.x; // [0, 1] range
const normalizedY = value.screenCoordinates.y; // [0, 1] range

// Convert to actual pixel coordinates
const screenCoordinates = {
x: normalizedX * mapWidth,
y: normalizedY * mapHeight
};
if (!screenCoordinates) return;

let pin = this.pins.get(key);
if (!pin) {
pin = this.createPinElement();
this.pins.set(key, pin);
this.container.appendChild(pin);
}

this.updatePinPosition(pin, screenCoordinates);
this.updatePinContent(pin, value);
});
}

private createPinElement(): HTMLElement {
const pin = document.createElement('div');
// IMPROVEMENT: Use an SVG icon instead of a red box/circle
pin.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" height="40" viewBox="0 0 24 24" width="40" fill="#EA4335">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>
`;
pin.style.position = 'absolute';
pin.style.width = '40px';
pin.style.height = '40px';
// Remove background color as the SVG handles the color
pin.style.backgroundColor = 'transparent';
pin.style.transform = 'translate(-50%, -100%)'; // Anchor at bottom center of the pin
pin.style.zIndex = '1000';
pin.style.cursor = 'pointer';
// Add a subtle drop shadow filter to the SVG itself
pin.style.filter = 'drop-shadow(0 2px 3px rgba(0,0,0,0.3))';

return pin;
}

private updatePinPosition(pin: HTMLElement, coords: {x: number, y: number}) {
pin.style.left = `${coords.x}px`;
pin.style.top = `${coords.y}px`;
}

private updatePinContent(pin: HTMLElement, data: any) {
if (data.title) {
pin.title = data.title;
}
// Customize pin appearance based on data
}

clearAll() {
this.pins.forEach(pin => pin.remove());
this.pins.clear();
}
}

Key Features

  • External Rendering: Custom HTML markers rendered outside the map canvas using ExternalRendererMarkers
  • GeoJSON Support: Convert API data to GeoJSON format for marker collections
  • Dynamic Updates: Markers automatically update position as the map moves
  • Visibility Management: Only visible markers are rendered for optimal performance
  • Custom Styling: Full control over marker appearance using CSS and DOM manipulation

How It Works

  1. Initialize External Renderer: Create an ExternalRendererMarkers instance
  2. Fetch Data: Retrieve location data from an external API
  3. Convert to GeoJSON: Transform the data into GeoJSON format
  4. Add Marker Collection: Use addGeoJsonAsMarkerCollection() to add markers to the map
  5. Setup Notifications: Register a callback to update marker positions when the map changes
  6. Render Pins: The PinManager creates and positions HTML elements for each marker

Coordinate Conversion

Screen coordinates from the SDK are normalized (0-1 range) and need to be converted to pixel coordinates:

const screenCoordinates = {
x: normalizedX * mapWidth,
y: normalizedY * mapHeight
};

Customization Options

You can customize markers by modifying the createPinElement() method:

  • Change size, color, and shape
  • Add icons or images
  • Include labels or badges
  • Implement hover effects
  • Add click handlers for interactions

Next Steps