Overview
MapLibre GL JS is a free, open-source WebGL mapping library that makes it easy to add interactive maps to web pages. It provides excellent support for vector tiles and works seamlessly with the Lynker Spatial Tile Service.
Installation
Install MapLibre GL JS via npm or use the CDN version:
NPM Installation
npm install maplibre-gl
CDN Installation (HTML)
<link href='https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css' rel='stylesheet' />
<script src='https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js'></script>
Basic Map Setup
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>Lynker Spatial Map</title>
<meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' />
<script src='https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js'></script>
<link href='https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css' rel='stylesheet' />
<style>
body { margin: 0; padding: 0; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
</style>
</head>
<body>
<div id='map'></div>
<script>
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [-100, 40],
zoom: 4
});
</script>
</body>
</html>
Adding Lynker Spatial Tiles
Vector Tiles (Recommended)
Vector tiles provide the best performance and styling flexibility:
map.on('load', () => {
// Add data source
map.addSource('padus', {
type: 'vector',
tiles: [
'https://tiles.lynker-spatial.com/api/tiles/lynker-spatial-modeling-fabric/{z}/{x}/{y}'
],
minzoom: 0,
maxzoom: 14
});
// Add layer to map
map.addLayer({
id: 'padus-layer',
type: 'fill',
source: 'padus',
'source-layer': 'flowpath',
paint: {
'fill-color': '#088',
'fill-opacity': 0.5
}
});
});
Handling Authentication
Since MapLibre runs in the browser, authentication requires special handling. Here are three approaches:
Option 1: Server-Side Proxy (Recommended)
Create a proxy endpoint on your server that adds the Authentication header:
// Node.js/Express example
app.get('/api/tiles/:source/:z/:x/:y', async (req, res) => {
const { source, z, x, y } = req.params;
const token = process.env.LYNKER_TOKEN;
const response = await fetch(
`https://tiles.lynker-spatial.com/api/tiles/${source}/${z}/${x}/${y}`,
{
headers: {
'Authorization': `Bearer ${token}`
}
}
);
res.set('Content-Type', 'application/octet-stream');
res.send(await response.buffer());
});
// Update tile URL to use proxy
map.addSource('padus', {
type: 'vector',
tiles: [
'/api/tiles/lynker-spatial-modeling-fabric/{z}/{x}/{y}'
]
});
Option 2: Token in Authorization Header (Development Only)
let token = localStorage.getItem('lynker_token');
// Refresh token if needed
async function ensureValidToken() {
const stored = localStorage.getItem('lynker_token_expires');
if (Date.now() > new Date(stored).getTime()) {
const response = await fetch('/api/refresh-token', {
method: 'POST'
});
token = await response.json().access_token;
localStorage.setItem('lynker_token', token);
}
}
map.on('load', async () => {
await ensureValidToken();
map.addSource('padus', {
type: 'vector',
tiles: [
`https://tiles.lynker-spatial.com/api/tiles/lynker-spatial-modeling-fabric/{z}/{x}/{y}?token=${token}`
]
});
});
Option 3: Programmatic Tile Addition with Custom Fetch
// Use a plugin or custom protocol handler
map.on('load', () => {
const token = localStorage.getItem('lynker_token');
// Override protocol
map.addProtocol('lynker', async (params, callback) => {
const url = `https://tiles.lynker-spatial.com${params.url.replace('lynker:', '')}`;
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const buffer = await response.arrayBuffer();
callback(null, buffer);
} catch (error) {
callback(error);
}
});
map.addSource('padus', {
type: 'vector',
tiles: ['lynker://padus/{z}/{x}/{y}']
});
});
Security Note: Storing raw tokens in browser localStorage is not secure. Use Option 1 (server-side proxy) for production applications.
Layer Styling
Simple Fill Styling
map.addLayer({
id: 'padus-fill',
type: 'fill',
source: 'padus',
'source-layer': 'flowpath',
paint: {
'fill-color': '#088',
'fill-opacity': 0.7
}
});
Data-Driven Styling
map.addLayer({
id: 'padus-by-protection',
type: 'fill',
source: 'padus',
'source-layer': 'flowpath',
paint: {
'fill-color': [
'interpolate',
['linear'],
['get', 'protection_level'],
0, '#fee5d9',
1, '#fcae91',
2, '#fb6a4a',
3, '#cb181d'
],
'fill-opacity': 0.75
}
});
Adding Outlines
map.addLayer({
id: 'padus-outline',
type: 'line',
source: 'padus',
'source-layer': 'flowpath',
paint: {
'line-color': '#627BC1',
'line-width': 2,
'line-opacity': 0.8
}
});
Interactive Features
Popup on Click
map.on('click', 'padus-fill', (e) => {
const properties = e.features[0].properties;
let popupContent = '<div>';
for (const key in properties) {
popupContent += `<p><strong>${key}:</strong> ${properties[key]}</p>`;
}
popupContent += '</div>';
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(popupContent)
.addTo(map);
});
// Change cursor on hover
map.on('mouseenter', 'padus-fill', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'padus-fill', () => {
map.getCanvas().style.cursor = '';
});
Hover Highlighting
let hoveredId = null;
map.on('mousemove', 'padus-fill', (e) => {
if (e.features.length > 0) {
if (hoveredId !== null) {
map.setFeatureState(
{ source: 'padus', id: hoveredId },
{ hover: false }
);
}
hoveredId = e.features[0].id;
map.setFeatureState(
{ source: 'padus', id: hoveredId },
{ hover: true }
);
}
});
map.on('mouseleave', 'padus-fill', () => {
if (hoveredId !== null) {
map.setFeatureState(
{ source: 'padus', id: hoveredId },
{ hover: false }
);
}
hoveredId = null;
});
// Add hover styling
map.addLayer({
id: 'padus-highlight',
type: 'fill',
source: 'padus',
'source-layer': 'flowpath',
paint: {
'fill-color': '#ff6b6b',
'fill-opacity': [
'case',
['boolean', ['feature-state', 'hover'], false],
0.9,
0
]
}
});
Performance Optimization
Tile Caching
// Enable browser caching with appropriate HTTP headers
map.addSource('padus', {
type: 'vector',
tiles: [
'https://tiles.lynker-spatial.com/api/tiles/lynker-spatial-modeling-fabric/{z}/{x}/{y}'
],
minzoom: 0,
maxzoom: 14,
// Browser caching is automatic with HTTP headers from server
});
Zoom-Level Based Layer Control
// Show detail only at higher zoom levels
map.addLayer({
id: 'padus-detail',
type: 'fill',
source: 'padus',
'source-layer': 'padus-detail',
minzoom: 10,
paint: {
'fill-color': '#088',
'fill-opacity': 0.7
}
});
Multiple Tile Sources
map.on('load', () => {
// PADUS layer
map.addSource('padus', {
type: 'vector',
tiles: ['https://tiles.lynker-spatial.com/api/tiles/lynker-spatial-modeling-fabric/{z}/{x}/{y}'],
minzoom: 0,
maxzoom: 14
});
// Wetlands layer
map.addSource('wetlands', {
type: 'vector',
tiles: ['https://tiles.lynker-spatial.com/api/tiles/wetlands/{z}/{x}/{y}'],
minzoom: 0,
maxzoom: 14
});
// Hydrofabric layer
map.addSource('lynker-spatial-modeling-fabric', {
type: 'vector',
tiles: ['https://tiles.lynker-spatial.com/api/tiles/lynker-spatial-modeling-fabric/{z}/{x}/{y}'],
minzoom: 0,
maxzoom: 14
});
// Add layers with different z-index ordering
map.addLayer({ id: 'wetlands-layer', type: 'fill', source: 'wetlands', ... });
map.addLayer({ id: 'padus-layer', type: 'fill', source: 'padus', ... });
map.addLayer({ id: 'hydrofabric-layer', type: 'line', source: 'lynker-spatial-modeling-fabric', ... });
});
Troubleshooting
Tiles Not Appearing
- Check browser console (F12) for CORS or 401 errors
- Verify your authentication token is valid
- Check that tile coordinates are within valid zoom/bounds
- Ensure the source-layer name matches the data
Authentication Errors (401)
If you see "401 Unauthorized" errors in the browser console, your authentication setup needs adjustment. Use Option 1 (server-side proxy) to add the Authorization header server-side.
Performance Issues
- Reduce the number of layers displayed at once
- Use smaller bounding boxes for data queries
- Implement zoom-level filtering
- Monitor network requests in the Network tab
Example: Complete Map Application
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>Lynker Spatial Watershed Map</title>
<meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' />
<script src='https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js'></script>
<link href='https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css' rel='stylesheet' />
<style>
body { margin: 0; padding: 0; font-family: Arial; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
#info { position: absolute; top: 20px; left: 20px; background: white;
padding: 20px; border-radius: 12px; z-index: 100; max-width: 300px; }
</style>
</head>
<body>
<div id='map'></div>
<div id='info'>
<h2>Watershed Map</h2>
<p>Click on protected areas for more information.</p>
<div id='feature-info'></div>
</div>
<script>
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [-100, 40],
zoom: 5
});
map.on('load', () => {
// Add data source
map.addSource('padus', {
type: 'vector',
// Canonical tile URL — omit the .pbf extension. Use a proxy if you need to inject Authorization headers.
tiles: ['https://tiles.lynker-spatial.com/api/tiles/lynker-spatial-modeling-fabric/{z}/{x}/{y}'],
minzoom: 0,
maxzoom: 14
});
// Add layer
map.addLayer({
id: 'padus-fill',
type: 'fill',
source: 'padus',
'source-layer': 'flowpath',
paint: {
'fill-color': '#088',
'fill-opacity': 0.5
}
});
map.addLayer({
id: 'padus-outline',
type: 'line',
source: 'padus',
'source-layer': 'flowpath',
paint: {
'line-color': '#627BC1',
'line-width': 2
}
});
// Add interactivity
map.on('click', 'padus-fill', (e) => {
const info = e.features[0].properties;
let html = '<h3>' + (info.name || 'Protected Area') + '</h3>';
html += '<p>' + (info.description || 'No description available') + '</p>';
document.getElementById('feature-info').innerHTML = html;
});
map.on('mouseenter', 'padus-fill', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'padus-fill', () => {
map.getCanvas().style.cursor = '';
});
});
</script>
</body>
</html>
Resources