Skip to main content

OpenLayers + Qt (QWebEngine) mapping widget for Python

Project description

pyopenlayersqt

OpenLayers + Qt (QWebEngine) mapping widget for Python.

A high-performance, feature-rich mapping widget that embeds OpenLayers in a Qt application using QWebEngine. Designed for displaying and interacting with large volumes of geospatial data. image

Table of Contents

Features

  • 🗺️ Interactive Map Widget: Fully-featured OpenLayers map embedded in PySide6/Qt
  • ⚡ High-Performance Rendering: Fast points layers with spatial indexing for millions of points
  • 🎨 Rich Styling: Customizable styles for points, custom icon markers, polygons, circles, and ellipses
  • 🎨 QColor Support: Use QColor objects or color names directly in styles - no .name() needed
  • 📍 Geolocation Support: Fast geo-points layer with uncertainty ellipses
  • 🌐 WMS Integration: Built-in Web Map Service layer support
  • 🖼️ Raster Overlays: PNG/ overlay support with custom bounds
  • ✅ Feature Selection: Interactive feature selection with Python ↔ JavaScript sync
  • 🎯 Smart Z-Ordering: Selected points and ellipses automatically appear on top
  • 📊 Feature Table Widget: High-performance table widget for displaying and managing features
  • 🔄 Bidirectional Sync: Seamless selection synchronization between map and table
  • 📏 Distance Measurement: Interactive measurement mode with geodesic distance calculations and great-circle path visualization
  • 🎚️ Range Slider Widget: Dual-handle range slider for filtering features by numeric or timestamp ranges

Installation

pip install pyopenlayersqt

Requirements

  • Python >= 3.8
  • PySide6 >= 6.5
  • numpy >= 1.23
  • pillow >= 10.0
  • matplotlib >= 3.7

Quick Start

from PySide6 import QtWidgets
from PySide6.QtGui import QColor
from pyopenlayersqt import OLMapWidget, PointStyle
import sys

app = QtWidgets.QApplication(sys.argv)

# Create the map widget with custom initial view
map_widget = OLMapWidget(center=(37.0, -120.0), zoom=6)

# Add a vector layer
vector_layer = map_widget.add_vector_layer("my_layer", selectable=True)

# Add some points with QColor styling
coords = [(37.7749, -122.4194), (34.0522, -118.2437)]  # SF, LA
vector_layer.add_points(
    coords,
    ids=["sf", "la"],
    style=PointStyle(radius=8.0, fill_color=QColor("red"))
)

# Show the map
map_widget.show()
sys.exit(app.exec())

See the examples directory for more working examples:

  • 01_basic_map_with_markers.py - Basic map with QColor markers (start here!)
  • 02_layer_types_and_styling.py - All geometry types with QColor
  • 03_fast_points_performance.py - High-performance rendering (10,000+ points)
  • 04_wms_and_base_layers.py - WMS integration and opacity control
  • 05_raster_overlay.py - Raster/heatmap visualization with in-memory PNG bytes
  • 06_geo_uncertainty_ellipses.py - Geolocation uncertainty with ellipses
  • 07_feature_selection.py - Interactive selection across layers
  • 08_table_integration.py - Bidirectional map-table sync (CORE)
  • 09_selection_and_recoloring.py - Interactive recoloring (CORE)
  • 10_range_slider_filtering.py - Range slider filtering
  • 11_measurement_tool.py - Distance measurement tool
  • 12_coordinate_display.py - Coordinate display toggle
  • 13_dual_table_linking.py - Two-table parent/child map-table selection workflow (both tables have map objects)
  • 14_delayed_render_interrupt.py - Debounced, interruptible process-based heatmap rendering
  • 15_load_data_and_zoom.py - Load features, then click a button to auto-zoom to loaded data
  • 16_metadata_only_table_linking.py - 100k FastGeo parent map objects linked to metadata-only child rows (3-5 per parent)
  • 17_map_right_click_context_menu.py - Right-click anywhere on the map for a custom menu (create new points or open dialogs for existing points)
  • 18_gradient_track_speed.py - Polyline/track speed visualization with segment color gradients from matplotlib colormaps
  • 19_virtual_feature_table.py - Minimal lazy/virtual FeatureTableWidget row-provider example

Core Components

OLMapWidget

The main widget class that embeds an OpenLayers map.

from pyopenlayersqt import OLMapWidget

# Create with default world view (center at 0,0, zoom level 2)
map_widget = OLMapWidget()

# Or create with custom initial view
map_widget = OLMapWidget(center=(37.0, -120.0), zoom=6)

# Programmatically adjust view later (no mouse interaction needed)
map_widget.set_center((34.0522, -118.2437))
map_widget.set_zoom(10)
# or set both at once
map_widget.set_view(center=(40.7128, -74.0060), zoom=12)

# Auto-zoom to all currently relevant points/features
feature_points = [
    (37.7749, -122.4194),
    (34.0522, -118.2437),
    (36.1699, -115.1398),
]
map_widget.auto_zoom_to_points(feature_points, padding_px=32, max_zoom=12)

# Or simply fit to all data already loaded in map layers
map_widget.fit_to_data()

Programmatic Zoom & Resolution Reference

OpenLayers uses the standard Web Mercator zoom model. This means zoom levels map predictably to resolution (meters per pixel at the equator):

resolution_m_per_px = 156543.03392804097 / (2 ** zoom)

You can query this directly in Python with:

resolution = OLMapWidget.zoom_resolution_m_per_px(zoom)

Approximate full-world horizontal extent represented by each zoom level:

Zoom Resolution (m/px) Approx. horizontal extent on a 256 px tile (km)
0 156543.0339 40075.02
1 78271.5170 20037.51
2 39135.7585 10018.75
3 19567.8792 5009.38
4 9783.9396 2504.69
5 4891.9698 1252.34
6 2445.9849 626.17
7 1222.9925 313.09
8 611.4962 156.54
9 305.7481 78.27
10 152.8741 39.14
11 76.4370 19.57
12 38.2185 9.78
13 19.1093 4.89
14 9.5546 2.45
15 4.7773 1.22
16 2.3887 0.61

