Display floor-filtered mixed map sources

This example combines four map data sources behind one shared floor picker: a WMTS tile layer, a collection-backed icon layer, a live alarm layer, and a static owner-backed point.

  • Configure a shared Floor selector parameter in the map widget and bind each layer with floorPropertyPath.

  • Store the floor field in a collection schema and create a form to place entries on specific floors.

  • Expose additionalData.geometry and additionalData.floor on an alarm view so the alarm layer can filter by floor.

  • Declare a static GeoJSON point with a properties.floor field so the floor selector can hide it on other levels.

  • Validate that each layer (WMTS, collections, alarms, static point) independently responds to the shared floor picker.

git checkout origin/osp-alarms-configuration .
git checkout origin/osp-web-configuration .
git checkout origin/osp-maps-configuration .
git checkout origin/osp-collections-configuration .
git checkout origin/osp-scripts-configuration .
git checkout origin/example-maps-floor-filter-mixed-sources .

Learning goals

  • How to drive one WMTS source, one COLLECTIONS layer, one ALARMS layer and one static owner.alarms point from the same floor selector.

  • How to bind OnSphere layers to the selected floor with mapsWidgetSettings.layerOptions[].floorPropertyPath.

  • How to keep the example config-only by reusing the shared offline PMTiles and icon resources.

1. Reuse the offline tiles and icon assets

The example depends on the PMTiles archive and PNG icons exposed by example-maps-basic-offline-maps.

modules/web/web-1/module.resources (from example-maps-basic-offline-maps)
{
    "resources": [
        {
            "source": "root/feature/protomaps/assets/CH-fr-Fribourg.pmtiles",
            "destination": "osp/resources/tiles/fr.pmtiles"
        },
        {
            "source": "root/feature/protomaps/assets/vd.pmtiles",
            "destination": "osp/resources/tiles/vd.pmtiles"
        },
        {
            "source": "root/feature/protomaps/assets/sdn_logo.png",
            "destination": "osp/resources/images/sdn_logo.png"
        },
        {
            "source": "root/feature/protomaps/assets/fire.png",
            "destination": "osp/resources/icons/fire.png"
        },
        {
            "source": "root/feature/protomaps/assets/maintenance.png",
            "destination": "osp/resources/icons/maintenance.png"
        }
    ]
}

2. Configure the dashboard and shared floor selector

The map widget reuses the WMTS background setup, declares one style: "Floor" shared parameter and binds the three OnSphere layers with floorPropertyPath.

