Lizmap Web Client 3.9.0 - Javascript to inject a websocket service with devices positions on LWC

Hi everybody.

I’m trying to create a project in LWC with real time device position by the second…i already have that being stored in a PostGis database, but instead of the LWC requests the latest positions from the database, I want to inject it directly on the LWC.

I have a websocket service running in node-red that outputs something like this:

{"name":"kanholas-1","lat":38.8663,"lon":-8.55705,"label":"kanholas-1, Speed: 73.37838","popup":"<b>kanholas-1</b><br>Timestamp: 2025-07-01T13:14:43<br>Depth/Altitude: 10.1081<br>Speed: 73.37838<br>Altitude: 10.1081","icon":"fa-circle-o","iconColor":"#fce405","ttl":"0.1","labelOffset":[0,20]}
{"name":"kanholas-2","lat":36.9121,"lon":-10.5139,"label":"kanholas-2, Speed: 32.527797","popup":"<b>kanholas-2</b><br>Timestamp: 2025-07-01T13:14:46<br>Depth/Altitude:0<br>Speed: 32.527797<br>Altitude: 0","icon":"fa-circle-o","iconColor":"#fce405","ttl":"0.1","labelOffset":[0,20]}

I’m succeeding injecting on LWC, my only problem that I can’t figure out is why it is position below all layers (including the active baselayer)

The only way I can see the points move in real time is to add a “no base map” option on Qgis Desktop using Lizmap plugin:

Can anyone give me a hint how to do this? Here’s my deviceTracker.js

// deviceTracker.js for Lizmap Web Client (Legacy OpenLayers 2 Environment)
// - FINAL ROBUST VERSION
// - SOLVES REDRAW ON PAN/ZOOM: Uses a reliable polling loop (setInterval) to detect
//   any change to the map's extent and forces the layer to redraw. This works for
//   native Lizmap controls (zoom, pan) and custom buttons.
// - Includes the full-featured UI panel with Zoom and Minimize buttons.
// - Includes a persistent attempt to keep the layer on top.