Notes:

  • "Extent" above is an equatorial approximation and varies with viewport size.
  • For the actual current extent of your map widget, use get_view_extent(callback).
  • For feature-driven workflows (e.g., after adding a batch of points), use fit_to_data() for a one-call auto-fit across loaded layers, or auto_zoom_to_points(...) / fit_bounds(...) when you want explicit control.
image

Constructor Parameters:

  • parent - Optional parent widget
  • center - Initial map center as (lat, lon) tuple. Defaults to (0, 0).
  • zoom - Initial zoom level (integer). Defaults to 2 (world view).
  • show_coordinates - If True, displays mouse lat/lon coordinates in the lower right corner. Defaults to True.
  • show_country_boundaries - If True, enables the built-in countries boundary layer at startup. Defaults to False.
  • country_boundaries_stroke_color - Optional country boundary stroke color as QColor or CSS string (e.g. "#ffcc00"). Defaults to None.
  • show_osm_layer - If True, the OSM base layer is visible on startup. Defaults to True.
  • osm_url - Optional OSM tile URL template override used for the base layer.
  • map_background_color - CSS color rendered behind the OSM layer. Defaults to "#ffffff".

Key Methods:

  • add_vector_layer(name, selectable=True) - Create a vector layer for points, polygons, circles, ellipses
  • add_fast_points_layer(name, selectable, style, cell_size_m) - Create a high-performance points layer
  • add_fast_geopoints_layer(name, selectable, style, cell_size_m) - Create a geo-points layer with uncertainty ellipses
  • add_wms(options, name) - Add a WMS (Web Map Service) layer
  • add_raster_image(image, bounds, style, name) - Add a raster image overlay
  • set_base_opacity(opacity) - Set OSM base layer opacity (0.0-1.0)
  • set_base_visible(visible) - Show/hide the OSM base layer
  • set_map_background_color(color) - Set map background color behind OSM tiles
  • set_country_boundaries_visible(visible, stroke_color=None) - Show/hide boundaries and optionally set boundary stroke color (QColor or CSS string)
  • set_measure_mode(enabled) - Enable/disable interactive distance measurement mode
  • on_measurement_updated(callback) - Register a typed callback for measurement click updates
  • clear_measurements() - Clear all measurement points and lines
  • set_view(center=None, zoom=None) - Programmatically set map center and/or zoom
  • set_center((lat, lon)) - Programmatically set map center
  • set_zoom(zoom) - Programmatically set map zoom level
  • fit_bounds(bounds, padding_px, max_zoom, duration_ms) - Auto-fit map view to SW/NE bounds
  • auto_zoom_to_points(points, padding_px, max_zoom, duration_ms) - Auto-fit map view to a list of feature points
  • fit_to_data(padding_px, max_zoom, duration_ms, ...) - Auto-fit map view to data already in map layers
  • zoom_resolution_m_per_px(zoom) - Return Web Mercator resolution (m/px) for a zoom level
  • get_view_extent(callback) - Get current map extent asynchronously
  • watch_view_extent(callback, debounce_ms) - Subscribe to extent changes

Signals:

  • ready - Emitted when the map is ready
  • selectionChanged - Emitted when feature selection changes
  • viewExtentChanged - Emitted when map extent changes
  • measurementUpdated - Emitted when a measurement point is added. Signal(object) carrying a MeasurementUpdate instance.
  • jsEvent - Emitted for low-level JavaScript events. Signal(str, str) with event type and JSON payload.

Layer Types

All layer types in pyopenlayersqt inherit from a common BaseLayer class, providing consistent functionality across different layer implementations.

Common Layer Methods

All layers (VectorLayer, FastPointsLayer, FastGeoPointsLayer, WMSLayer, RasterLayer) support these core methods:

# Set layer opacity (0.0 = transparent, 1.0 = opaque)
layer.set_opacity(0.7)

# Remove the layer from the map
layer.remove()

Feature-based layers (VectorLayer, FastPointsLayer, FastGeoPointsLayer) also support:

# Show/hide the layer
layer.set_visible(True)

# Enable/disable feature selection
layer.set_selectable(True)

# Clear all features from the layer
layer.clear()

Each layer type also has specialized methods for its specific use case, as detailed below.

VectorLayer

For standard vector features with full styling control.

from pyopenlayersqt import PointStyle, IconStyle, PolygonStyle, CircleStyle, EllipseStyle

# Add a vector layer
vector = map_widget.add_vector_layer("vector", selectable=True)

# Add points
vector.add_points(
    coords=[(lat, lon), ...],
    ids=["id1", "id2", ...],
    style=PointStyle(
        radius=6.0,
        fill_color=QColor("red"),
        fill_opacity=0.85,
        stroke_color=QColor("black"),
        stroke_width=1.0
    )
)

# Add custom icon marker points. icon and selected_icon accept the same forms.
from pathlib import Path
import base64
from PySide6.QtCore import QByteArray

icon_path = Path("assets/pin.svg")
icon_bytes = icon_path.read_bytes()
icon_sources = {
    "path_object": icon_path,
    "path_string": str(icon_path),
    "bytes": icon_bytes,
    "bytearray": bytearray(icon_bytes),
    "memoryview": memoryview(icon_bytes),
    "qbytearray": QByteArray(icon_bytes),
    "data_uri": "data:image/svg+xml;base64,"
                + base64.b64encode(icon_bytes).decode("ascii"),
    "remote_url": "https://example.com/pin.svg",
}
# Supply one (latitude, longitude) coordinate for each source above.
icon_coords = [(lat, lon), ...]

for index, (source_name, icon_source) in enumerate(icon_sources.items()):
    vector.add_icon_points(
        coords=[icon_coords[index]],
        icon=icon_source,
        # Optional; accepts any source form listed above.
        selected_icon=Path("assets/pin-selected.svg") if index == 0 else None,
        ids=[f"icon_{source_name}"],
        properties=[{"icon_source": source_name}],
        scale=1.0,
        opacity=0.95,
        anchor=(0.5, 1.0),  # bottom-center sits on the coordinate
        rotation_deg=45.0,  # clockwise degrees from true north
        rotate_with_view=False,
        # Useful for remote URLs when their server permits CORS. Remote icons
        # without CORS still render, but selection uses a halo instead of tinting.
        cross_origin="anonymous" if source_name == "remote_url" else None,
    )