root/dashboard.view
{
    "configuration": [
        {
            "id": "floor-filtered-map",
            "type": "Maps",
            "title": "Floor-filtered mixed map sources",
            "menuReferences": [
                "root.maps.menu"
            ],
            "mapsWidgetSettings": {
                "protomapSource": "/osp/resources/tiles/fr.pmtiles",
                "library": "MapLibre",
                "centerPosition": [
                    7.139734,
                    46.806418
                ],
                "zoomLevel": 14,
                "icons": [
                    {
                        "path": "osp/resources/icons/fire.png",
                        "name": "fire"
                    },
                    {
                        "path": "osp/resources/icons/maintenance.png",
                        "name": "maintenance"
                    }
                ],
                "iconPriority": [
                    "fire",
                    "maintenance"
                ],
                "sharedParameters": [
                    {
                        "id": "floor",
                        "type": "Select",
                        "style": "Floor",
                        "label": "Floor",
                        "defaultValue": "RS",
                        "options": [
                            {
                                "value": "E3"
                            },
                            {
                                "value": "E2"
                            },
                            {
                                "value": "E1"
                            },
                            {
                                "value": "RS"
                            },
                            {
                                "value": "RI"
                            },
                            {
                                "value": "S1"
                            },
                            {
                                "value": "S2"
                            },
                            {
                                "value": "S3"
                            }
                        ]
                    }
                ],
                "wmtsSources": [
                    {
                        "sourceName": "myWMTSLayer",
                        "urlTemplate": "http://your-hostname:your-port/patho/to/wmts/${floor}/${tileMatrixSet}/{z}/{x}/{y}.png",
                        "format": "image/png",
                        "tileSize": 256,
                        "urlRequestParameters": [
                            {
                                "id": "tileMatrixSet",
                                "type": "Static",
                                "defaultValue": "webmercator"
                            },
                            {
                                "id": "floor",
                                "type": "Shared"
                            }
                        ]
                    }
                ],
                "layerOptions": [
                    {
                        "layerId": "root.layers.live_alarms",
                        "activatedByDefault": true,
                        "floorPropertyPath": "additionalData.floor"
                    },
                    {
                        "layerId": "root.layers.collection_icons",
                        "activatedByDefault": true,
                        "floorPropertyPath": "floor"
                    },
                    {
                        "layerId": "root.layers.static_alarm_point",
                        "activatedByDefault": true,
                        "floorPropertyPath": "floor"
                    }
                ]
            }
        },
        {
            "id": "floor-filtered-collection-table",
            "type": "CollectionTable",
            "title": "Floor-tagged collection items",
            "menuReferences": [
                "root.collections.dynamic_map_items.delete.menu"
            ],
            "collectionTableWidgetSettings": {
                "defaultSchemas": [
                    "root.collections.dynamic_map_items"
                ],
                "defaultFilter": "visible_only",
                "pageSize": 50,
                "pageSizes": [
                    50,
                    100,
                    200
                ],
                "resizeMode": "widget",
                "disableToolbarTableRefresh": false,
                "disableSchemaUpdate": false,
                "disableViewUpdate": false,
                "disableFilterUpdate": false,
                "disableToolbar": false,
                "disableToolbarMenu": false,
                "disableSidePanel": false,
                "disableToolbarExport": false,
                "disableToolbarColumnShowHide": false,
                "disableToolbarFilterShowHide": false,
                "disableToolbarSummaryShowHide": false,
                "disableToolbarSearch": false,
                "disableToolbarColumnChooser": false,
                "disableToolbarClearFilter": false
            }
        }
    ],
    "layout": {
        "lg": [
            {
                "w": 12,
                "h": 4,
                "x": 0,
                "y": 0,
                "i": "floor-filtered-map"
            },
            {
                "w": 12,
                "h": 5,
                "x": 0,
                "y": 4,
                "i": "floor-filtered-collection-table"
            }
        ]
    },
    "breakpoints": {
        "lg": 1200,
        "md": 996,
        "sm": 768,
        "xs": 480,
        "xxs": 0
    },
    "cols": {
        "lg": 12,
        "md": 10,
        "sm": 6,
        "xs": 4,
        "xxs": 2
    },
    "rowHeight": 130
}

3. Add the floor-tagged collection-backed layer

The collection schema stores the business fields, the selected floor and the GeoJSON point at map.geometry.

root/collections/dynamic_map_items/schema.ospp
{
    "schema": {
        "type": "object",
        "properties": {
            "name": {
                "type": "string"
            },
            "category": {
                "type": "string",
                "enum": [
                    "fire",
                    "maintenance"
                ]
            },
            "floor": {
                "type": "string",
                "enum": [
                    "E3",
                    "E2",
                    "E1",
                    "RS",
                    "RI",
                    "S1",
                    "S2",
                    "S3"
                ]
            },
            "visibleOnMap": {
                "type": "boolean"
            },
            "map": {
                "type": "object",
                "properties": {
                    "geometry": {
                        "type": "object",
                        "properties": {
                            "type": {
                                "type": "string",
                                "enum": [
                                    "Point"
                                ]
                            },
                            "coordinates": {
                                "type": "array",
                                "minItems": 2,
                                "maxItems": 2,
                                "items": {
                                    "type": "number"
                                }
                            }
                        },
                        "required": [
                            "type",
                            "coordinates"
                        ]
                    }
                },
                "required": [
                    "geometry"
                ]
            }
        },
        "required": [
            "name",
            "category",
            "floor",
            "visibleOnMap",
            "map"
        ]
    },
    "filters": [
        {
            "id": "visible_only",
            "name": "Visible points only"
        }
    ]
}