(function() {

    // --- CONFIGURATION ---
    var WEBSOCKET_URL = 'ws://10.1.1.133:1880/device-live';
    var POLLING_INTERVAL = 1000; // Check for changes every 1 second for good responsiveness.

    // --- LIZMAP INIT ---
    lizMap.events.on({
        'uicreated': function() {
            setTimeout(initDeviceTracker, 1000);
        }
    });

    function initDeviceTracker() {
        if (!lizMap.map) {
            setTimeout(initDeviceTracker, 500);
            return;
        }

        // --- DYNAMIC STYLE CONTEXT (for per-feature labelOffset) ---
        var deviceStyleContext = {
            getLabelXOffset: function(feature) { return (feature.attributes.labelOffset && feature.attributes.labelOffset.length > 0) ? feature.attributes.labelOffset[0] : 15; },
            getLabelYOffset: function(feature) { return (feature.attributes.labelOffset && feature.attributes.labelOffset.length > 1) ? feature.attributes.labelOffset[1] : 15; }
        };

        // --- CREATE VECTOR LAYER (OpenLayers 2 API) ---
        var deviceLayer = new OpenLayers.Layer.Vector("Live Devices", {
            styleMap: new OpenLayers.StyleMap({
                "default": new OpenLayers.Style({ pointRadius: 8, fillColor: "${iconColor}", fillOpacity: 0.8, strokeColor: "#000000", strokeWidth: 2, label: "${label}", fontColor: "#222", fontSize: "12px", fontWeight: "bold", labelAlign: "lb", labelXOffset: "${getLabelXOffset}", labelYOffset: "${getLabelYOffset}" }, { context: deviceStyleContext }),
                "select": new OpenLayers.Style({ pointRadius: 10, fillColor: "${iconColor}", fillOpacity: 1.0, strokeColor: "#ff0000", strokeWidth: 3, label: "${label}", fontColor: "#c00", fontSize: "13px", fontWeight: "bold", labelAlign: "lb", labelXOffset: "${getLabelXOffset}", labelYOffset: "${getLabelYOffset}" }, { context: deviceStyleContext })
            })
        });

        lizMap.map.addLayer(deviceLayer);


        // --- NEW: RELIABLE POLLING FOR MAP CHANGES ---
        var previousExtent = null; // Stores the last known map view

        setInterval(function() {
            if (!lizMap.map || !deviceLayer.getVisibility()) {
                return; // Do nothing if map isn't ready or layer is hidden
            }

            var currentExtent = lizMap.map.getExtent();
            if (!currentExtent) {
                return;
            }

            // If the extent has changed since the last check, redraw the layer.
            if (!previousExtent || !currentExtent.equals(previousExtent)) {
                deviceLayer.redraw({ force: true });

                // While we're at it, also re-run our "on top" logic.
                try {
                    var layerDiv = deviceLayer.div || (deviceLayer.renderer && deviceLayer.renderer.root);
                    if (layerDiv && layerDiv.parentNode) {
                        layerDiv.parentNode.appendChild(layerDiv);
                    }
                } catch(e) { /* ignore */ }
            }

            // Store the current view for the next check.
            previousExtent = currentExtent;

        }, POLLING_INTERVAL);


        // --- DEVICE MANAGEMENT (No changes here) ---
        var deviceMarkers = new Map();
        function updateDevice(deviceData) { /* ... same as before ... */
            var name = deviceData.name, lat = deviceData.lat, lon = deviceData.lon, iconColor = deviceData.iconColor||'#fce405', labelOffset = deviceData.labelOffset||[15,15], popupHtml = deviceData.popup||'', timestamp = '', timestampMatch = popupHtml.match(/Timestamp: (.*?)(<br>|$)/);
            if(timestampMatch && timestampMatch[1]){ timestamp = timestampMatch[1].trim(); } if (!name || !lat || !lon) return;
            var formattedLabel = name; if (timestamp) { formattedLabel += '\n' + timestamp; }
            var popupContent = deviceData.popup || '<b>' + name + '</b>';
            var lonLat = new OpenLayers.LonLat(lon, lat);
            var point = lonLat.transform(new OpenLayers.Projection("EPSG:4326"), lizMap.map.getProjectionObject());
            if (deviceMarkers.has(name)) {
                var feature = deviceMarkers.get(name); feature.geometry.x = point.lon; feature.geometry.y = point.lat; feature.geometry.clearBounds();
                feature.attributes.label = formattedLabel; feature.attributes.popup = popupContent; feature.attributes.iconColor = iconColor; feature.attributes.labelOffset = labelOffset;
                deviceLayer.drawFeature(feature);
            } else {
                var pointGeometry = new OpenLayers.Geometry.Point(point.lon, point.lat);
                var feature = new OpenLayers.Feature.Vector(pointGeometry, { name: name, label: formattedLabel, popup: popupContent, iconColor: iconColor, labelOffset: labelOffset });
                deviceMarkers.set(name, feature); deviceLayer.addFeatures([feature]);
            }
            updateDeviceCounter();
        }

        // --- POPUP CONTROL (No changes here) ---
        var selectControl = new OpenLayers.Control.SelectFeature(deviceLayer, {
            onSelect: function(feature) { var popup = new OpenLayers.Popup.FramedCloud("devicePopup", feature.geometry.getBounds().getCenterLonLat(), null, feature.attributes.popup, null, true, function() { selectControl.unselect(feature); }); feature.popup = popup; lizMap.map.addPopup(popup); },
            onUnselect: function(feature) { if (feature.popup) { lizMap.map.removePopup(feature.popup); feature.popup.destroy(); feature.popup = null; } }
        });
        lizMap.map.addControl(selectControl);
        selectControl.activate();

        // --- WEBSOCKET HANDLING (No changes here) ---
        var ws, reconnectInterval=5000, reconnectTimer;
        function connectWebSocket() { try { ws = new WebSocket(WEBSOCKET_URL); ws.onopen = function() { showNotification('Connected to live device tracking', 'success'); clearTimeout(reconnectTimer); }; ws.onmessage = function(event) { try { var deviceData = JSON.parse(event.data); updateDevice(deviceData); } catch (error) { console.error('Error parsing WebSocket message:', error); } }; ws.onclose = function() { showNotification('Lost connection to device tracking', 'warning'); scheduleReconnect(); }; ws.onerror = function() { showNotification('Device tracking connection error', 'error'); }; } catch (error) { scheduleReconnect(); } }
        function scheduleReconnect() { clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connectWebSocket, reconnectInterval); }
        connectWebSocket();

        // --- FULL-FEATURED CONTROL PANEL ---
        function updateDeviceCounter() { var count = deviceMarkers.size; var counterElement = document.getElementById('device-counter'); if (counterElement) { counterElement.textContent = 'Live Devices: ' + count; } }
        function showNotification(message, type) { var notification = document.createElement('div'); notification.className = 'alert alert-' + type + ' device-tracker-notification'; notification.innerHTML = '<button type="button" class="close" data-dismiss="alert">×</button>' + message; var container = document.querySelector('#lizmap-content') || document.body; container.insertBefore(notification, container.firstChild); setTimeout(function() { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 5000); }
        function addControlPanel() {
            var controlPanel = document.createElement('div');
            controlPanel.id = 'device-tracker-panel';
            controlPanel.className = 'device-tracker-panel';
            controlPanel.innerHTML = `
                <div class="panel panel-default">
                    <div class="panel-heading">
                        <h4 class="panel-title">Live Device Tracking</h4>
                        <button id="minimize-panel-btn" class="btn btn-xs btn-default" title="Minimize">_</button>
                    </div>
                    <div id="device-panel-body" class="panel-body">
                        <div id="device-counter">Live Devices: 0</div>
                        <div class="btn-group" style="width:100%; margin-bottom: 5px;">
                           <button id="zoom-out-btn" class="btn btn-sm btn-default" style="width:50%">Zoom Out</button>
                           <button id="zoom-in-btn" class="btn btn-sm btn-default" style="width:50%">Zoom In</button>
                        </div>
                        <button id="center-devices-btn" class="btn btn-sm btn-info" style="width:100%; margin-bottom: 5px;">Center on Devices</button>
                        <button id="toggle-devices-btn" class="btn btn-sm btn-primary" style="width:100%;">Hide Layer</button>
                    </div>
                </div>
            `;
            document.body.appendChild(controlPanel);

            document.getElementById('minimize-panel-btn').addEventListener('click', function() {
                var body = document.getElementById('device-panel-body');
                if (body.style.display === 'none') { body.style.display = 'block'; this.innerHTML = '_'; this.title = 'Minimize'; }
                else { body.style.display = 'none'; this.innerHTML = '□'; this.title = 'Maximize'; }
            });

            document.getElementById('zoom-in-btn').addEventListener('click', function() { lizMap.map.zoomIn(); });
            document.getElementById('zoom-out-btn').addEventListener('click', function() { lizMap.map.zoomOut(); });
            document.getElementById('toggle-devices-btn').addEventListener('click', function() {
                deviceLayer.setVisibility(!deviceLayer.getVisibility());
                this.textContent = deviceLayer.getVisibility() ? 'Hide Layer' : 'Show Layer';
            });
            document.getElementById('center-devices-btn').addEventListener('click', function() {
                if (deviceMarkers.size > 0) {
                    var bounds = deviceLayer.getDataExtent();
                    if (bounds) { lizMap.map.zoomToExtent(bounds); }
                }
            });
        }
        addControlPanel();

        // --- CSS STYLES ---
        var styles = `
        .device-tracker-panel { position: fixed; right: 10px; top: 70px; z-index: 2001; background: white; border-radius: 5px; box-shadow: 0 2px 10px rgba(0,0,0,0.3); width: 250px; }
        .device-tracker-panel .panel-heading { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; }
        .device-tracker-panel .panel-body { padding: 10px; }
        .device-tracker-panel #minimize-panel-btn { padding: 1px 6px; }
        #device-counter { font-weight: bold; margin-bottom: 10px; color: #2c3e50; text-align: center; }
        `;
        var styleSheet = document.createElement('style'); styleSheet.textContent = styles; document.head.appendChild(styleSheet);
        window.addEventListener('beforeunload', function() { if (ws) ws.close(); });
    }

})();