# Add polygons
vector.add_polygon(
    ring=[(lat1, lon1), (lat2, lon2), ...],
    feature_id="poly1",
    style=PolygonStyle(
        stroke_color=QColor("dodgerblue"),
        stroke_width=2.0,
        fill_color=QColor("dodgerblue"),
        fill_opacity=0.15
    )
)

# Add lines (polylines)
vector.add_line(
    coords=[(lat1, lon1), (lat2, lon2), (lat3, lon3)],
    feature_id="ln1",
    style=PolygonStyle(
        stroke_color=QColor("dodgerblue"),
        stroke_width=2.0
    )
)

# Add gradient lines (e.g., speed along a track)
# per-segment values are converted to smooth per-vertex interpolation
vector.add_gradient_line(
    coords=[(lat1, lon1), (lat2, lon2), (lat3, lon3), (lat4, lon4)],
    values=[8.5, 12.1, 5.9],  # per-segment values (smoothed through vertices)
    feature_id="track_speed",
    cmap="turbo",
    vmin=0.0,
    vmax=20.0,
    style=PolygonStyle(stroke_width=4.0),
    properties={"metric": "speed_mps"},
    interpolate_steps=64,
)

# Optional: pass explicit segment colors instead of cmap
# vector.add_gradient_line(..., segment_colors=["blue", "green", "red"])


# Smooth transitions: pass per-vertex values and interpolate between points
vector.add_gradient_line(
    coords=[(lat1, lon1), (lat2, lon2), (lat3, lon3)],
    values=[5.0, 12.0, 20.0],  # per-vertex values
    cmap="plasma",
    vmin=0.0,
    vmax=25.0,
    interpolate_steps=64,
)

# Add circles (radius in meters)
vector.add_circle(
    center=(lat, lon),
    radius_m=1000.0,
    feature_id="circle1",
    style=CircleStyle(stroke_color=QColor("dodgerblue"), fill_opacity=0.15)
)

# Add ellipses (semi-major/minor axes in meters, tilt in degrees from north)
vector.add_ellipse(
    center=(lat, lon),
    sma_m=2000.0,  # Semi-major axis
    smi_m=1200.0,  # Semi-minor axis
    tilt_deg=45.0,  # Tilt from true north
    feature_id="ell1",
    style=EllipseStyle(stroke_color=QColor("gold"), fill_opacity=0.12)
)

# Update styles of specific features (e.g., selected features)
feature_ids = ["id1", "id2"]
new_styles = [
    PointStyle(radius=8.0, fill_color=QColor("red"), fill_opacity=1.0),
    PointStyle(radius=8.0, fill_color=QColor("green"), fill_opacity=1.0),
]
vector.update_feature_styles(feature_ids, new_styles)

# Remove features
vector.remove_features(["id1", "poly1"])

# Clear all features
vector.clear()
image
Custom icon API

VectorLayer.add_icon_points() accepts these values for both icon and selected_icon:

Source value Behavior
pathlib.Path or another os.PathLike The local file is copied into the widget's HTTP-served icon cache.
String path to an existing local file The local file is copied into the widget's HTTP-served icon cache.
bytes, bytearray, memoryview, or PySide6.QtCore.QByteArray The image data is written into the widget's HTTP-served icon cache.
http:// or https:// URL The browser loads the remote image directly.
data:, file:, or qrc: URI The browser loads the URI directly.

The icon cache detects PNG, JPEG, GIF, WebP, and SVG image data. A string that is neither a recognized URI nor an existing local path is passed through to the browser unchanged.

add_icon_points() uses the following arguments:

Argument Description
coords Sequence of (latitude, longitude) coordinates.
icon Required icon source unless style.icon_src supplies one.
selected_icon Optional replacement icon shown while selected; accepts every icon source form.
ids Optional feature IDs; generated automatically when omitted.
properties Optional property dictionary for each point.
scale Image scale multiplier; defaults to 1.0.
opacity Image opacity from 0.0 to 1.0; defaults to 1.0.
anchor Image anchor fractions; (0.5, 1.0) places the bottom-center on the coordinate.
rotation_deg Clockwise rotation in degrees from true north.
rotate_with_view When true, rotates the icon with the map view.
cross_origin Optional OpenLayers cross-origin value, such as "anonymous", for remote images.
style Optional reusable IconStyle. When supplied, its style settings take precedence over the direct scale, opacity, anchor, rotation, and cross-origin arguments. The icon and selected_icon arguments still override style.icon_src and style.selected_icon_src.

For a runnable map showing path objects, path strings, each byte-like type, a data URI, and a remote URL, see examples/02_layer_types_and_styling.py.

FastPointsLayer

High-performance layer for rendering millions of points using canvas and spatial indexing.

from pyopenlayersqt import FastPointsStyle

# Create fast points layer
fast = map_widget.add_fast_points_layer(
    "fast_points",
    selectable=True,
    style=FastPointsStyle(
        radius=2.5,
        default_color="green",  # Color name or QColor
        selected_radius=6.0,
        selected_color="yellow"
    ),
    cell_size_m=750.0  # Spatial index cell size
)

# Add points (efficient for large datasets)
coords = [(lat, lon), ...]  # millions of points
ids = [f"pt{i}" for i in range(len(coords))]

# Option 1: Single color for all points
fast.add_points(coords, ids=ids)

# Option 2: Per-point colors using QColor objects
from PySide6.QtGui import QColor
colors = [QColor(255, 0, 0, 180), QColor(0, 255, 0, 180), ...]
fast.add_points(coords, ids=ids, colors_rgba=colors)

# Option 3: Per-point colors using color names
colors = ["red", "green", "blue", ...]
fast.add_points(coords, ids=ids, colors_rgba=colors)

# Remove specific points
fast.remove_points(["pt1", "pt2"])

# Update colors of specific points (e.g., selected points)
feature_ids = ["pt10", "pt25", "pt50"]
# Use QColor objects (recommended)
from PySide6.QtGui import QColor
new_colors = [QColor("red"), QColor("green"), QColor("blue")]
fast.set_colors(feature_ids, new_colors)
# Or color names
new_colors = ["red", "green", "blue"]
fast.set_colors(feature_ids, new_colors)

# Temporarily hide/show features (without removing them)
fast.hide_features(["pt100", "pt200"])
fast.show_features(["pt100"])
fast.show_all_features()  # Show all hidden features

# Clear all points
fast.clear()
image