The collection module keeps the visible_only filter and adds a simple floor index.

root/collections/dynamic_map_items/schema.collections
{
    "moduleId": "modules.collections.collections-1",
    "collectionName": "dynamic_map_items",
    "indexes": [
        {
            "name": "visibleOnMap",
            "index": "{'visibleOnMap': 1}"
        },
        {
            "name": "category",
            "index": "{'category': 1}"
        },
        {
            "name": "floor",
            "index": "{'floor': 1}"
        }
    ],
    "filters": [
        {
            "id": "visible_only",
            "query": "{visibleOnMap: true}"
        }
    ]
}

The icon layer reuses the same fire and maintenance icon rules as the collection example.

root/layers/collection_icons/layer.ospp
{
    "name": "Dynamic collection icons",
    "description": "Collection-backed icons filtered by the shared floor selector",
    "layerType": "ICON",
    "valueType": "POINT",
    "icons": [
        "fire",
        "maintenance"
    ],
    "mapSources": [
        {
            "type": "COLLECTIONS",
            "collectionSource": "root.collections.dynamic_map_items",
            "geometry": "map.geometry",
            "filter": [
                "visible_only"
            ],
            "displayRules": [
                {
                    "iconName": "fire",
                    "visibleExpression": "category == 'fire'"
                },
                {
                    "iconName": "maintenance",
                    "visibleExpression": "category == 'maintenance'"
                }
            ]
        }
    ]
}

The collection table below the map uses the standard form flow so operators can create or edit entries on specific floors.

root/collections/dynamic_map_items/form/form.web
{
    "moduleId": [
        "modules.web.web-1"
    ],
    "name": "Dynamic map item",
    "description": "Create or update a floor-tagged map-backed collection entry",
    "bindings": {},
    "rights": [],
    "schema": "root.collections.dynamic_map_items",
    "ui": {
        "type": "VerticalLayout",
        "elements": [
            {
                "type": "HorizontalLayout",
                "elements": [
                    {
                        "type": "Control",
                        "scope": "#/properties/name",
                        "label": "Name"
                    },
                    {
                        "type": "Control",
                        "scope": "#/properties/category",
                        "label": "Category"
                    },
                    {
                        "type": "Control",
                        "scope": "#/properties/floor",
                        "label": "Floor"
                    },
                    {
                        "type": "Control",
                        "scope": "#/properties/visibleOnMap",
                        "label": "Visible on map"
                    }
                ]
            },
            {
                "type": "HorizontalLayout",
                "elements": [
                    {
                        "type": "Control",
                        "scope": "#/properties/map/properties/geometry/properties/type",
                        "label": "Geometry type"
                    },
                    {
                        "type": "Control",
                        "scope": "#/properties/map/properties/geometry/properties/coordinates",
                        "label": "Coordinates [lng, lat]"
                    }
                ]
            }
        ]
    },
    "initialValue": {
        "floor": "RS",
        "visibleOnMap": true,
        "map": {
            "geometry": {
                "type": "Point",
                "coordinates": [
                    7.139734,
                    46.806418
                ]
            }
        }
    },
    "submit": {
        "destination": "Request",
        "type": "Deferred"
    }
}

4. Add the floor-tagged alarm-backed layer

The alarm view exposes additionalData.geometry and additionalData.floor so the map stream can both place and filter the live alarm features.