It’s a big script, most of it generated using Chatgpt, Claude.ai and Github Copilot.

Kind regards and thanks in advance,
NMS

Sorry, i’m absolutely not JS affine - just my two cents:

Isn’t that something QField was built for? I’m using QField for mapping outside (and i can record the NMEA stream from any external GNSS sensor if i wish to) - QField and Lizmap both share the same PostGres Database, so as soon i sync from QField i have it in LWC as well.

Yeah, I already do that…but using Postgres database it takes too long to get the records and draw on the map (now it’s taking about 6-7 seconds)…the main function of recording it to the postgres is for later use of historical data.

Using this approach, the LWC does not have any work and the updates are faster…and for now, it’s just a proof of concept.

If it possible…yes, I just need a little tweak on the internal lizmap z-index.

Thanks for the feedback anyway.
NMS

1 Like

I was expecting that the maître @gustry could give a hand :slight_smile:

Maybe it’s a small misunderstanding of the OpenLayers or Lizmap architecture and he could easily point me in the right direction.

Kind regards,
NMS

Hi,

I’m not a JS developer !

I would not use OpenLayers 2, but OpenLayers 10.X version. Way more future proof, and a lot of features available. And the OpenLayers 10 map is above the OpenLayers 2 map (which is used only for edition I think). So need to tweak the Z-Index. OpenLayers 2 is on its way to death…