FastGeoPointsLayer

High-performance layer for geolocation data with uncertainty ellipses.

from pyopenlayersqt import FastGeoPointsStyle

# Create fast geo points layer
fast_geo = map_widget.add_fast_geopoints_layer(
    "fast_geo",
    selectable=True,
    style=FastGeoPointsStyle(
        # Point styling
        point_radius=2.5,
        default_color="steelblue",  # Color name or QColor
        selected_point_radius=6.0,
        selected_color="white",
        # Ellipse styling
        ellipse_stroke_color="steelblue",
        ellipse_stroke_width=1.2,
        fill_ellipses=False,
        ellipse_fill_color=QColor(40, 80, 255, 40),
        # Behavior
        ellipses_visible=True,
        selected_ellipses_visible=True,  # Independent toggle for selected ellipses
        min_ellipse_px=0.0,  # Cull tiny ellipses
        max_ellipses_per_path=2000,
        skip_ellipses_while_interacting=True
    ),
    cell_size_m=750.0
)

# Add points with uncertainty ellipses
coords = [(lat, lon), ...]
sma_m = [200.0, 300.0, ...]  # Semi-major axes in meters
smi_m = [100.0, 150.0, ...]  # Semi-minor axes in meters
tilt_deg = [45.0, 90.0, ...]  # Tilt from north in degrees
ids = [f"geo{i}" for i in range(len(coords))]

fast_geo.add_points_with_ellipses(
    coords=coords,
    sma_m=sma_m,
    smi_m=smi_m,
    tilt_deg=tilt_deg,
    ids=ids
)

# Toggle ellipse visibility
fast_geo.set_ellipses_visible(False)

# Toggle selected-ellipse visibility independently
fast_geo.set_selected_ellipses_visible(False)

# Update colors of specific points (e.g., selected points)
feature_ids = ["geo5", "geo12", "geo20"]
# Use QColor objects (recommended)
from PySide6.QtGui import QColor
new_colors = [QColor("red"), QColor("green"), QColor("blue")]
fast_geo.set_colors(feature_ids, new_colors)
# Or color names
new_colors = ["red", "green", "blue"]
fast_geo.set_colors(feature_ids, new_colors)

# Temporarily hide/show features (without removing them)
fast_geo.hide_features(["geo100", "geo200"])
fast_geo.show_features(["geo100"])
fast_geo.show_all_features()  # Show all hidden features

# Remove points
fast_geo.remove_ids(["geo1", "geo2"])

# Clear all
fast_geo.clear()
image

WMSLayer

Web Map Service layer integration.

from pyopenlayersqt import WMSOptions

# Add WMS layer
wms_options = WMSOptions(
    url="https://ahocevar.com/geoserver/wms",
    params={
        "LAYERS": "topp:states",
        "TILED": True,
        "FORMAT": "image/png",
        "TRANSPARENT": True
    },
    opacity=0.85
)

wms_layer = map_widget.add_wms(wms_options, name="wms")

# Update WMS parameters
wms_layer.set_params({"LAYERS": "new:layer"})

# Set opacity
wms_layer.set_opacity(0.5)

# Remove layer
wms_layer.remove()
image

RasterLayer

Image overlay layer for heatmaps, imagery, etc.

from pyopenlayersqt import RasterStyle

# Create PNG bytes (example using PIL)
from PIL import Image
import io

img = Image.new('RGBA', (512, 512), color=(255, 0, 0, 128))
buf = io.BytesIO()
img.save(buf, format='PNG')
png_bytes = buf.getvalue()

# Add raster overlay
bounds = [
    (lat_min, lon_min),  # Southwest corner
    (lat_max, lon_max)   # Northeast corner
]

raster = map_widget.add_raster_image(
    png_bytes,  # Can be bytes, file path, or URL
    bounds=bounds,
    style=RasterStyle(opacity=0.6),
    name="heatmap"
)

# Update opacity
raster.set_opacity(0.8)

# Remove layer
raster.remove()
image

Style Classes

All style classes are immutable dataclasses with sensible defaults:

from pyopenlayersqt import (
    PointStyle,
    IconStyle,
    PolygonStyle,
    CircleStyle,
    EllipseStyle,
    RasterStyle,
    FastPointsStyle,
    FastGeoPointsStyle
)
from pathlib import Path
from PySide6.QtGui import QColor

# Vector styles use QColor objects or color names (recommended)
point_style = PointStyle(
    radius=5.0,
    fill_color=QColor("red"),        # QColor object (recommended)
    fill_opacity=0.85,
    stroke_color=QColor("black"),    # QColor object
    stroke_width=1.0,
    stroke_opacity=0.9
)

# For most icon markers, pass style settings directly to add_icon_points().
# Use IconStyle when you need to reuse settings or use pixel anchor units.
icon_style = IconStyle(
    scale=1.0,
    opacity=0.95,
    anchor=(16.0, 32.0),
    anchor_x_units="pixels",
    anchor_y_units="pixels",
    rotation_deg=90.0,  # clockwise degrees from true north
    rotate_with_view=False,
    cross_origin="anonymous",
)
vector.add_icon_points(
    coords=[(lat, lon)],
    icon=Path("assets/pin.svg"),
    selected_icon=Path("assets/pin-selected.svg"),
    style=icon_style,
)
# When style is supplied, its settings take precedence over direct scale, opacity,
# anchor, rotation_deg, rotate_with_view, and cross_origin arguments.

# You can also use color names directly
polygon_style = PolygonStyle(
    stroke_color="red",    # Color name string
    fill_color="green"     # Color name string
)

# Fast layer styles support QColor/color names (recommended)
# Recommended: Using QColor objects or color names
fast_style_qcolor = FastPointsStyle(
    radius=3.0,
    default_color=QColor("steelblue"),  # QColor object
    selected_radius=6.0,
    selected_color="orange"              # Color name string
)

# Legacy (deprecated): Using RGBA tuples
fast_style = FastPointsStyle(
    radius=3.0,
    default_rgba=(255, 51, 51, 204),
    selected_radius=6.0,
    selected_rgba=(0, 255, 255, 255)
)

# Mixed: Both styles (color options take precedence)
fast_style_mixed = FastPointsStyle(
    radius=3.0,
    default_rgba=(255, 51, 51, 204),     # Fallback
    default_color="purple",               # This takes precedence
    selected_radius=6.0,
    selected_color=QColor("yellow")       # This takes precedence
)