root/alarms/views/live_map/view.ospp
{
    "name": "Live map alarms",
    "description": "Alarm view for the floor-filtered mixed sources example",
    "liveColumns": [
        {
            "name": "Serial",
            "field": "serial"
        },
        {
            "name": "Summary",
            "field": "summary"
        },
        {
            "name": "Source",
            "field": "source"
        },
        {
            "name": "Location",
            "field": "location"
        },
        {
            "name": "Severity",
            "field": "severity"
        },
        {
            "name": "Highest severity",
            "field": "highestSeverity"
        },
        {
            "name": "Count",
            "field": "count"
        },
        {
            "name": "Floor",
            "field": "additionalData.floor"
        },
        {
            "name": "Geometry",
            "field": "additionalData.geometry"
        },
        {
            "name": "Tags",
            "field": "tags"
        },
        {
            "name": "Last occurrence",
            "field": "lastTimestamp"
        }
    ],
    "historyColumns": [
        {
            "name": "Serial",
            "field": "serial"
        },
        {
            "name": "Summary",
            "field": "summary"
        },
        {
            "name": "Source",
            "field": "source"
        },
        {
            "name": "Location",
            "field": "location"
        },
        {
            "name": "Severity",
            "field": "severity"
        },
        {
            "name": "Count",
            "field": "count"
        },
        {
            "name": "Floor",
            "field": "additionalData.floor"
        },
        {
            "name": "Geometry",
            "field": "additionalData.geometry"
        },
        {
            "name": "Operation time",
            "field": "operationTime"
        }
    ]
}

The alarm-backed layer keeps one filter id only and streams severity points from the alarms module.

root/layers/live_alarms/layer.ospp
{
    "name": "Live alarm severity",
    "description": "Live alarms streamed from the alarms module and filtered by the shared floor selector",
    "layerType": "SEVERITY",
    "valueType": "POINT",
    "clusteringType": "NONE",
    "mapSources": [
        {
            "type": "ALARMS",
            "geometry": "additionalData.geometry",
            "view": "root.alarms.views.live_map",
            "filter": [
                "root.alarms.filters.all"
            ]
        }
    ]
}

The map menu opens a form and then executes a detached script. The form asks explicitly for the floor because the map menu context does not expose the currently selected floor picker value.

root/maps/menu/menu.web
{
    "moduleId": [
        "modules.web.web-1"
    ],
    "name": "Floor-filtered mixed sources map menu",
    "description": "Create an alarm from the mixed sources map context menu",
    "menus": [
        {
            "label": "Create alarm here",
            "icon": "add_alert",
            "context": [
                {
                    "type": [
                        "Maps"
                    ],
                    "scopes": [
                        "Click"
                    ],
                    "action": "root.maps.actions.run_script",
                    "input": {
                        "scriptId": {
                            "expression": "'root.script.create_alarm'"
                        },
                        "form": {
                            "prompt": {
                                "type": "FORM",
                                "formId": "root.maps.create_alarm.form",
                                "formSave": false,
                                "labels": {
                                    "title": "Create alarm",
                                    "message": "Confirm and edit the alarm fields",
                                    "save": "Create",
                                    "cancel": "Cancel"
                                },
                                "data": {
                                    "summary": {
                                        "expression": "'Alarm from floor-filtered map click'"
                                    },
                                    "source": {
                                        "expression": "'maps.floor-filter-mixed-sources'"
                                    },
                                    "longitude": {
                                        "expression": "map.point.lngLat.lng"
                                    },
                                    "latitude": {
                                        "expression": "map.point.lngLat.lat"
                                    },
                                    "location": {
                                        "expression": "'lng=' + map.point.lngLat.lng.toFixed(6) + ', lat=' + map.point.lngLat.lat.toFixed(6)"
                                    },
                                    "severity": {
                                        "expression": "300"
                                    },
                                    "floor": {
                                        "expression": "'RS'"
                                    },
                                    "tags": {
                                        "expression": "'maps,floor,manual'"
                                    }
                                }
                            }
                        }
                    },
                    "output": [
                        {
                            "operation": "snackbar",
                            "input": {
                                "message": {
                                    "expression": "${result.success} ? 'Alarm created from floor-filtered map context menu' : 'Alarm creation failed from floor-filtered map context menu: ' + (${result.message} || ${result.error} || 'unknown error')"
                                },
                                "variant": {
                                    "expression": "${result.success} ? 'success' : 'error'"
                                }
                            }
                        }
                    ]
                }
            ]
        }
    ]
}
root/maps/create_alarm/form/schema.ospp
{
    "schema": {
        "type": "object",
        "properties": {
            "summary": {
                "type": "string"
            },
            "source": {
                "type": "string"
            },
            "longitude": {
                "type": "number"
            },
            "latitude": {
                "type": "number"
            },
            "location": {
                "type": "string"
            },
            "severity": {
                "type": "number"
            },
            "floor": {
                "type": "string",
                "enum": [
                    "E3",
                    "E2",
                    "E1",
                    "RS",
                    "RI",
                    "S1",
                    "S2",
                    "S3"
                ]
            },
            "tags": {
                "type": "string"
            },
            "serial": {
                "type": "string"
            }
        },
        "required": [
            "summary",
            "source",
            "longitude",
            "latitude",
            "location",
            "severity",
            "floor"
        ]
    }
}
root/script/create_alarm/create_alarm.js
const DEFAULT_SEVERITY = 300;
const DEFAULT_FLOOR = "RS";
const ALLOWED_FLOORS = [
    "E3",
    "E2",
    "E1",
    "RS",
    "RI",
    "S1",
    "S2",
    "S3"
];

