Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 62 additions & 28 deletions src/lib/core/PMTilesLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type MapMouseEvent,
Popup,
} from "maplibre-gl";
import { generateDistinctColors } from "../utils/color";
import type {
PMTilesLayerControlOptions,
PMTilesLayerControlState,
Expand Down Expand Up @@ -449,40 +450,60 @@ export class PMTilesLayerControl implements IControl {

if (pickableLayerIds.length === 0) return;

// Query features at click point
const features = this._map.queryRenderedFeatures(e.point, {
layers: pickableLayerIds,
});
// Query all features at click point, grouped by source layer
const seenSourceLayers = new Set<string>();
const layerFeatures: Array<{
sourceLayer: string;
properties: Record<string, unknown>;
}> = [];

for (const layerId of pickableLayerIds) {
if (!this._map.getLayer(layerId)) continue;
const features = this._map.queryRenderedFeatures(e.point, {
layers: [layerId],
});
if (features.length > 0) {
Comment on lines +460 to +465
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queryRenderedFeatures is called once per layerId in pickableLayerIds. This turns a single click into N queries (3× per source-layer), which can become noticeably expensive as more PMTiles sources/layers are added. Prefer a single queryRenderedFeatures(e.point, { layers: pickableLayerIds }) call and then group the results in-memory by sourceLayer (and feature).

Copilot uses AI. Check for mistakes.
const sl = features[0].sourceLayer || layerId;
if (!seenSourceLayers.has(sl)) {
seenSourceLayers.add(sl);
layerFeatures.push({
sourceLayer: sl,
properties: features[0].properties || {},
});
}
Comment on lines +460 to +473
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The click handler only captures features[0] per style layer and then de-dupes by sourceLayer, so multiple overlapping features (even within the same source layer) at the click point will be dropped. Consider collecting all returned features and grouping them (e.g., by feature.id/properties + sourceLayer) so the popup truly shows all overlapping features per source layer.

Copilot uses AI. Check for mistakes.
}
}

if (features.length === 0) {
// Close popup if clicking on empty area
if (layerFeatures.length === 0) {
if (this._popup) {
this._popup.remove();
}
return;
}

// Get the first feature
const feature = features[0];
const properties = feature.properties || {};

// Build popup content
// Build combined popup content for all layers at this point
let html = '<div class="maplibre-gl-pmtiles-popup">';
html += `<div class="maplibre-gl-pmtiles-popup-header">${feature.sourceLayer || "Feature"}</div>`;
html += '<div class="maplibre-gl-pmtiles-popup-content">';

const propEntries = Object.entries(properties);
if (propEntries.length === 0) {
html +=
'<div class="maplibre-gl-pmtiles-popup-empty">No properties</div>';
} else {
html += '<table class="maplibre-gl-pmtiles-popup-table">';
for (const [key, value] of propEntries) {
const displayValue =
typeof value === "object" ? JSON.stringify(value) : String(value);
html += `<tr><td class="maplibre-gl-pmtiles-popup-key">${key}</td><td class="maplibre-gl-pmtiles-popup-value">${displayValue}</td></tr>`;
for (const { sourceLayer, properties } of layerFeatures) {
const friendlyName = sourceLayer.replace(/[-_]/g, " ");
html += `<div class="maplibre-gl-pmtiles-popup-header">${friendlyName}</div>`;

const propEntries = Object.entries(properties);
if (propEntries.length === 0) {
html +=
'<div class="maplibre-gl-pmtiles-popup-empty">No properties</div>';
} else {
html += '<table class="maplibre-gl-pmtiles-popup-table">';
for (const [key, value] of propEntries) {
const displayValue =
typeof value === "object"
? JSON.stringify(value)
: String(value);
html += `<tr><td class="maplibre-gl-pmtiles-popup-key">${key}</td><td class="maplibre-gl-pmtiles-popup-value">${displayValue}</td></tr>`;
Comment on lines +488 to +503
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Popup HTML is constructed via string interpolation with unescaped sourceLayer, property keys, and property values, then passed to Popup#setHTML. If any attribute contains </& etc., this can lead to HTML injection/XSS. Please escape these fields (there are _escapeHtml helpers elsewhere in the codebase) or build the popup DOM using textContent instead of raw HTML.

Copilot uses AI. Check for mistakes.
}
html += "</table>";
}
html += "</table>";
}

html += "</div></div>";
Expand Down Expand Up @@ -1054,15 +1075,25 @@ export class PMTilesLayerControl implements IControl {
? this._options.beforeId
: undefined;

// Track per-source-layer colors for this PMTiles source
const sourceLayerColors: Record<string, string> = {};

if (tileType === "vector") {
// Use selected source layers if available, otherwise use all
const layersToRender =
this._state.selectedSourceLayers.length > 0
? this._state.selectedSourceLayers
: sourceLayers;

// Generate distinct colors for each source layer
const colors = generateDistinctColors(layersToRender.length);

// Add layers for each source layer
for (const sourceLayer of layersToRender) {
for (let i = 0; i < layersToRender.length; i++) {
const sourceLayer = layersToRender[i];
const color = colors[i];
sourceLayerColors[sourceLayer] = color;

// Determine layer type based on geometry (simplified - add all types)
const fillLayerId = `${sourceId}-${sourceLayer}-fill`;
const lineLayerId = `${sourceId}-${sourceLayer}-line`;
Expand All @@ -1076,7 +1107,7 @@ export class PMTilesLayerControl implements IControl {
source: sourceId,
"source-layer": sourceLayer,
paint: {
"fill-color": this._options.defaultFillColor,
"fill-color": color,
"fill-opacity": this._state.layerOpacity * 0.6,
},
filter: ["==", ["geometry-type"], "Polygon"],
Expand All @@ -1093,7 +1124,7 @@ export class PMTilesLayerControl implements IControl {
source: sourceId,
"source-layer": sourceLayer,
paint: {
"line-color": this._options.defaultLineColor,
"line-color": color,
"line-opacity": this._state.layerOpacity,
"line-width": 1,
},
Expand All @@ -1115,9 +1146,11 @@ export class PMTilesLayerControl implements IControl {
source: sourceId,
"source-layer": sourceLayer,
paint: {
"circle-color": this._options.defaultCircleColor,
"circle-color": color,
"circle-opacity": this._state.layerOpacity,
"circle-radius": 4,
"circle-radius": 2,
"circle-stroke-color": color,
"circle-stroke-width": 0.5,
},
filter: ["==", ["geometry-type"], "Point"],
},
Expand Down Expand Up @@ -1180,6 +1213,7 @@ export class PMTilesLayerControl implements IControl {
layerIds,
opacity: this._state.layerOpacity,
pickable: this._state.pickable,
sourceLayerColors,
};
this._pmtilesLayers.set(sourceId, layerInfo);

Expand Down
2 changes: 2 additions & 0 deletions src/lib/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1792,6 +1792,8 @@ export interface PMTilesLayerInfo {
opacity: number;
/** Whether features are pickable (clickable). */
pickable: boolean;
/** Map of source layer names to their assigned colors. */
sourceLayerColors?: Record<string, string>;
}

/**
Expand Down
40 changes: 26 additions & 14 deletions src/lib/styles/pmtiles-layer.css
Original file line number Diff line number Diff line change
Expand Up @@ -339,44 +339,56 @@
}

.maplibre-gl-pmtiles-popup-header {
font-weight: 600;
font-weight: 700;
font-size: 13px;
padding-bottom: 6px;
margin-bottom: 6px;
border-bottom: 1px solid #e0e0e0;
color: #0078d7;
padding: 6px 8px 4px 8px;
color: #111;
text-transform: capitalize;
background: #f0f0f0;
border-bottom: 1px solid #ddd;
}

.maplibre-gl-pmtiles-popup-content {
max-height: 200px;
max-height: 300px;
overflow-y: auto;
}

.maplibre-gl-pmtiles-popup-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
background: #fff;
}

.maplibre-gl-pmtiles-popup-table tr {
background: #fff;
}

.maplibre-gl-pmtiles-popup-table tr:nth-child(even) {
background: #f9f9f9;
.maplibre-gl-pmtiles-popup-table tr:last-child td {
border-bottom: none;
}

.maplibre-gl-pmtiles-popup-key {
font-weight: 500;
color: #555;
padding: 3px 8px 3px 0;
font-weight: 600;
color: #222;
padding: 4px 8px;
vertical-align: top;
white-space: nowrap;
width: 40%;
border-bottom: 1px solid #eee;
}

.maplibre-gl-pmtiles-popup-value {
color: #333;
padding: 3px 0;
color: #444;
padding: 4px 8px;
word-break: break-word;
overflow-wrap: break-word;
width: 60%;
border-bottom: 1px solid #eee;
}

.maplibre-gl-pmtiles-popup-empty {
color: #999;
font-style: italic;
padding: 8px 0;
padding: 8px;
}
61 changes: 61 additions & 0 deletions src/lib/utils/color.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,66 @@
import type { ColorStop } from "../core/types";

/**
* Converts an HSL color to a hex string.
*
* @param h - Hue (0-360).
* @param s - Saturation (0-100).
* @param l - Lightness (0-100).
* @returns Hex color string with #.
*/
function hslToHex(h: number, s: number, l: number): string {
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = lNorm - c / 2;
let r = 0,
g = 0,
b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
return rgbToHex(
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
);
}

/**
* Generates an array of visually distinct colors using the golden angle
* hue distribution for maximum separation.
*
* @param count - Number of distinct colors to generate.
* @returns Array of hex color strings.
*/
export function generateDistinctColors(count: number): string[] {
const colors: string[] = [];
for (let i = 0; i < count; i++) {
const hue = (i * 137.508) % 360;
const saturation = 55 + (i % 3) * 15;
const lightness = 45 + (i % 2) * 10;
colors.push(hslToHex(hue, saturation, lightness));
}
return colors;
}
Comment on lines +53 to +62
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateDistinctColors() is newly added and exported, but there are no unit tests covering its output shape/validity (e.g., count handling, hex format, uniqueness expectations). Since tests/utils.test.ts already covers the other color utilities, please add tests for this function there.

Copilot uses AI. Check for mistakes.

/**
* Converts a hex color to RGB values.
*
Expand Down
1 change: 1 addition & 0 deletions src/lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
interpolateColor,
getColorAtPosition,
generateGradientCSS,
generateDistinctColors,
} from "./color";

export {
Expand Down
Loading