# FastGeoPointsStyle supports QColor for all colors (points and ellipses)
geo_style = FastGeoPointsStyle(
    point_radius=4.0,
    default_color="darkgreen",                    # Point color (QColor or color name)
    selected_color=QColor("red"),                 # Selected point color
    ellipse_stroke_color="darkgreen",             # Ellipse stroke color
    ellipse_fill_color=QColor(0, 100, 0, 40),    # Ellipse fill color (with alpha)
    selected_ellipse_stroke_color="red",          # Selected ellipse stroke color
    fill_ellipses=True,
    ellipses_visible=True,
    selected_ellipses_visible=True            # Hide/show selected ellipses independently
)

Key Features:

  • QColor Support in ALL Styles: Pass QColor objects directly to any color parameter in PointStyle, CircleStyle, PolygonStyle, EllipseStyle, FastPointsStyle, and FastGeoPointsStyle - no need for .name()
  • Color Names Everywhere: Use color names like "red", "Green", "steelblue" directly in all Style classes
  • Custom Icon Markers: Use VectorLayer.add_icon_points(icon=..., selected_icon=...) to place points rendered with a local image path, URL, data URI, or image bytes; local files and bytes-like values (including QByteArray) are served to the embedded browser automatically, selected_icon overrides selection rendering when provided, and selected icons otherwise use the vector selection color (tinting for same-origin/data/CORS-enabled icons, halo fallback for other remote URLs)
  • Multiple Formats: Color styles accept QColor objects, color names, hex strings, and CSS strings (RGBA tuples are deprecated)
  • Backward Compatible: Existing code using RGBA tuples or hex colors continues to work
  • Z-Ordering: Selected points and ellipses are automatically drawn on top in dense areas

Feature Selection

Selection is synchronized between the map and Python:

# Set selection programmatically
map_widget.set_vector_selection(layer_id, ["feature1", "feature2"])
map_widget.set_fast_points_selection(layer_id, ["pt1", "pt2"])
map_widget.set_fast_geopoints_selection(layer_id, ["geo1", "geo2"])

# Listen to selection changes from map
def on_selection_changed(selection):
    print(f"Layer: {selection.layer_id}")
    print(f"Selected IDs: {selection.feature_ids}")
    print(f"Count: {selection.count}")

map_widget.selectionChanged.connect(on_selection_changed)

Selection and Recoloring

For updating styles of selected features, see the layer-specific methods documented above:

  • VectorLayer.update_feature_styles() - Update styles for vector features
  • FastPointsLayer.set_colors() - Update colors for fast points
  • FastGeoPointsLayer.set_colors() - Update colors for fast geo-points
  • FastGeoPointsLayer.set_selected_ellipses_visible() - Toggle selected ellipse outlines independently from unselected ellipses

Multi-layer selection workflow example:

# Track selections for all layers (layer_id -> list of feature_ids)
selections = {}

def on_selection_changed(selection):
    global selections
    # Update selections for this layer
    if len(selection.feature_ids) > 0:
        selections[selection.layer_id] = selection.feature_ids
    elif selection.layer_id in selections:
        # Clear selection for this layer
        del selections[selection.layer_id]
    
    total = sum(len(ids) for ids in selections.values())
    print(f"Total selected: {total} features across {len(selections)} layer(s)")

map_widget.selectionChanged.connect(on_selection_changed)

# Recolor all selected items across all layers
def recolor_selected_red():
    from PySide6.QtGui import QColor
    for layer_id, feature_ids in selections.items():
        if layer_id == vector_layer.id:
            styles = [PointStyle(fill_color="red") for _ in feature_ids]
            vector_layer.update_feature_styles(feature_ids, styles)
        elif layer_id == fast_layer.id:
            colors = [QColor("red") for _ in feature_ids]
            fast_layer.set_colors(feature_ids, colors)
        elif layer_id == fast_geo_layer.id:
            colors = [QColor("red") for _ in feature_ids]
            fast_geo_layer.set_colors(feature_ids, colors)

See examples/09_selection_and_recoloring.py for a complete interactive example.

Deleting Features

Each layer type provides methods to remove features, either individually, in batches, or all at once.

VectorLayer Deletion

# Remove specific features by ID
vector_layer.remove_features(["point1", "polygon2", "circle3"])

# Clear all features from the layer
vector_layer.clear()

FastPointsLayer Deletion

# Remove specific points by ID
fast_layer.remove_points(["pt1", "pt2", "pt100"])

# Clear all points from the layer
fast_layer.clear()

FastGeoPointsLayer Deletion

# Remove specific geo-points by ID
geo_layer.remove_ids(["geo1", "geo2", "geo50"])

# Clear all geo-points from the layer
geo_layer.clear()

Deleting Selected Features

A common pattern is to delete features that the user has selected interactively:

from PySide6.QtGui import QShortcut, QKeySequence

# Track selections across all layers
selections = {}

def on_selection_changed(selection):
    """Update the selections dictionary when selection changes."""
    if len(selection.feature_ids) > 0:
        selections[selection.layer_id] = selection.feature_ids
    elif selection.layer_id in selections:
        del selections[selection.layer_id]

map_widget.selectionChanged.connect(on_selection_changed)

def delete_selected():
    """Delete all currently selected features across all layers."""
    for layer_id, feature_ids in list(selections.items()):
        if layer_id == vector_layer.id:
            vector_layer.remove_features(feature_ids)
        elif layer_id == fast_layer.id:
            fast_layer.remove_points(feature_ids)
        elif layer_id == geo_layer.id:
            geo_layer.remove_ids(feature_ids)
    
    # Clear selections after deletion
    selections.clear()
    print(f"Deleted features")

# Connect to a button
delete_button.clicked.connect(delete_selected)

# Or add keyboard shortcut (Delete key)
delete_shortcut = QShortcut(QKeySequence.Delete, map_widget)
delete_shortcut.activated.connect(delete_selected)

Removing Entire Layers

To remove an entire layer from the map:

# Remove the layer (also removes all its features)
vector_layer.remove()
fast_layer.remove()
geo_layer.remove()