lizMap.mainLizmap.map.addToolLayer(OPENLAYER_VECTOR_LAYER);
let layers = lizMap.mainLizmap.map.getAllLayers();

Like this example to add a WMS layer on the fly within the Lizmap map : [Feature] Add OpenLayers to Lizmap's map and TreeView by rldhont · Pull Request #4389 · 3liz/lizmap-web-client · GitHub

Just a quick note, do not forget syntax highlight for JavaScript when you post code, I edited your comment above :wink:

I just tried the code below :

  1. Visit https://demo.lizmap.com/lizmap/index.php/view/map?repository=features&project=cats
  2. Stop the atlas feature, annoying in this case ahah
  3. Open the Javascript console
  4. Copy/paste
const extGroupMapState = lizMap.mainLizmap.state.rootMapGroup.createExternalGroup('Test')
const olLayer = new lizMap.ol.layer.Tile({
  source: new lizMap.ol.source.TileWMS({
    url: 'https://ahocevar.com/geoserver/gwc/service/wms',
    crossOrigin: '',
    params: {
      'LAYERS': 'ne:NE1_HR_LC_SR_W_DR',
      'TILED': true,
      'VERSION': '1.1.1',
    },
    projection: 'EPSG:4326',
    // Source tile grid (before reprojection)
    tileGrid: lizMap.ol.tilegrid.createXYZ({
      extent: [-180, -90, 180, 90],
      maxResolution: 360 / 512,
      maxZoom: 10,
    }),
    // Accept a reprojection error of 2 pixels
    reprojectionErrorThreshold: 2,
  }),
});

extGroupMapState.addOlLayer('wms4326', olLayer);

const olLayer2 = new lizMap.ol.layer.Image({
    extent: [-13884991, 2870341, -7455066, 6338219],
    source: new lizMap.ol.source.ImageWMS({
      url: 'https://ahocevar.com/geoserver/wms',
      params: {'LAYERS': 'topp:states'},
      ratio: 1,
      serverType: 'geoserver',
    }),
  });
extGroupMapState.addOlLayer('states', olLayer2);

It is possible to manipulate your WMS on the fly :

lizMap.mainLizmap.state.rootMapGroup.removeExternalGroup('Test');

Neither am I! But you pointed me on the right direction and I figured out the rest!

If you ever come to Portugal, let me know…a cold beer will be waiting for you, many many thanks!

Kind regards,
NMS

1 Like

Nice to know it works :beer:

Your “monitor dialog” on the right looks nice !
Any “generic” contribution you could think of ?
GitHub - 3liz/lizmap-javascript-scripts: Scripts for Lizmap Web Client accepts contributions

1 Like

My primary function on this current job is not coding or developing…just the thought of having to learn how to use Github gives me goosebumps… :sweat_smile:

Best I can do for now is share the code here:
list_layers.js (21.3 KB)

Hope it helps someone!

Disclaimer: Script entirely made using ChatGPT, Claude.AI , Github Copilot, Perplexity and Grok!

Like I said, I’m no developer!

Kind regards,
NMS

Now that the proof of concept is made, my next step is to make it work like other layers in LWC are presented to the client, they don’t have to be published on the internet for the client to use it…LWC acts like a reverse proxy right?

What’s the best approach to transform this deviceTracker.js into a common layer that isn’t published to the internet?

What I have done so far:

I created a Qgis Desktop plugin that mimics the deviceTracker.js developed early:

Opens popup to connect to WS service:

2025-07-04 08-13-28.mkv (1.0 MB)

And it works fine, if you want to use QGIS DESKTOP…but when published to LWC, the layer is there, but no information is displayed…

Guess that this temporary layer does not do the trick right?

Any hints?

Kind regards,
NMS