try {
    main();
} catch (error) {
    const message = !!error && !!error.message ? String(error.message) : String(error);
    log.error("Floor-filtered mixed sources map script failed with unexpected error [{}].", message);
    execution.reportFailure(message);
}

function main() {
    const parameters = Array.isArray(trigger.parameters) && trigger.parameters.length > 0
        ? trigger.parameters[0]
        : {};
    const form = typeof parameters.form === "object" && parameters.form !== null ? parameters.form : {};
    const longitude = parseCoordinate(form.longitude, parameters.longitude);
    const latitude = parseCoordinate(form.latitude, parameters.latitude);

    if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
        log.error("Missing map coordinates in script parameters [{}].", JSON.stringify(parameters));
        execution.reportFailure("Missing map coordinates in script parameters.");
        return;
    }

    const severity = parseSeverity(form.severity);
    const floor = parseFloor(form.floor);
    const summary = toText(form.summary, "Alarm from floor-filtered map click");
    const source = toText(form.source, "maps.floor-filter-mixed-sources");
    const location = toText(form.location, `lng=${longitude.toFixed(6)}, lat=${latitude.toFixed(6)}`);
    const serial = buildSerial(form.serial);
    const tags = parseTags(form.tags);

    log.info(
        "Create floor-filtered mixed sources alarm with serial [{}], severity [{}], floor [{}], location [{}].",
        serial,
        severity,
        floor,
        location
    );

    const alarm = alarms.newAlarmBuilder(serial, severity)
        .setSummary(summary)
        .setSource(source)
        .setLocation(location)
        .addTags(tags)
        .addAdditionalData("geometry", {
            type: "Point",
            coordinates: [longitude, latitude]
        })
        .addAdditionalData("floor", floor)
        .build();

    const createResult = alarms.create(alarm);
    if (createResult && createResult.success === true) {
        log.info("Alarm creation succeeded from floor-filtered mixed sources script [{}] with create().", serial);
        execution.reportResult("Alarm created", {
            serial: serial,
            longitude: longitude,
            latitude: latitude,
            floor: floor
        });
        return;
    }

    log.warn(
        "Alarm create() failed for floor-filtered mixed sources script [{}], trying insert(). create result [{}].",
        serial,
        JSON.stringify(createResult)
    );

    const insertResult = alarms.insert(alarm);
    if (!insertResult || insertResult.success !== true) {
        const createMessage = !!createResult && !!createResult.message ? createResult.message : JSON.stringify(createResult);
        const insertMessage = !!insertResult && !!insertResult.message ? insertResult.message : JSON.stringify(insertResult);
        const failureMessage = `create failed [${createMessage}] / insert failed [${insertMessage}]`;
        log.error("Alarm creation failed from floor-filtered mixed sources script [{}] cause [{}].", serial, failureMessage);
        execution.reportFailure(failureMessage);
        return;
    }

    log.info("Alarm creation succeeded from floor-filtered mixed sources script [{}] with insert().", serial);
    execution.reportResult("Alarm created", {
        serial: serial,
        longitude: longitude,
        latitude: latitude,
        floor: floor
    });
}