Complete CRUD Example: See examples/08_table_integration.py for a full working example demonstrating Create, Read, Update, and Delete operations with interactive add/delete buttons and keyboard shortcuts across all layer types.

Distance Measurement Mode

Interactive distance measurement with geodesic calculations and a clean callback API:

from pyopenlayersqt import MeasurementUpdate

# Enable measurement mode
map_widget.set_measure_mode(True)

# Listen for typed measurement updates
def on_measurement(update: MeasurementUpdate):
    if update.segment_distance_m is not None:
        print(f"Segment: {update.segment_distance_m:.1f} m")
    print(f"Total: {update.cumulative_distance_m:.1f} m")
    print(f"Point at ({update.lat:.5f}, {update.lon:.5f})")

handle = map_widget.on_measurement_updated(on_measurement)
# (Optional) also available as a Qt signal:
# map_widget.measurementUpdated.connect(on_measurement)

# Clear all measurements
map_widget.clear_measurements()

# Stop callback if needed
handle.cancel()

# Disable measurement mode
map_widget.set_measure_mode(False)

Features:

  • Click on map to create measurement anchor points
  • Live polyline drawn from last point to cursor
  • Tooltip displays segment and cumulative distances
  • Uses Haversine formula for accurate great-circle distances
  • Lines follow great-circle paths - measurement lines curve to represent the true shortest path on Earth's surface
  • Curved paths are especially visible for long distances (e.g., New York to London)
  • Press Escape to exit measurement mode
  • Measurement updates emitted to Python as MeasurementUpdate objects with distances and coordinates

See examples/11_measurement_tool.py for a complete working example. image

FeatureTableWidget

High-performance table widget for displaying and managing features:

from pyopenlayersqt.features_table import FeatureTableWidget, ColumnSpec

# Define columns
columns = [
    ColumnSpec("Layer", lambda r: r.get("layer_kind", "")),
    ColumnSpec("Type", lambda r: r.get("geom_type", "")),
    ColumnSpec("ID", lambda r: r.get("feature_id", "")),
    ColumnSpec(
        "Latitude",
        lambda r: r.get("center_lat", ""),
        fmt=lambda v: f"{float(v):.6f}" if v != "" else ""
    ),
    ColumnSpec(
        "Longitude",
        lambda r: r.get("center_lon", ""),
        fmt=lambda v: f"{float(v):.6f}" if v != "" else ""
    ),
]

# Create table
table = FeatureTableWidget(
    columns=columns,
    key_fn=lambda r: (str(r.get("layer_id", "")), str(r.get("feature_id", ""))),
    debounce_ms=90
)

# Add rows
rows = [
    {
        "layer_kind": "vector",
        "layer_id": "v1",
        "feature_id": "pt1",
        "geom_type": "point",
        "center_lat": 37.7749,
        "center_lon": -122.4194
    }
]
table.append_rows(rows)

# Sync selection: table -> map
def on_table_selection(keys):
    # keys is list of (layer_id, feature_id) tuples
    for layer_id, feature_id in keys:
        # Update map selection based on layer type
        pass

table.selectionKeysChanged.connect(on_table_selection)

# Sync selection: map -> table
def on_map_selection(selection):
    keys = [(selection.layer_id, fid) for fid in selection.feature_ids]
    table.select_keys(keys, clear_first=True)

map_widget.selectionChanged.connect(on_map_selection)

# Optional: built-in right-click menu actions
from pyopenlayersqt.features_table import ContextMenuActionSpec


def view_metadata(event):
    # event.keys -> [(layer_id, feature_id), ...]
    # event.rows -> underlying row objects for selected rows
    print("Metadata:", event.rows)


def delete_selected(event):
    if event.keys:
        table.remove_keys(event.keys)


table.set_context_menu_actions([
    ContextMenuActionSpec("View Metadata", view_metadata),
    ContextMenuActionSpec("Delete Selected", delete_selected),
])

# Optional hook for custom menus owned by your GUI code
# table.contextMenuRequested.connect(on_context_menu_requested)

Virtual/lazy row providers

For very large datasets, use a row provider instead of appending one Python row object per table row. A provider implements the small TableRowProvider protocol: row_count(), data(source_row, column, column_spec), key(source_row), row_for_key(key), and row_data(source_row). The table keeps the same selection API (selectionKeysChanged, select_keys(), select_feature_ids(), and set_visible_row_indices()), but resolves display values and selection keys lazily through the provider.

from pyopenlayersqt.features_table import ColumnSpec, FeatureTableWidget


class ArrayBackedProvider:
    def __init__(self, layer_id, names, values):
        self.layer_id = layer_id
        self.names = names
        self.values = values

    def row_count(self):
        return len(self.names)

    def data(self, source_row, column, column_spec):
        if column_spec.name == "ID":
            return f"pt_{source_row}"
        if column_spec.name == "Name":
            return self.names[source_row]
        if column_spec.name == "Value":
            return self.values[source_row]
        return ""

    def key(self, source_row):
        return (self.layer_id, f"pt_{source_row}")

    def row_for_key(self, key):
        layer_id, feature_id = key
        if layer_id != self.layer_id or not feature_id.startswith("pt_"):
            return None
        row = int(feature_id[3:])
        return row if 0 <= row < self.row_count() else None

    def row_data(self, source_row):
        return {
            "feature_id": f"pt_{source_row}",
            "name": self.names[source_row],
            "value": self.values[source_row],
        }


table = FeatureTableWidget(
    columns=[
        ColumnSpec("ID", lambda row: row.get("feature_id", "")),
        ColumnSpec("Name", lambda row: row.get("name", "")),
        ColumnSpec("Value", lambda row: row.get("value", "")),
    ],
    sorting_enabled=False,
)
table.set_row_provider(ArrayBackedProvider("measurements", names, values))

See examples/19_virtual_feature_table.py for a minimal runnable example.

Multi-table linking pattern

For parent/child selection workflows (including one parent + many child tables), use TableLink + MultiSelectLink from pyopenlayersqt.selection_linking.

from pyopenlayersqt import MultiSelectLink, TableLink

# parent table/layer
parent = TableLink(table=regions_table, layer=region_layer)

# any number of child table/layers
kids = {
    "sites": TableLink(table=sites_table, layer=sites_layer),
    "assets": TableLink(table=assets_table, layer=assets_layer),
    "tickets": TableLink(table=tickets_table, layer=tickets_layer),
}

# mapping for each child table: child_feature_id -> parent_feature_id
parent_by_kid = {
    "sites": site_to_region,
    "assets": asset_to_region,
    "tickets": ticket_to_region,
}

link = MultiSelectLink(
    map_widget=map_widget,
    parent=parent,
    kids=kids,
    parent_by_kid=parent_by_kid,
    clear_parent_on_kid_subset=True,
)

# If mappings change later (e.g., data reload)
link.set_links(parent_by_kid)

# Programmatic parent selection fans out to all child tables/layers
link.set_parent(["region_1", "region_5"])

For metadata-only child rows (no map layer), use key_layer_id:

metadata_table = FeatureTableWidget(
    columns=[...],
    key_fn=lambda r: (region_layer.id, str(r["site_uuid"])),
)

link = MultiSelectLink(
    map_widget=map_widget,
    parent=TableLink(table=regions_table, layer=region_layer),
    kids={
        "site_metadata": TableLink(
            table=metadata_table,
            key_layer_id=region_layer.id,
        )
    },
    parent_by_kid={"site_metadata": site_to_region},
)

Design pattern:

  • Keep one authoritative parent entity (e.g., Region).
  • For each child table, maintain a lightweight child_id -> parent_id dict.
  • Feed all child mappings into one MultiSelectLink instance.
  • Let the link own Qt signal wiring and map/table sync logic (instead of per-view glue code).

How to choose the linking field (id/uuid/etc):

  • MultiSelectLink matches on feature IDs (the second value in each table key tuple).
  • Use the same canonical ID string in 3 places:
    1. table key (key_fn),
    2. map feature ids=[...],
    3. mapping dict (child_id -> parent_id).
  • Example rule of thumb: region_uuid in your data becomes the map/table region_id.
# 3+ table example with explicit UUID -> ID mapping
# parent entity = Region
# canonical parent ID used by linking = str(region["region_uuid"])

regions_table = FeatureTableWidget(
    columns=[...],
    key_fn=lambda r: (region_layer.id, str(r["region_uuid"])),
)
sites_table = FeatureTableWidget(
    columns=[...],
    key_fn=lambda r: (sites_layer.id, str(r["site_uuid"])),
)
assets_table = FeatureTableWidget(
    columns=[...],
    key_fn=lambda r: (assets_layer.id, str(r["asset_uuid"])),
)
tickets_table = FeatureTableWidget(
    columns=[...],
    key_fn=lambda r: (tickets_layer.id, str(r["ticket_uuid"])),
)

# Map feature IDs must match table key IDs
region_layer.add_points(region_coords, ids=[str(r["region_uuid"]) for r in region_rows])
sites_layer.add_points(site_coords, ids=[str(r["site_uuid"]) for r in site_rows])
assets_layer.add_points(asset_coords, ids=[str(r["asset_uuid"]) for r in asset_rows])
tickets_layer.add_points(ticket_coords, ids=[str(r["ticket_uuid"]) for r in ticket_rows])

# Child -> parent mappings (child UUID -> parent Region UUID)
site_to_region = {str(x["site_uuid"]): str(x["region_uuid"]) for x in site_rows}
asset_to_region = {str(x["asset_uuid"]): str(x["region_uuid"]) for x in asset_rows}
ticket_to_region = {str(x["ticket_uuid"]): str(x["region_uuid"]) for x in ticket_rows}

link = MultiSelectLink(
    map_widget=map_widget,
    parent=TableLink(table=regions_table, layer=region_layer),
    kids={
        "sites": TableLink(table=sites_table, layer=sites_layer),
        "assets": TableLink(table=assets_table, layer=assets_layer),
        "tickets": TableLink(table=tickets_table, layer=tickets_layer),
    },
    parent_by_kid={
        "sites": site_to_region,
        "assets": asset_to_region,
        "tickets": ticket_to_region,
    },
)

As long as that ID contract is consistent, the original field names can be anything (id, uuid, pk, etc.).

About fan-out chains (Table1 -> Table2 -> Table3):

  • A single MultiSelectLink supports one level of fan-out (parent -> many children).
  • For deeper cascades, compose multiple links:
    • Link A: Table1 parent -> Table2 children
    • Link B: Table2 parent -> Table3 children
  • In other words: yes, this pattern supports that workflow by chaining links per level.

See examples/13_dual_table_linking.py for the map-to-map version and examples/16_metadata_only_table_linking.py for the map-to-metadata-only version.

Row removal APIs: remove_keys vs remove_where

FeatureTableWidget provides two row-removal methods for different use cases:

  • table.remove_keys(keys)

    • Use when you already know the exact (layer_id, feature_id) keys to remove.

    • Best for map-driven actions like deleting selected features from one or more layers, because keys are already available from selection events.

    • Example:

      selected_keys = table.selected_keys()  # [(layer_id, feature_id), ...]
      if selected_keys:
          table.remove_keys(selected_keys)
      
  • table.remove_where(predicate)

    • Use when removal logic depends on arbitrary row attributes/conditions rather than known keys.

    • Example:

      # Remove all rows from a specific layer kind
      table.remove_where(lambda row: row.get("layer_kind") == "geo_points")
      

In short: prefer remove_keys for explicit feature-ID removals (typical CRUD flows), and remove_where for ad-hoc, attribute-based filtering/removal.

RangeSliderWidget

Dual-handle range slider for filtering features by numeric or timestamp ranges:

from pyopenlayersqt.range_slider import RangeSliderWidget
from pyopenlayersqt import FastPointsStyle

# Create a fast points layer (required for hide/show features)
fast_layer = map_widget.add_fast_points_layer(
    "filterable_points",
    selectable=True,
    style=FastPointsStyle(radius=3.0, default_color="green")
)

# Numeric range slider
value_slider = RangeSliderWidget(
    min_val=0.0,
    max_val=100.0,
    step=1.0,
    label="Filter by Value"
)

# Connect to filter function
def on_value_range_changed(min_val, max_val):
    # Filter features based on value range
    visible_ids = [f["id"] for f in features if min_val <= f["value"] <= max_val]
    hidden_ids = [f["id"] for f in features if not (min_val <= f["value"] <= max_val)]
    
    # Hide/show features on map (FastPointsLayer and FastGeoPointsLayer only)
    if hidden_ids:
        fast_layer.hide_features(hidden_ids)
    if visible_ids:
        fast_layer.show_features(visible_ids)
    
    # Hide/show rows in table
    layer_id = fast_layer.id
    table.hide_rows_by_keys([(layer_id, fid) for fid in hidden_ids])
    table.show_rows_by_keys([(layer_id, fid) for fid in visible_ids])