function toText(value, fallback) {
    if (value === undefined || value === null) {
        return fallback;
    }

    const normalized = String(value).trim();
    return normalized.length > 0 ? normalized : fallback;
}

function parseSeverity(rawValue) {
    const parsed = Number(rawValue);
    return Number.isFinite(parsed) ? parsed : DEFAULT_SEVERITY;
}

function parseCoordinate(rawValue, fallbackValue) {
    const parsed = Number(rawValue);
    if (Number.isFinite(parsed)) {
        return parsed;
    }

    const fallback = Number(fallbackValue);
    return Number.isFinite(fallback) ? fallback : NaN;
}

function parseFloor(rawValue) {
    const floor = toText(rawValue, DEFAULT_FLOOR);
    return ALLOWED_FLOORS.includes(floor) ? floor : DEFAULT_FLOOR;
}

function buildSerial(serialValue) {
    const provided = toText(serialValue, "");
    if (provided.length > 0) {
        return provided;
    }

    return `floor-filter-mixed-source-alarm-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
}

function parseTags(rawTags) {
    if (Array.isArray(rawTags)) {
        return rawTags
            .map((tag) => String(tag).trim())
            .filter((tag) => tag.length > 0);
    }

    if (typeof rawTags === "string") {
        return rawTags
            .split(",")
            .map((tag) => tag.trim())
            .filter((tag) => tag.length > 0);
    }

    return [];
}

5. Add the static owner-backed point

The static point lives in geo.maps and declares properties.floor: "RS" so the floor selector can hide it on all other levels.

root/site/reception/geo.maps
{
    "geoJsonString": {
        "type": "Feature",
        "geometry": {
            "type": "Point",
            "coordinates": [
                7.1392,
                46.8061
            ]
        },
        "properties": {
            "name": "Reception alarm aggregate",
            "floor": "RS"
        }
    },
    "moduleId": "modules.maps.maps-1"
}

Its count and highest severity are owned by osp-alarms, and both owners point to the shared root.alarms.filters.all filter.

root/site/reception/alarms/count/owner.alarms
{
    "filterId": "root.alarms.filters.all",
    "type": "ALARM_COUNT"
}
root/site/reception/alarms/highest_severity/owner.alarms
{
    "filterId": "root.alarms.filters.all",
    "type": "MAX_SEVERITY"
}

The corresponding static severity layer references only that single point.

root/layers/static_alarm_point/layer.ospp
{
    "name": "Static alarm aggregate point",
    "description": "One static geo.maps point whose count and severity are owned by the alarms module",
    "layerType": "SEVERITY",
    "valueType": "POINT",
    "mapSources": [
        {
            "type": "STATIC",
            "severityConfigurations": [
                {
                    "severity": "root.site.reception.alarms.highest_severity",
                    "count": "root.site.reception.alarms.count"
                }
            ]
        }
    ]
}

6. Validate the behavior

  1. Open the Floor-filtered mixed sources dashboard and switch the floor selector to confirm that the WMTS request changes ${floor}.

  2. Create a collection entry on RS and confirm it appears only on RS.

  3. Create another collection entry on E1 and confirm it disappears when you switch back to RS.

  4. Right-click the map, choose Create alarm here and submit one alarm with floor RS.

  5. Create another alarm with floor E2. Change the floor in the form before submitting it.

  6. Confirm that the live alarm layer only shows the alarms matching the active floor.

  7. Confirm that the static reception point is only visible on RS.

  8. Confirm that the reception point count and severity still update when alarms are created on other floors, because its owners aggregate root.alarms.filters.all while its visibility is controlled separately by properties.floor.