value_slider.rangeChanged.connect(on_value_range_changed)

# ISO8601 timestamp range slider (can be created before values are available)
timestamp_slider = RangeSliderWidget(
    is_iso8601=True,
    label="Filter by Timestamp"
)

# Later, after loading data:
timestamp_slider.set_range("2024-01-01T00:00:00Z", "2024-01-31T23:59:59Z")

timestamp_slider.rangeChanged.connect(on_timestamp_range_changed)

# Reset filters - show all features again
fast_layer.show_all_features()  # Show all on map
table.show_all_rows()  # Show all in table

See examples/10_range_slider_filtering.py for a complete working example with map and table filtering. image

Complete Example

For a comprehensive demonstration of all features, see the complete working example at examples/08_table_integration.py. This example includes:

  • Vector and fast points layers
  • Feature table with bidirectional selection sync
  • Sample data generation
  • Layer management

View Extent Tracking

Monitor map extent changes for dynamic data loading:

# One-time extent request
def on_extent(extent):
    print(f"Extent: {extent['lon_min']}, {extent['lat_min']} to "
          f"{extent['lon_max']}, {extent['lat_max']}")
    print(f"Zoom: {extent['zoom']}, Resolution: {extent['resolution']}")

map_widget.get_view_extent(on_extent)

# Watch extent changes (debounced)
def on_extent_changed(extent):
    # Load data for current extent
    load_data_for_extent(extent)

handle = map_widget.watch_view_extent(on_extent_changed, debounce_ms=150)

# Stop watching
handle.cancel()

Advanced: Direct JavaScript Communication

For advanced use cases, you can send custom messages to the JavaScript bridge:

# Send custom message to JavaScript
map_widget.send({
    "type": "custom_command",
    "param1": "value1",
    "param2": 123
})

# Listen to JavaScript events
def on_js_event(event_type, payload_json):
    print(f"Event: {event_type}, Payload: {payload_json}")

map_widget.jsEvent.connect(on_js_event)

Right-click map context menu (custom app actions)

The map emits a contextmenu JavaScript event when users right-click anywhere on the map. The payload includes map coordinates and (if applicable) the clicked feature id/layer id, so you can open a custom Qt menu.

import json
from PySide6 import QtCore, QtWidgets

def on_js_event(event_type, payload_json):
    if event_type != "contextmenu":
        return
    payload = json.loads(payload_json)
    lat = payload["lat"]
    lon = payload["lon"]
    feature_id = payload.get("feature_id")

    menu = QtWidgets.QMenu()
    create_action = menu.addAction("Create point here")
    if feature_id:
        open_action = menu.addAction(f"Open dialog for {feature_id}")

    # map client coordinates -> global screen coordinates
    global_pos = map_widget.mapToGlobal(
        QtCore.QPoint(int(payload["client_x"]), int(payload["client_y"]))
    )
    chosen = menu.exec(global_pos)

    if chosen == create_action:
        create_point(lat, lon)
    elif feature_id and chosen == open_action:
        open_point_dialog(feature_id)

map_widget.jsEvent.connect(on_js_event)

For a complete runnable demo, see examples/17_map_right_click_context_menu.py.

Performance Tips

  1. Use Fast Layers for Large Datasets: For > 1000 points, use FastPointsLayer or FastGeoPointsLayer instead of vector layers
  2. Tune Cell Size: Adjust cell_size_m parameter based on your data density (larger = faster, but less precise selection)
  3. Chunk Large Additions: FastGeoPointsLayer.add_points_with_ellipses() automatically chunks data (default 50k points per chunk)
  4. Debounce Extent Watching: Use appropriate debounce_ms when watching extent changes to avoid excessive updates
  5. Cull Tiny Ellipses: Set min_ellipse_px in FastGeoPointsStyle to skip rendering very small ellipses
  6. Skip Ellipses While Interacting: Enable skip_ellipses_while_interacting for smoother panning/zooming

Architecture

  • Python → JavaScript: Commands sent via window.pyolqt_send()
  • JavaScript → Python: Events sent via Qt Web Channel (qtBridge.emitEvent())
  • Static Assets: Served by embedded HTTP server (wheel-safe)
  • Raster Overlays: Written to user cache directory and served dynamically

License

MIT License

Contributing

Contributions are welcome! Please feel free to submit issues or pull requests.

For maintainers, see CONTRIBUTING.md for information on creating releases and publishing to PyPI.

Credits

Built with:

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyopenlayersqt-0.16.0.tar.gz (6.5 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyopenlayersqt-0.16.0-py3-none-any.whl (6.5 MB view details)

Uploaded Python 3

File details

Details for the file pyopenlayersqt-0.16.0.tar.gz.

File metadata

  • Download URL: pyopenlayersqt-0.16.0.tar.gz
  • Upload date:
  • Size: 6.5 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pyopenlayersqt-0.16.0.tar.gz
Algorithm Hash digest
SHA256 da7c390802dce702e131685d457153678d84bc019d1a22bdc41817c21d6acc24
MD5 ef96a6d9e20683437dd5f416f70218f8
BLAKE2b-256 92e05369aed37986f9c276e3549a673404e95f4fc1768e65cbcba5beb31a072d

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyopenlayersqt-0.16.0.tar.gz:

Publisher: publish.yml on crroush/pyopenlayersqt

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyopenlayersqt-0.16.0-py3-none-any.whl.

File metadata

File hashes

Hashes for pyopenlayersqt-0.16.0-py3-none-any.whl
Algorithm Hash digest
SHA256 03ea036c53e5599530487b7e527a54a3eafe414287d8db61c28c1c9813a34307
MD5 5a1639f25592131fd88e0957f9a23292
BLAKE2b-256 8d315b380800a263ed208e3a23c15d707033c5992f957a499c26495f1fdd585e

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyopenlayersqt-0.16.0-py3-none-any.whl:

Publisher: publish.yml on crroush/pyopenlayersqt

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page