﻿let maps = {};
let routePlanningLayer = {};
let itemsLayers = {};
let linearItemsLayers = {};
let editShapeLayers = {};
let editShapeInteractions = {};
let editAssetShapesStore = {};
let itemLayerSources = {};
let mapControls = {};
let heatmapLayers = {};
let userPositionLayers = {};
let mapSources;
let iconStyleCache = {};
let dotnetObjectRef = {};
let popovers = {};
let loadedShapesItemsId = {};
// Each map's items' linear features as one extent per item - see getLinearItemExtents.
let linearItemExtents = {};

function getLoadedItemKey(itemId) {
    return itemId === undefined || itemId === null ? null : String(itemId);
}

let fillStyleCache = {};
let strokeStyleCache = {};
let locationParamTimeouts = {};
let isMoving = {};
let toggleOnOffOpens = false;
let editShapeGeometry = "LineString";
let editShapeMode = "Draw";
let userCurrentLcoation = false;
let isNaviagtedForDataView = false;
const maxNumberOfMapFeatures = 20000; //when you change this value make sure you change the same variable in LocationDataService too

// Tiles each tile layer's renderer keeps. OpenLayers 10 caches per layer (default 512) and ignores the
// cacheSize set on a source, which is where this file has always set it - so the 256 those sources
// ask for was never applied. A map can have several tile layers (basemap, low-zoom fallback, WMS/WMTS
// overlays), each holding decoded images. Vector tile layers keep OpenLayers' own default instead
// (see the vector basemap).
const MAP_TILE_CACHE_SIZE = 256;

// The zoom "show local assets" starts fetching the assets in view at. Below it the map asks for
// nothing, because the query is bounded by the extent and a wide extent is the whole estate.
//
// The banner that says so, and the button that takes the user to the zoom, are driven by
// BaseMapComponent.LocalAssetsZoomLevel - keep the two the same, or the banner goes away at a
// zoom where nothing has started loading.
//
// 16 rather than 18: at 18 a user looking at a town saw an empty map and no reason to believe
// there was anything in it, which is how this was reported. 16 covers a town rather than a
// street, at four times the extent - so the cap above is what now decides how much comes back
// on a busy area. The mobile map keeps its own floor at 18, where the data costs more.
const LOCAL_ASSETS_MIN_ZOOM = 16;
// The zoom the vector basemap takes over from the raster fallback at.
//
// It is where the map gets heavy, and for a reason that is nobody's mistake: a styled vector
// basemap draws every road, building, label and symbol in view, and OpenLayers wraps each of
// those in a save/drawImage/restore. Wide zooms are the worst case - the tiles cover a huge
// area and still carry their labels - and it eases as you zoom further in and each tile holds
// less ground. Raising this keeps the cheap raster for longer at the cost of the styled look
// at middle zooms.
//
// A let, and settable from the console with amxPbfMinZoom(13), so the trade can be tried on a
// real map before it is decided. Layers are built when a map is created, so reload the page
// after changing it.
let pbfMinZoom = 10;

// The zoom the vector basemap starts drawing its labels, shields and POI icons at.
//
// This is the one that costs. OpenLayers caches a vector tile's polygons and lines as an image
// and reuses it (and the style's plain circles - see drawVectorTilesForLessEachFrame), but every
// label, shield and icon is drawn again on each frame - each one its own
// save/drawImage/restore - so on a national basemap at wide zooms the labels are most of
// what a repaint does, and they are unreadable clutter at that scale anyway. Holding them back
// leaves the roads, water and land exactly as they were.
//
// Settable from the console with amxPbfLabelZoom(11) to find the right trade for a given style.
let pbfLabelMinZoom = 13;

function amxPbfLabelZoom(zoom) {
    if (typeof zoom === 'number') {
        pbfLabelMinZoom = zoom;

        console.log(`Vector basemap labels will draw from zoom ${pbfLabelMinZoom} - reload the page to rebuild the map.`);
    }

    return pbfLabelMinZoom;
}

window.amxPbfLabelZoom = amxPbfLabelZoom;

// Raises the minzoom of a style's symbol layers so its labels, shields and icons hold off until
// pbfLabelMinZoom. Nothing is removed - a Mapbox style layer's own minzoom is the style's way of
// saying "not yet", so the labels come back on their own further in.
function holdBackBasemapLabels(style) {
    let held = 0;

    for (const layer of style?.layers ?? []) {
        if (layer.type !== 'symbol') {
            continue;
        }

        if ((layer.minzoom ?? 0) < pbfLabelMinZoom) {
            layer.minzoom = pbfLabelMinZoom;
            held++;
        }
    }

    // Said out loud, because this is the difference between a basemap that costs its labels on
    // every frame and one that does not - and a style that could not be read falls back to being
    // applied as it stands, which looks identical and performs nothing like it.
    console.log(`Basemap: ${held} label layers held back to zoom ${pbfLabelMinZoom}.`);

    return style;
}

// Where ol-mapbox-style fetches a font a basemap style names in text-font and the browser does
// not have. Its own default is jsDelivr's @fontsource packages, which the CSP does not allow;
// Google Fonts is, and serves the fonts basemap styles name (Asap, Open Sans, Noto Sans...).
const basemapWebfonts = 'https://fonts.googleapis.com/css?family={Font+Family}:{fontweight}{fontstyle}';

// Applies the basemap's own style, with its symbol layers held back to pbfLabelMinZoom.
//
// The style is fetched and edited rather than filtered through ol-mapbox-style's layer argument
// so that nothing is removed: a Mapbox style layer carries its own minzoom, so raising it is the
// style's own way of saying "not yet" and the labels come back on their own further in.
//
// Falls back to applying the style as it stands if it cannot be read - a basemap with expensive
// labels beats no basemap.
async function applyBasemapStyle(baseLayer, styleUrl, resolutions) {
    try {
        const response = await fetch(styleUrl);

        if (!response.ok) {
            throw new Error(`Style request returned ${response.status}`);
        }

        const styleJson = await response.json();

        // ArcGIS answers a rejected or expired token with HTTP 200 and an error document, so the
        // only sign of it downstream is ol-mapbox-style saying "glStyle version 8 required" about
        // something that was never a style. Said here instead, where the body can be read.
        if (styleJson?.version !== 8) {
            throw new Error(styleJson?.error?.message
                ? 'The style service returned an error instead of a style: ' + styleJson.error.message
                : 'Not a Mapbox GL style (version ' + styleJson?.version + '), so it cannot be applied');
        }

        const style = holdBackBasemapLabels(styleJson);

        // styleUrl is passed on so the sprite and glyph URLs in the style still resolve against
        // where it came from, now that it is being handed over as an object.
        return await olms.applyStyle(baseLayer, style, '', {
            resolutions: resolutions,
            styleUrl: styleUrl,
            webfonts: basemapWebfonts
        });
    } catch (error) {
        console.warn('Could not hold back the basemap labels; applying the style as it stands.', styleUrl, error);

        return await olms.applyStyle(baseLayer, styleUrl, '', {resolutions: resolutions, webfonts: basemapWebfonts});
    }
}

// Finds the layer that is keeping the map repainting, by taking each one away for a moment and
// watching what happens to the rate.
//
// A map that repaints when nobody is touching it is a layer asking for another frame - a tile
// still fading, an icon whose image never loaded, a source that keeps saying it changed. Which
// layer is not something OpenLayers reports, so this asks the question the only way it can be
// asked: hide one, count the frames, put it back.
//
// Run amxFindRenderLoop() from the console and leave the map alone while it works.
async function amxFindRenderLoop(elementId) {
    const map = maps[elementId] ?? Object.values(maps)[0];

    if (!map) {
        console.log('No map to look at.');
        return;
    }

    const countFrames = (ms) => new Promise((resolve) => {
        let frames = 0;

        const onRender = () => frames++;

        map.on('postrender', onRender);

        setTimeout(() => {
            map.un('postrender', onRender);
            resolve(frames / (ms / 1000));
        }, ms);
    });

    const sampleMs = 800;
    const baseline = await countFrames(sampleMs);

    console.log(`Repainting at ${baseline.toFixed(1)}/s with everything on.`);

    if (baseline < 2) {
        console.log('That is already quiet - nothing is looping.');
        return;
    }

    const layers = [];

    for (const layer of map.getLayers().getArray()) {
        if (layer instanceof ol.layer.Group) {
            for (const child of layer.getLayers().getArray()) {
                layers.push(child);
            }

            continue;
        }

        layers.push(layer);
    }

    const results = [];

    for (const layer of layers) {
        if (layer.getVisible() !== true) {
            continue;
        }

        layer.setVisible(false);

        const withoutIt = await countFrames(sampleMs);

        layer.setVisible(true);

        results.push({
            layer: layer.get('title') || layer.constructor.name || '(untitled)',
            rate: Number(withoutIt.toFixed(1)),
            drop: Number((baseline - withoutIt).toFixed(1))
        });
    }

    results.sort((a, b) => b.drop - a.drop);

    console.log('Hiding each layer in turn - the one with the biggest drop is the one asking:');
    console.table(results);

    return results;
}

window.amxFindRenderLoop = amxFindRenderLoop;

function amxPbfMinZoom(zoom) {
    if (typeof zoom === 'number') {
        pbfMinZoom = zoom;

        console.log(`Vector basemap will take over at zoom ${pbfMinZoom} - reload the page to rebuild the map.`);
    }

    return pbfMinZoom;
}

window.amxPbfMinZoom = amxPbfMinZoom;
let esrijsonFormat = new ol.format.EsriJSON();
let isActivePolygonInteraction = false;
let drawInteraction = null;
let multiSelectInteraction;
let interactionLayer = null;
let selectedIds = null;
let what3WordslatAndLong = [];
let shownIconAtCentre = false;
let showWhat3WordsMarker = false;
let undoBuffer = {};
let disposedMaps = {};
let pendingEditShapeUpdateCounts = {};
let pendingEditShapeUpdateResolvers = {};
let selectedFeatureIdsPerMap = {};
// The area select, per map: the records its areas take in, drawn as selected while it runs, and
// the session itself - the areas finished so far, whether one is being drawn, how many records
// they take in, and its keydown listener. A map with no session running has no entry in either.
let polygonSelectionPreviewIds = {};
let polygonSelectionStates = {};
// The distance measuring sessions, one per map - see measureControl.js and the block of
// measure* functions below. A map with no session running has no entry.
let measureSessions = {};

let mapDefaults = {
    appWkId: undefined,
    mapSources: undefined,
    editShapeGeometry: "LineString",
    editShapeMode: "Draw"
};

proj4.defs("EPSG:27700", "+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.999601272 +x_0=400000 +y_0=-100000 +ellps=airy +datum=OSGB36 +units=m +no_defs");
proj4.defs("osgb:BNG", "+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.999601272 +x_0=400000 +y_0=-100000 +ellps=airy +datum=OSGB36 +units=m +no_defs");
proj4.defs("EPSG:3997", "+proj=tmerc +lat_0=0 +lon_0=55.33333333333334 +k=1 +x_0=500000 +y_0=0 +datum=WGS84 +units=m +no_defs");
proj4.defs("EPSG:26910", "+proj=utm +zone=10 +datum=NAD83 +units=m +no_defs");
proj4.defs("EPSG:2932", "+proj=tmerc +lat_0=24.45 +lon_0=51.21666666666667 +k=0.99999 +x_0=200000 +y_0=300000 +ellps=intl +towgs84=-119.425,-303.659,-11.0006,1.1643,0.174458,1.09626,3.65706 +units=m +no_defs");
proj4.defs("EPSG:102747", "PROJCS[\"NAD_1983_StatePlane_Virginia_South_FIPS_4502_Feet\",GEOGCS[\"GCS_North_American_1983\",DATUM[\"D_North_American_1983\",SPHEROID[\"GRS_1980\",6378137.0,298.257222101]],PRIMEM[\"Greenwich\",0.0],UNIT[\"Degree\",0.0174532925199433]],PROJECTION[\"Lambert_Conformal_Conic\"],PARAMETER[\"False_Easting\",11482916.66666666],PARAMETER[\"False_Northing\",3280833.333333333],PARAMETER[\"Central_Meridian\",-78.5],PARAMETER[\"Standard_Parallel_1\",36.76666666666667],PARAMETER[\"Standard_Parallel_2\",37.96666666666667],PARAMETER[\"Latitude_Of_Origin\",36.33333333333334],UNIT[\"Foot_US\",0.3048006096012192]]");
proj4.defs("EPSG:29900", "+proj=tmerc +lat_0=53.5 +lon_0=-8 +k=1.000035 +x_0=200000 +y_0=250000 +a=6377340.189 +rf=299.3249646 +towgs84=482.5,-130.6,564.6,-1.042,-0.214,-0.631,8.15 +units=m +no_defs +type=crs");
proj4.defs("EPSG:29902", "+proj=tmerc +lat_0=53.5 +lon_0=-8 +k=1.000035 +x_0=200000 +y_0=250000 +a=6377340.189 +rf=299.3249646 +towgs84=482.5,-130.6,564.6,-1.042,-0.214,-0.631,8.15 +units=m +no_defs +type=crs");
proj4.defs("EPSG:29903", "+proj=tmerc +lat_0=53.5 +lon_0=-8 +k=1.000035 +x_0=200000 +y_0=250000 +a=6377340.189 +rf=299.3249646 +towgs84=482.5,-130.6,564.6,-1.042,-0.214,-0.631,8.15 +units=m +no_defs +type=crs");
proj4.defs("EPSG:3763", "PROJCS[\"ETRS_1989_Portugal_TM06\",GEOGCS[\"GCS_ETRS_1989\",DATUM[\"D_ETRS_1989\",SPHEROID[\"GRS_1980\",6378137.0,298.257222101]],PRIMEM[\"Greenwich\",0.0],UNIT[\"Degree\",0.0174532925199433]],PROJECTION[\"Transverse_Mercator\"],PARAMETER[\"False_Easting\",0.0],PARAMETER[\"False_Northing\",0.0],PARAMETER[\"Central_Meridian\",-8.133108333333334],PARAMETER[\"Scale_Factor\",1.0],PARAMETER[\"Latitude_Of_Origin\",39.66825833333333],UNIT[\"Meter\",1.0]]");
proj4.defs("EPSG:2246", "PROJCS[\"NAD_1983_StatePlane_Kentucky_North_FIPS_1601_Feet\",GEOGCS[\"GCS_North_American_1983\",DATUM[\"D_North_American_1983\",SPHEROID[\"GRS_1980\",6378137.0,298.257222101]],PRIMEM[\"Greenwich\",0.0],UNIT[\"Degree\",0.0174532925199433]],PROJECTION[\"Lambert_Conformal_Conic\"],PARAMETER[\"False_Easting\",1640416.66666667],PARAMETER[\"False_Northing\",0.0],PARAMETER[\"Central_Meridian\",-84.25],PARAMETER[\"Standard_Parallel_1\",37.9666666666667],PARAMETER[\"Standard_Parallel_2\",38.9666666666667],PARAMETER[\"Latitude_Of_Origin\",37.5],UNIT[\"Foot_US\",0.3048006096012192]]");

// ESRI's own code for Web Mercator. An ArcGIS service reports its projection as 102100 where the
// EPSG register calls it 3857, and proj4 ships the 102113 spelling of it but not this one - so a
// map source taken straight from ArcGIS arrives in a projection nothing on the page has heard of,
// and the first asset placed on it throws "No transform available between EPSG:27700 and
// EPSG:102100" - which took the whole map down with it.
proj4.defs("EPSG:102100", proj4.defs("EPSG:3857"));

ol.proj.proj4.register(proj4);

// Registered through proj4 they are three separate projections that happen to describe the same
// grid, so every coordinate would be put through a transform to go between them. Telling
// OpenLayers they are the same thing makes that a no-op.
try {
    if (typeof ol.proj.addEquivalentProjections === 'function') {
        ol.proj.addEquivalentProjections([
            ol.proj.get('EPSG:3857'),
            ol.proj.get('EPSG:102100'),
            ol.proj.get('EPSG:102113')
        ].filter(Boolean));
    }
} catch (error) {
    console.warn('Could not tie the Web Mercator projections together:', error);
}

// --- Smooth map resize on device orientation change / window resize ---
let _resizeTimer = null;

function _updateAllMapSizes() {
    for (let elementId in maps) {
        if (disposedMaps[elementId]) continue;
        let map = maps[elementId];
        if (map) {
            map.updateSize();
            if (!map._groupingResizePending) {
                map._groupingResizePending = true;
                map.once('postrender', () => {
                    map._groupingResizePending = false;
                    setItemsLayerSource(elementId);
                });
            }
        }
    }
}

window.addEventListener('resize', function () {
    // Immediate update so the map isn't blank during rotation animation
    _updateAllMapSizes();
    // Debounced follow-up to catch final layout after the resize/rotation transition settles
    clearTimeout(_resizeTimer);
    _resizeTimer = setTimeout(_updateAllMapSizes, 300);
});

// Profiling hooks for a map - see amxMapProfile. How many of the report's records are on the map
// is said by BaseMapComponent (ShowLocationCount), from the report's location data, as the old web's
// map tab said it; the "points" and "lines" pills this used to build have gone (Mobile still has them).
function watchMapProfile(map, elementId) {
    // One line per render while profiling is on - see amxMapProfile. Costs a property read when
    // it is off, which it is unless someone has asked.
    map.on('rendercomplete', () => reportMapProfile(elementId));

    // What OpenLayers itself says about the frame it has just drawn: whether anything asked for
    // another one, and whether the view is moving. A map that repaints while nobody is touching
    // it is answered here rather than guessed at.
    map.on('postrender', (event) => {
        if (!mapProfiling || !event.frameState) {
            return;
        }

        mapProfile.animate = event.frameState.animate === true;
        mapProfile.viewAnimating = event.frameState.viewHints?.[0] ?? 0;
        mapProfile.viewInteracting = event.frameState.viewHints?.[1] ?? 0;
        mapProfile.tilesQueued = event.frameState.tileQueue?.getCount?.() ?? 0;
    });
}

// The user's map settings, applied to a live map (MapSettingsPanel raises this on every change,
// and BaseMapComponent once as the map loads).
//
// The settings are kept on the map's own options so the style functions can read them as they
// draw - they run per feature per frame, so anything they need has to be there for the asking
// rather than fetched. Everything that follows is a redraw, not a reload: none of these change
// what the map is holding, only how it is drawn.
function applyMapSettings(elementId, settings) {
    const map = maps[elementId];
    if (!map || !settings) {
        return;
    }

    map.mapOptions.mapSettings = settings;

    // Labels are the one setting that already had a home of its own, from the label control, so
    // this goes through setMapLabelsEnabled to move the control with the switch.
    setMapLabelsEnabled(elementId, settings.dataLabels === true);

    // Clustering is read from clusteringOptions by setItemsLayerSource and the cluster styles.
    map.mapOptions.allowClustering = settings.allowClustering === true;
    map.mapOptions.clusteringOptions = {
        ...map.mapOptions.clusteringOptions,
        clusterDistance: settings.clusterDistance,
        clusterMinDistance: settings.clusterMinDistance,
        useZoom: settings.clusterUseZoom === true,
        zoomLevel: settings.clusterUseZoom === true ? settings.clusterZoomLevel : 99,
        useVisibleAssets: settings.clusterUseVisibleAssets === true,
        visibleAssetCount: settings.clusterVisibleAssetNumber
    };

    applyMapColourSaturation(map, settings.colourSaturation);

    applyLayerLabelSpacing(map);

    setItemsLayerSource(elementId);

    getItemsLayerUnderlyingSource(elementId)?.changed();
    getLinearItemsLayerUnderlyingSource(elementId)?.changed();
    itemsLayers[elementId]?.changed();
    linearItemsLayers[elementId]?.changed();
}

// Data labels, written wherever they are read from. The user reaches this one setting two ways -
// the label control on the map and the Data labels switch in the settings panel - so neither of
// them owns it: whichever was used, this puts the label state on the map's options, on the
// settings the panel reads back, on the control's own icon, and on the page state a reloaded map
// is rebuilt from. Left to themselves the two drifted apart, and the map ended up showing labels
// with the switch off, or the switch on with the control dark.
function setMapLabelsEnabled(elementId, enabled) {
    const map = maps[elementId];
    if (!map) {
        return;
    }

    map.mapOptions.mapLabelsEnabled = enabled;

    if (map.mapOptions.mapSettings) {
        map.mapOptions.mapSettings.dataLabels = enabled;
    }

    // The control is only built for maps that have features - see createMap - so it is asked
    // rather than assumed.
    mapControls[elementId]?.labelToggleControl?.toggleMapLabels?.();

    // The view model the map was built from lives on the Blazor side, and a reload builds from
    // it. Without this the labels came back as they were before the panel was touched.
    // Nothing waits on this, and a map on its way out has nothing left to tell.
    dotnetObjectRef[elementId]?.invokeMethodAsync('SetMapLabelsState', enabled)?.catch(() => {
    });

    // Layers whose labels follow the toggle are styled per feature per frame, but a style only
    // reruns when the layer is told something changed; without this their labels stayed as they
    // were until the next pan.
    if (map.mapOptions.layerLabelsFollowToggle === true) {
        redrawLabelledLayers(map.getLayers());
        // The feature service's own items are drawn by the items layers, which carry no label
        // field of their own; they are told directly.
        itemsLayers[elementId]?.changed();
        linearItemsLayers[elementId]?.changed();
        getItemsLayerUnderlyingSource(elementId)?.changed();
        getLinearItemsLayerUnderlyingSource(elementId)?.changed();
    }

    // Raised after every write above, so a listener reads the state as it now is. Anything drawn
    // around the labels rather than by them - the route planner's stop badges sit in whichever
    // corner the label leaves free - has to redraw when the labels move or go, and this is the one
    // place both the label control and the settings panel pass through.
    map.dispatchEvent(AMX_MAP_LABELS_CHANGE);
}

/** Tells every layer with a label field, at any depth of grouping, to draw itself again. */
function redrawLabelledLayers(collection) {
    collection?.forEach(layer => {
        if (typeof layer.getLayers === 'function') {
            redrawLabelledLayers(layer.getLayers());
        } else if (layer.get('labelField')) {
            layer.changed();
        }
    });
}

/** Map event raised by setMapLabelsEnabled once the label state and position have been written. */
const AMX_MAP_LABELS_CHANGE = 'amx:labelschange';

// What this map is currently drawing with, for the settings panel to open on. Null before any
// settings have been applied, which is the panel's cue to fall back to the stored ones.
function getAppliedMapSettings(elementId) {
    return maps[elementId]?.mapOptions?.mapSettings ?? null;
}

// The least space, in pixels, the user wants kept between the labels of the map's feature layers
// (map settings - Space between layer labels). 0 is off: every label is drawn where it falls.
function getLayerLabelSpacing(map) {
    const spacing = Number(getMapSettings(map).layerLabelSpacing);
    return Number.isFinite(spacing) && spacing > 0 ? spacing : 0;
}

// Per-layer note of the spacing a layer was last set up for - see applyLayerLabelSpacing.
const LAYER_LABEL_SPACING_KEY = 'amxLabelSpacing';

// Spaces out the labels of the map's feature layers, or stops doing so.
//
// Two things keep them apart. The layers are decluttered, so OpenLayers never draws one label over
// another and brings back what it held back as the map zooms in. The gap itself is kept here, in
// the layers' style functions (hasLayerLabelRoom): OpenLayers' own declutter has no margin, and the
// padding it does count is ignored for labels that follow a line and would grow the box behind an
// AMX feature service label, so the spacing only ever reached point labels. The layer's points are
// never dropped with their labels - see the declutterMode on their images. Off, every label is
// drawn wherever it falls, as layers always did.
//
// Only layers that label their features and draw them in the browser are touched: a tile or image
// layer's labels are already in its pictures. Each layer remembers the spacing it was set up for,
// because the settings are applied again on every change in the panel and redrawing every layer
// for a change of icon size is wasted work - and a layer made since (a change of base map brings
// its own) has no note yet, so it is set up on the next call.
function applyLayerLabelSpacing(map) {
    if (!map) {
        return;
    }

    const spacing = getLayerLabelSpacing(map);

    forEachLabelledVectorLayer(map.getLayers(), layer => {
        // A layer switched on or off changes which labels are competing for room, so the room
        // handed out so far is taken back and every layer is labelled again.
        if (layer.get(LAYER_LABEL_ROOM_LISTENER_KEY) !== true) {
            layer.set(LAYER_LABEL_ROOM_LISTENER_KEY, true);
            layer.on('change:visible', () => resetLayerLabelRoom(map));

            // So does a feature leaving the layer, or its room is held until the next zoom for a
            // label nobody draws. A layer that reloads its features as the map moves (an Esri
            // layer's Reload features on navigation) clears its source and adds every feature
            // back as a new one, and each of those then collided with the room its old self still
            // held: after the first pan every label on the layer was held back. A change of base
            // map clears the old feature layers the same way.
            const source = layer.getSource?.();
            source?.on?.('removefeature', () => scheduleLayerLabelRoomReset(map));
            source?.on?.('clear', () => scheduleLayerLabelRoomReset(map));
        }

        if (layer.get(LAYER_LABEL_SPACING_KEY) === spacing) {
            return;
        }

        layer.set(LAYER_LABEL_SPACING_KEY, spacing);

        // Redraws the layer as well, which runs its style function again for the new spacing.
        layer.setDeclutter(spacing > 0);
    });
}

const LAYER_LABEL_ROOM_LISTENER_KEY = 'amxLabelRoomListener';

// The size of a cell in the grid the placed labels are filed in, in pixels. A few labels across, so
// a label is checked against the handful near it rather than against every label on the map.
const LAYER_LABEL_ROOM_CELL = 128;

// Forgets which labels were given room, and labels every layer again.
function resetLayerLabelRoom(map) {
    map.amxLabelRoom = null;

    forEachLabelledVectorLayer(map.getLayers(), layer => layer.changed());
}

const LAYER_LABEL_ROOM_RESET_KEY = 'amxLabelRoomResetPending';

// resetLayerLabelRoom once, after the features leaving a layer have all gone - a cleared source
// reports every one of them - rather than once a feature. Before the next frame, which is drawn
// with whatever the source was given in their place. Nothing to do with the spacing off.
function scheduleLayerLabelRoomReset(map) {
    if (map[LAYER_LABEL_ROOM_RESET_KEY] === true || getLayerLabelSpacing(map) <= 0) {
        return;
    }

    map[LAYER_LABEL_ROOM_RESET_KEY] = true;

    queueMicrotask(() => {
        map[LAYER_LABEL_ROOM_RESET_KEY] = false;
        resetLayerLabelRoom(map);
    });
}

// Whether a feature layer's label can be drawn without coming closer than the user's spacing to a
// label already given room. Asked by the style functions, which drop the text when it cannot.
//
// The label's box is worked out the way OpenLayers will draw it - measured text, aligned and offset
// from the point it is drawn at, turned along a line for a label that follows one - then widened by
// half the gap on every side, so two boxes that miss each other are at least the whole gap apart.
// The boxes are kept in map units for the resolution they were worked out at, and every answer is
// kept with them: panning restyles features without changing the zoom, and a label that came and
// went as the map moved would be worse than one that stayed hidden. A new zoom starts again.
//
// `placement` is the label's text style as far as its box goes: textAlign, textBaseline, offsetX,
// offsetY and padding, as an ol.style.Text takes them, and line for a label drawn along a line.
function hasLayerLabelRoom(map, feature, resolution, text, font, placement) {
    return hasLabelRoom(map, 'amxLabelRoom', getLayerLabelSpacing(map), 0, feature, resolution, text, font, placement);
}

// The least space, in pixels, the user wants kept between the labels of the map's own items (map
// settings - Space between item labels). 0 is off: every item label is drawn where it falls.
function getItemLabelSpacing(map) {
    const spacing = Number(getMapSettings(map).itemLabelSpacing);
    return Number.isFinite(spacing) && spacing > 0 ? spacing : 0;
}

// Whether an item's label can be drawn without coming closer than the user's item label spacing to
// another item's label already given room - hasLayerLabelRoom's rule, among item labels only, so
// the two spacings never take room from each other. Asked by the items layer's style function,
// which drops the text and keeps the icon when it cannot.
//
// The room is kept until the items change as well as the zoom. Filtering, hiding items and loading
// new ones all call changed() on the items layer's sources, so their revisions are part of what the
// room was worked out for: an item filtered off the map must not go on holding room it no longer
// draws in.
function hasItemLabelRoom(map, elementId, feature, resolution, text) {
    const spacing = getItemLabelSpacing(map);
    if (!text || spacing <= 0) {
        return true;
    }

    const itemsSource = itemsLayers[elementId]?.getSource();
    const underlyingSource = getItemsLayerUnderlyingSource(elementId);
    const revision = (itemsSource?.getRevision() ?? 0)
        + (underlyingSource && underlyingSource !== itemsSource ? underlyingSource.getRevision() : 0);

    return hasLabelRoom(map, 'amxItemLabelRoom', spacing, revision, feature, resolution, text, LABEL_TEXT_FONT, {
        ...getLabelPlacement(getMapSettings(map).dataLabelsPosition),
        padding: LABEL_TEXT_PADDING
    });
}

// The rule both of those apply: one set of placed labels per kind, kept on the map under roomKey
// for the resolution, spacing and revision it was worked out at. See hasLayerLabelRoom.
function hasLabelRoom(map, roomKey, spacing, revision, feature, resolution, text, font, placement) {
    if (!text || spacing <= 0 || !resolution || !feature?.getGeometry) {
        return true;
    }

    let state = map[roomKey];
    if (!state || state.resolution !== resolution || state.spacing !== spacing || state.revision !== revision) {
        state = map[roomKey] = {
            resolution,
            spacing,
            revision,
            cellSize: LAYER_LABEL_ROOM_CELL * resolution,
            cells: new Map(),
            answers: new WeakMap()
        };
    }

    const known = state.answers.get(feature);
    if (known !== undefined && known.text === text) {
        return known.room;
    }

    const box = getLayerLabelExtent(feature.getGeometry(), resolution, text, font, placement, spacing / 2);

    // No box is a label OpenLayers will not draw - a line too short on screen to carry it - so it
    // takes no room.
    let room = true;
    if (box) {
        room = !layerLabelRoomCollides(state, box);
        if (room) {
            layerLabelRoomAdd(state, box);
        }
    }

    state.answers.set(feature, {text, room});

    return room;
}

// The map extent a label covers, widened by `margin` pixels on every side. Null when OpenLayers
// would not draw the label at all.
function getLayerLabelExtent(geometry, resolution, text, font, placement, margin) {
    const size = measureLayerLabel(text, font);

    let anchor;
    let box;

    if (placement?.line) {
        const line = getLongestLine(geometry);
        if (!line || line.getLength() / resolution < size.width) {
            return null;
        }

        // Drawn along the line, centred on its middle: the box is the text turned to the line's
        // direction there, and boxed square to the map.
        anchor = line.getCoordinateAt(0.5);
        const before = line.getCoordinateAt(0.45);
        const after = line.getCoordinateAt(0.55);
        const angle = Math.atan2(after[1] - before[1], after[0] - before[0]);
        const cos = Math.abs(Math.cos(angle));
        const sin = Math.abs(Math.sin(angle));
        const halfWidth = (size.width * cos + size.height * sin) / 2;
        const halfHeight = (size.width * sin + size.height * cos) / 2;

        box = [-halfWidth, -halfHeight, halfWidth, halfHeight];
    } else {
        anchor = getLabelAnchor(geometry);
        if (!anchor) {
            return null;
        }

        box = getLabelBox(size, placement);
    }

    // Screen pixels run down the page and map units up it.
    return [
        anchor[0] + (box[0] - margin) * resolution,
        anchor[1] - (box[3] + margin) * resolution,
        anchor[0] + (box[2] + margin) * resolution,
        anchor[1] - (box[1] - margin) * resolution
    ];
}

// A label's box against the point it is drawn at, in pixels with y down, as OpenLayers lays it out.
function getLabelBox(size, placement) {
    const [top, right, bottom, left] = placement?.padding ?? [0, 0, 0, 0];

    const align = placement?.textAlign;
    let minX = align === 'left' || align === 'start' ? 0 : align === 'right' || align === 'end' ? -size.width : -size.width / 2;

    // OpenLayers' own default is 'middle'.
    const baseline = placement?.textBaseline;
    let minY = baseline === 'top' || baseline === 'hanging' ? 0
        : baseline === 'bottom' || baseline === 'alphabetic' || baseline === 'ideographic' ? -size.height
            : -size.height / 2;

    minX += placement?.offsetX ?? 0;
    minY += placement?.offsetY ?? 0;

    return [minX - left, minY - top, minX + size.width + right, minY + size.height + bottom];
}

// Where OpenLayers draws a point-placed label for a geometry.
function getLabelAnchor(geometry) {
    switch (geometry?.getType()) {
        case 'Point':
            return geometry.getCoordinates();
        case 'MultiPoint':
            return geometry.getCoordinates()[0];
        case 'LineString':
            return geometry.getCoordinateAt(0.5);
        case 'MultiLineString':
            return getLongestLine(geometry)?.getCoordinateAt(0.5);
        case 'Polygon':
            return geometry.getInteriorPoint().getCoordinates();
        case 'MultiPolygon':
            return geometry.getInteriorPoints().getCoordinates()[0];
        case 'Circle':
            return geometry.getCenter();
        default:
            return geometry ? ol.extent.getCenter(geometry.getExtent()) : null;
    }
}

function getLongestLine(geometry) {
    if (geometry instanceof ol.geom.LineString) {
        return geometry;
    }

    if (geometry instanceof ol.geom.MultiLineString) {
        let longest = null;
        for (const line of geometry.getLineStrings()) {
            if (!longest || line.getLength() > longest.getLength()) {
                longest = line;
            }
        }

        return longest;
    }

    return null;
}

// A label's width and height in pixels. Measured once per font and text: the same names are asked
// for on every restyle, and measuring is the expensive part of placing a label.
const layerLabelSizes = new Map();
let layerLabelMeasure = null;

function measureLayerLabel(text, font) {
    const labelFont = font || '10px sans-serif';
    const key = `${labelFont}|${text}`;

    let size = layerLabelSizes.get(key);
    if (size) {
        return size;
    }

    layerLabelMeasure ??= document.createElement('canvas').getContext('2d');
    layerLabelMeasure.font = labelFont;

    const lines = String(text).split('\n');
    const width = Math.max(...lines.map(line => layerLabelMeasure.measureText(line).width));
    const fontSize = parseFloat(/(\d+(?:\.\d+)?)px/.exec(labelFont)?.[1]) || 10;

    size = {width, height: lines.length * fontSize * 1.2};

    if (layerLabelSizes.size >= 5000) {
        layerLabelSizes.clear();
    }

    layerLabelSizes.set(key, size);

    return size;
}

// The grid the placed labels are filed in: each box under every cell it touches.
function forEachLayerLabelRoomCell(state, extent, callback) {
    const size = state.cellSize;
    const minX = Math.floor(extent[0] / size);
    const maxX = Math.floor(extent[2] / size);
    const minY = Math.floor(extent[1] / size);
    const maxY = Math.floor(extent[3] / size);

    for (let x = minX; x <= maxX; x++) {
        for (let y = minY; y <= maxY; y++) {
            if (callback(`${x},${y}`) === true) {
                return true;
            }
        }
    }

    return false;
}

function layerLabelRoomCollides(state, extent) {
    return forEachLayerLabelRoomCell(state, extent, key =>
        state.cells.get(key)?.some(placed => ol.extent.intersects(placed, extent)) === true);
}

function layerLabelRoomAdd(state, extent) {
    forEachLayerLabelRoomCell(state, extent, key => {
        const placed = state.cells.get(key);
        if (placed) {
            placed.push(extent);
        } else {
            state.cells.set(key, [extent]);
        }
    });
}

// Vector tiles keep their circles in the tile's image, and are rebuilt a quarter of a zoom level at a time,
// a few tiles a frame.
//
// Two costs of a styled vector basemap that OpenLayers pays over and over, both found zooming in
// and out over a busy map on the OS Outdoor basemap (a trace in Brave, October 2026):
//
// - Its circles. ol-mapbox-style draws a style's circle layers as point symbols, and OpenLayers
//   keeps only a tile's lines and areas in the tile's image: every point symbol is drawn again on
//   every frame, each its own save, drawImage and restore. The OS Outdoor style has thousands of
//   circles in view at middling zooms, where its labels are held back (pbfLabelMinZoom), and they
//   took 30-130ms of every frame of a zoom - three quarters of the time the map spent drawing. A
//   circle is never decluttered (ol-mapbox-style gives it declutterMode 'none', as the Mapbox
//   spec has it) and never turns with the view, so it is drawn into the tile as a small filled
//   shape of the same size and colours, in its own place among the style's layers, and from then
//   on costs nothing a frame. Most of them draw nothing at all: the style puts an invisible
//   circle (colour fully transparent, no stroke) under every place name, thousands to a tile,
//   and OpenLayers drew each of those every frame too. A circle like that is left out.
//
// - Rebuilding. When the map comes to rest OpenLayers rebuilds every vector tile in view for the
//   exact resolution it stopped at - runs the style over every feature again and redraws the
//   tile's image - so lines come out at their true width. The view zooms in fractions, so every
//   stop is a new resolution: 115-260ms at the end of each step of a zoom, and nothing reused on
//   the way back. Built for the nearest quarter of a zoom level instead, a tile is drawn at most
//   9% larger or smaller than it was built for, and a zoom that comes back to a quarter it has
//   been at finds its tiles ready.
//
// - Rebuilding all at once. Even a quarter at a time, the frame a zoom came to rest in rebuilt
//   every tile in view in one go - 10 to 50 of them on the OS Outdoor basemap, 1-14ms each, most
//   of it ol-mapbox-style running the style over each feature: a 50-200ms stall at the end of
//   most steps of a zoom, and the next turn of the wheel waited behind it (a third trace in
//   Brave, October 2026). Now a frame builds tiles for 8ms and leaves the rest to the frames
//   after it. A tile waiting its turn is drawn from the build it has, scaled, exactly as every
//   tile is drawn while a zoom is moving, so the map looks the same until its lines settle to
//   their width. A tile with nothing built yet is always built, or it would be a hole. Zooming
//   again holds the rest back until that zoom comes to rest too.
//
// Every vector tile layer is drawn this way - the basemap and any pbf overlay.
(function drawVectorTilesForLessEachFrame() {
    const VectorTileLayerRenderer = ol.renderer?.canvas?.VectorTileLayer;
    if (!VectorTileLayerRenderer) {
        return;
    }

    const BUILDS_PER_ZOOM_LEVEL = 4;

    // How long a frame spends building tiles before it leaves the rest to the next frame. One
    // frame's time is shared by every vector tile layer in it, so a pbf overlay and the basemap
    // under it do not take 8ms each.
    const BUILD_MS_PER_FRAME = 8;

    // The frame being drawn, held weakly. It is only compared, to tell when a new frame starts,
    // and a frame holds its map - the tile queue calls back into it - so holding it strongly kept
    // the last map that drew vector tiles alive after its record closed, with every tile it had
    // built (up to the 128 the renderer caches) and their canvases.
    let buildFrame = null;
    let buildMs = 0;

    // Corners of a circle's shape, by how many: worked out once.
    const unitCircles = new Map();

    function unitCircle(sides) {
        let corners = unitCircles.get(sides);

        if (!corners) {
            corners = new Float64Array(sides * 2);

            for (let i = 0; i < sides; ++i) {
                corners[i * 2] = Math.cos(2 * Math.PI * i / sides);
                corners[i * 2 + 1] = Math.sin(2 * Math.PI * i / sides);
            }

            unitCircles.set(sides, corners);
        }

        return corners;
    }

    // The shape a circle symbol on a point (or each point of a multipoint) draws: a polygon of
    // the circle's radius at the resolution the tile is built for, with enough corners that none
    // shows - one per half a screen pixel of its radius, 8 to 32. None when any of it would fall
    // outside the part of the tile being built: the tile's image ends there, and the tile next
    // door may not carry the point to draw the rest, so a circle on the edge stays a symbol,
    // drawn whole as it always was.
    function circleShape(feature, circle, stroke, resolution, pixelRatio, extent) {
        const scale = circle.getScaleArray();
        const radius = circle.getRadius() * scale[0];
        const displacement = circle.getDisplacement();
        const reach = (radius + (stroke?.getWidth() ?? 1) / 2) * resolution;
        const sides = Math.min(32, Math.max(8, Math.ceil(radius * pixelRatio * 2)));
        const corners = unitCircle(sides);

        const points = feature.getFlatCoordinates();
        const stride = feature.getStride();
        const count = Math.floor(points.length / stride);
        const flat = new Array(count * (sides + 1) * 2);
        const ends = [];
        let at = 0;

        for (let p = 0; p < count; ++p) {
            const x = points[p * stride] + displacement[0] * resolution;
            const y = points[p * stride + 1] + displacement[1] * resolution;

            if (!extent || x - reach < extent[0] || y - reach < extent[1] || x + reach > extent[2] || y + reach > extent[3]) {
                return null;
            }

            for (let i = 0; i <= sides; ++i) {
                const corner = (i % sides) * 2;
                flat[at++] = x + corners[corner] * radius * resolution;
                flat[at++] = y + corners[corner + 1] * radius * resolution;
            }

            ends.push(at);
        }

        return count === 1
            ? new ol.geom.Polygon(flat, 'XY', ends)
            : new ol.geom.MultiPolygon(flat, 'XY', ends.map(end => [end]));
    }

    // A style that only draws a plain circle on the feature itself - no label, no fade, no
    // geometry of its own, no stretch - and so can be drawn as a shape without looking different.
    function isPlainCircle(style) {
        const circle = style.getImage();

        if (!(circle instanceof ol.style.Circle) || style.getGeometry() || style.getRenderer()
            || style.getText()?.getText() || circle.getOpacity() !== 1 || !(circle.getRadius() > 0)) {
            return false;
        }

        const scale = circle.getScaleArray();

        return scale[0] === scale[1] && scale[0] > 0;
    }

    // A circle's fill and stroke, each only if it shows: a fully transparent colour or a stroke
    // no wide draws nothing, however many times it is drawn.
    function isTransparent(color) {
        return (typeof color === 'string' || Array.isArray(color)) && ol.color.asArray(color)[3] === 0;
    }

    function visibleFill(circle) {
        const fill = circle.getFill();

        return fill && !isTransparent(fill.getColor()) ? fill : null;
    }

    function visibleStroke(circle) {
        const stroke = circle.getStroke();

        return stroke && (stroke.getWidth() ?? 1) > 0 && !isTransparent(stroke.getColor()) ? stroke : null;
    }

    class VectorTileRenderer extends VectorTileLayerRenderer {
        constructor(layer, options) {
            super(layer, options);

            // The resolution and pixel ratio the tile being built is for, which renderFeature is
            // not told, and the styles each slot of a feature's style array is swapped for. A
            // style is used up by the time renderFeature returns, so each slot's is reused, the
            // way ol-mapbox-style reuses its own.
            this.buildResolution_ = undefined;
            this.buildPixelRatio_ = 1;
            this.shapeStyles_ = [];
        }

        getTile(z, x, y, frameState) {
            const tile = super.getTile(z, x, y, frameState);

            if (tile?.wantedResolution) {
                const tileResolution = this.getLayer().getSource()
                    .getTileGridForProjection(frameState.viewState.projection).getResolution(z);

                if (tileResolution > 0) {
                    const builds = Math.round(Math.log2(tile.wantedResolution / tileResolution) * BUILDS_PER_ZOOM_LEVEL);

                    tile.wantedResolution = tileResolution * Math.pow(2, builds / BUILDS_PER_ZOOM_LEVEL);
                }
            }

            return tile;
        }

        updateExecutorGroup_(tile, pixelRatio, projection) {
            this.buildResolution_ = tile.wantedResolution;
            this.buildPixelRatio_ = pixelRatio;

            super.updateExecutorGroup_(tile, pixelRatio, projection);
        }

        // Draws the tile, building it first if it needs it - unless this frame has had its time
        // for building and the tile has a build to be drawn from meanwhile. Then that build is
        // drawn, and another frame asked for, which builds the tile if its own time allows. Only
        // building counts against the frame's time, not drawing a tile that is ready - on a phone
        // drawing the ready ones alone can take 8ms, and the tiles behind them would then wait for
        // good - so the first build in a frame always goes ahead and every tile gets its turn.
        drawTile(tile, frameState, ...rest) {
            if (buildFrame?.deref() !== frameState) {
                buildFrame = new WeakRef(frameState);
                buildMs = 0;
            }

            const layer = this.getLayer();
            const state = tile.getReplayState?.(layer);

            if (!state || this.isBuilt_(tile, state, layer, tile.wantedResolution, frameState.pixelRatio)) {
                super.drawTile(tile, frameState, ...rest);
                return;
            }

            if (buildMs >= BUILD_MS_PER_FRAME
                && this.isBuilt_(tile, state, layer, state.renderedResolution, frameState.pixelRatio)) {
                tile.wantedResolution = state.renderedResolution;
                frameState.animate = true;
                super.drawTile(tile, frameState, ...rest);
                return;
            }

            const started = performance.now();
            super.drawTile(tile, frameState, ...rest);
            buildMs += performance.now() - started;
        }

        // Whether the tile is built, and its image drawn, for this resolution and pixel ratio with
        // the layer's current style revision and render order - nothing in the build still loading
        // (a dirty build is waiting for an icon). Then drawing it redoes neither. OpenLayers
        // rebuilds a tile whose pixel ratio has changed (the window moved to another screen), so
        // a build made at another ratio is not one.
        isBuilt_(tile, state, layer, resolution, pixelRatio) {
            const revision = layer.getRevision();

            if (!(resolution > 0) || state.renderedResolution !== resolution || state.renderedPixelRatio !== pixelRatio
                || state.dirty || state.renderedRevision !== revision
                || state.renderedRenderOrder !== (layer.getRenderOrder() || null)) {
                return false;
            }

            return layer.getRenderMode() === 'vector'
                || (state.renderedTileResolution === resolution && state.renderedTileRevision === revision);
        }

        renderFeature(feature, squaredTolerance, styles, builderGroup, declutter, index) {
            return super.renderFeature(feature, squaredTolerance, this.circlesAsShapes_(feature, styles, builderGroup),
                builderGroup, declutter, index);
        }

        // The feature's styles with each plain circle swapped for its shape, and each one that
        // shows nothing left out. The array handed in is ol-mapbox-style's own, reused for the
        // next feature, so it is copied, not changed.
        // The builder group knows the part of the tile it is building (maxExtent_), which is
        // all the tile's image will show of it.
        circlesAsShapes_(feature, styles, builderGroup) {
            const type = feature.getType?.();

            if (!styles || !(this.buildResolution_ > 0) || typeof feature.getFlatCoordinates !== 'function'
                || (type !== 'Point' && type !== 'MultiPoint')) {
                return styles;
            }

            const list = Array.isArray(styles) ? styles : [styles];
            let swapped = null;

            for (let i = 0; i < list.length; ++i) {
                const style = list[i];

                if (!isPlainCircle(style)) {
                    swapped?.push(style);
                    continue;
                }

                const circle = style.getImage();
                const fill = visibleFill(circle);
                const stroke = visibleStroke(circle);

                if (!fill && !stroke) {
                    swapped ??= list.slice(0, i);
                    continue;
                }

                const shape = circleShape(feature, circle, stroke, this.buildResolution_, this.buildPixelRatio_,
                    builderGroup?.maxExtent_);

                if (!shape) {
                    swapped?.push(style);
                    continue;
                }

                swapped ??= list.slice(0, i);

                const shapeStyle = this.shapeStyles_[i] ??= new ol.style.Style();

                shapeStyle.setFill(fill);
                shapeStyle.setStroke(stroke);
                shapeStyle.setZIndex(style.getZIndex());
                shapeStyle.setGeometry(shape);

                swapped.push(shapeStyle);
            }

            return swapped ?? styles;
        }
    }

    // The layer's cacheSize goes to the renderer, as OpenLayers' own createRenderer hands it on.
    // Without it the renderer kept 512 tiles whatever the layer asked for: a record's map in Brave
    // (a trace and a heap snapshot, October 2026) held 167 vector tiles from every zoom level it
    // had been at, 8 to 15 - 530MB of features and drawing instructions, and 83 tile images, most
    // of them 4MB - and each frame of a zoom or pan spent 20-40ms drawing its canvases on the CPU.
    ol.layer.VectorTile.prototype.createRenderer = function () {
        return new VectorTileRenderer(this, {cacheSize: this.cacheSize_});
    };
})();

// Faded feature layers are drawn on a canvas of their own, faded by the page rather than redrawn.
//
// OpenLayers fades a vector layer (opacity under 1 - a feature layer's configured opacity, the
// user's choice in Map layers, and its label layer, which follows it) by drawing the whole layer
// onto a spare canvas the size of the map, copying that onto the map at the layer's opacity, and
// shrinking the spare to 1 x 1 again - on every frame. A record's map with a faded, labelled layer
// panned at 2-3 frames a second: the copy stalled each frame for 300-450ms while the GPU worked
// through every icon and label drawn on the spare (a trace in Brave, October 2026). With label
// spacing on, the layers are decluttered, and the copy waits until every layer has drawn its
// labels. It also throws when a faded layer has nothing to draw, which abandons the rest of that
// frame.
//
// So a faded layer is given a canvas nobody else draws on, OpenLayers draws to it as if the layer
// were opaque, and the canvas's container carries the opacity, which the browser applies when it
// composites the page. The look is the same; nothing is redrawn to fade it. A layer back at full
// opacity shares the map's canvas again. The map export already composites each layer canvas at
// its container's opacity (mapExportControl.js), so a printed or downloaded map keeps the fade.
(function drawFadedVectorLayersOnTheirOwnCanvas() {
    const VectorLayerRenderer = ol.renderer?.canvas?.VectorLayer;
    if (!VectorLayerRenderer) {
        return;
    }

    class FadedOnOwnCanvasRenderer extends VectorLayerRenderer {
        // opacity_ is the layer's opacity for this frame, set by renderFrame before it asks for
        // a container. A faded layer never takes the canvas of the layer below it, and its own
        // carries a class no other layer has, so the layer above never takes it either.
        useContainer(target, transform, backgroundColor, ...rest) {
            const faded = this.opacity_ !== 1;

            super.useContainer(faded ? null : target, transform, backgroundColor, ...rest);

            if (this.containerReused) {
                return;
            }

            const className = this.getLayer().getClassName() + (faded ? ' amx-faded-layer' : '');
            if (this.container.className !== className) {
                this.container.className = className;
            }

            const opacity = faded ? String(this.opacity_) : '';
            if (this.container.style.opacity !== opacity) {
                this.container.style.opacity = opacity;
            }
        }

        // Where OpenLayers swaps in the spare canvas and copies it back faded. The container does
        // the fading, so the layer draws straight onto its canvas.
        setDrawContext_() {
        }

        resetDrawContext_() {
        }
    }

    ol.layer.Vector.prototype.createRenderer = function () {
        return new FadedOnOwnCanvasRenderer(this);
    };
})();

// Labels drawn above everything else on the map.
//
// OpenLayers draws a layer's labels with the rest of that layer, so any layer drawn after it -
// the linear items, another feature layer, a route - is drawn over them: a chainage label under the
// line it marks. Declutter does not lift them either; its labels still land in the layer's own
// place in the stack. So each labelled layer gets a second layer for its labels alone: it shares
// the layer's source and style function, keeps only the text, and sits above every layer of
// content (LABEL_LAYER_Z_INDEX). The layer keeps everything but the text.
//
// Above the items (10), the linear items and the route's lines and turns (up to 25), and one under
// the route planner's stop badges (30): where a badge and a label collide the badge has to win
// (routeOverlay.js), and OpenLayers gives a collision to the higher layer. The drawing and
// measuring tools, the user's location and the search marker stay above them all.
//
// The label layer follows its layer: shown, hidden, faded and zoom-limited with it, restyled when
// it is, given its source when it changes (the items layer swaps between plain and grouped), and
// taken off the map with it (a change of base map replaces the feature layers).
const LABEL_LAYER_Z_INDEX = 29;
const LABEL_LAYER_KEY = 'amxLabelLayer';
const LABEL_LAYER_MIRRORED_PROPERTIES = ['visible', 'opacity', 'minResolution', 'maxResolution', 'minZoom', 'maxZoom', 'extent'];

// A feature that carries its own style (an AMX feature service icon, a KML placemark) would be
// drawn whole by the label layer too - OpenLayers prefers a feature's style to its layer's. Its
// style is moved to here, where the two layers' style functions pick it up, so each draws its half.
const OWN_FEATURE_STYLE_KEY = 'amxOwnStyle';

// The label styles worked out while the layer drew a feature, for the label layer to reuse rather
// than run a style function - the items layer's is the heaviest code on the map - a second time.
// Keyed by the feature; good for the resolution and revisions it was made at.
const liftedLabelStyles = new WeakMap();

function liftMapLabels(map, elementId) {
    if (!map) {
        return;
    }

    [itemsLayers[elementId], linearItemsLayers[elementId]].forEach(layer => liftLayerLabels(map, layer));
    forEachLabelledVectorLayer(map.getLayers(), layer => liftLayerLabels(map, layer));
}

function liftLayerLabels(map, layer) {
    if (!layer || layer.get(LABEL_LAYER_KEY) || typeof layer.getStyleFunction !== 'function') {
        return;
    }

    // None where every feature brings its own style (a KML layer): the features' styles are all
    // there is to draw, and a feature without one draws nothing, as it did.
    const ownStyle = layer.getStyleFunction() ?? (() => undefined);

    const stylesFor = (feature, resolution) => {
        const featureStyle = feature.get(OWN_FEATURE_STYLE_KEY);
        if (featureStyle !== undefined && featureStyle !== null) {
            return typeof featureStyle === 'function' ? featureStyle(feature, resolution) : featureStyle;
        }

        return ownStyle(feature, resolution);
    };

    layer.setStyle((feature, resolution) => {
        const styles = stylesFor(feature, resolution);

        // The text is copied as it is now: a cached style's text is set afresh for each feature
        // it draws, so by the time the label layer draws this one it will name another.
        liftedLabelStyles.set(feature, {
            resolution,
            layerRevision: layer.getRevision(),
            featureRevision: feature.getRevision(),
            labels: getLabelOnlyStyles(styles, true)
        });

        return getStylesWithoutLabels(styles);
    });

    const labels = new ol.layer.Vector({
        source: layer.getSource(),
        zIndex: LABEL_LAYER_Z_INDEX,
        declutter: layer.getDeclutter(),
        updateWhileAnimating: layer.getUpdateWhileAnimating?.(),
        updateWhileInteracting: layer.getUpdateWhileInteracting?.(),
        style: (feature, resolution) => {
            const lifted = liftedLabelStyles.get(feature);
            if (lifted && lifted.resolution === resolution && lifted.layerRevision === layer.getRevision()
                && lifted.featureRevision === feature.getRevision()) {
                return lifted.labels;
            }

            return getLabelOnlyStyles(stylesFor(feature, resolution), false);
        }
    });

    LABEL_LAYER_MIRRORED_PROPERTIES.forEach(key => labels.set(key, layer.get(key)));
    layer.set(LABEL_LAYER_KEY, labels);

    layer.on('propertychange', event => {
        if (LABEL_LAYER_MIRRORED_PROPERTIES.includes(event.key)) {
            labels.set(event.key, layer.get(event.key));
        } else if (event.key === 'source') {
            labels.setSource(layer.getSource());
            adoptOwnFeatureStyles(layer.getSource());
        } else if (event.key === 'map' && !layer.getMapInternal?.()) {
            labels.getMapInternal?.()?.removeLayer(labels);
        }
    });

    layer.on('change', () => {
        if (labels.getDeclutter() !== layer.getDeclutter()) {
            labels.setDeclutter(layer.getDeclutter());
        } else {
            labels.changed();
        }
    });

    adoptOwnFeatureStyles(layer.getSource());
    map.addLayer(labels);
}

// Moves a feature's own style onto it as a property - see OWN_FEATURE_STYLE_KEY - for the features
// a source has and every one it is given or restyled with later.
function adoptOwnFeatureStyles(source) {
    if (!source || source.get?.(OWN_FEATURE_STYLE_KEY) || typeof source.getFeatures !== 'function') {
        return;
    }

    source.set(OWN_FEATURE_STYLE_KEY, true);

    const adopt = feature => {
        const style = feature?.getStyle?.();
        if (style === undefined || style === null) {
            return;
        }

        feature.set(OWN_FEATURE_STYLE_KEY, style, true);
        feature.setStyle(null);
    };

    source.getFeatures().forEach(adopt);
    source.on('addfeature', event => adopt(event.feature));
    source.on('changefeature', event => adopt(event.feature));
}

function toStyleArray(styles) {
    if (!styles) {
        return [];
    }

    return Array.isArray(styles) ? styles : [styles];
}

function hasLabelText(style) {
    const text = style?.getText?.()?.getText?.();

    return text !== undefined && text !== null && text !== '';
}

// The styles as given, but with no text - a copy of each style that has any, so a style shared
// between features (the icon cache) keeps its own.
function getStylesWithoutLabels(styles) {
    const all = toStyleArray(styles);
    if (!all.some(hasLabelText)) {
        return styles;
    }

    return all.map(style => hasLabelText(style)
        ? new ol.style.Style({
            geometry: style.getGeometry(),
            fill: style.getFill(),
            stroke: style.getStroke(),
            image: style.getImage(),
            renderer: style.getRenderer(),
            hitDetectionRenderer: style.getHitDetectionRenderer?.(),
            zIndex: style.getZIndex()
        })
        : style);
}

// Only the styles' text, where they have any. `keep` copies the text, for a style kept beyond the
// call it was made in.
function getLabelOnlyStyles(styles, keep) {
    return toStyleArray(styles).filter(hasLabelText).map(style => new ol.style.Style({
        geometry: style.getGeometry(),
        text: keep ? style.getText().clone() : style.getText(),
        zIndex: style.getZIndex()
    }));
}

/** Every vector layer with a label field, at any depth of grouping. */
function forEachLabelledVectorLayer(collection, callback) {
    collection?.forEach(layer => {
        if (typeof layer.getLayers === 'function') {
            forEachLabelledVectorLayer(layer.getLayers(), callback);
        } else if (layer.get('labelField') && typeof layer.setDeclutter === 'function') {
            callback(layer);
        }
    });
}

// Drains the colour out of the base map so the map's own items stand off it. Applied to the tile
// layers only, through the canvas filter either side of their render - the whole map is drawn to
// one canvas, so a filter on the element would take the items and shapes down with it.
function applyMapColourSaturation(map, saturation) {
    const value = saturation === undefined || saturation === null ? 100 : saturation;

    for (const layer of map.getLayers().getArray()) {
        const isBaseLayer = layer instanceof ol.layer.Group
            ? layer.get('title') === 'Base maps'
            : layer instanceof ol.layer.Tile;

        if (!isBaseLayer) {
            continue;
        }

        const targets = layer instanceof ol.layer.Group ? layer.getLayers().getArray() : [layer];

        for (const target of targets) {
            // Registered once per layer and then just re-read, so changing the slider does not
            // stack up another pair of handlers on every drag.
            if (target.get('saturationHandlersAdded') !== true) {
                target.on('prerender', (event) => {
                    const current = map.mapOptions?.mapSettings?.colourSaturation;
                    if (current === undefined || current === null || current === 100) {
                        return;
                    }

                    event.context.save();
                    event.context.filter = `saturate(${current}%)`;
                });

                target.on('postrender', (event) => {
                    const current = map.mapOptions?.mapSettings?.colourSaturation;
                    if (current === undefined || current === null || current === 100) {
                        return;
                    }

                    event.context.restore();
                });

                target.set('saturationHandlersAdded', true);
            }

            target.changed();
        }
    }
}

// The settings a style function needs, with the shipped defaults standing in until the panel or
// the map has handed any over. Reading them through here keeps every `?.` and fallback in one
// place rather than scattered through the drawing code.
function getMapSettings(map) {
    return map?.mapOptions?.mapSettings ?? {};
}

// How every image the items layer draws - icons, discs, status dots, line arrows, cluster
// bubbles - takes part in decluttering, when the layer declutters at all (the route planner turns
// it on; see routeOverlay.js). 'none' means the image is always drawn and never counted as a
// collision: the icon is the item, and the label is what yields when two items are close. Left
// at the default, an icon whose label collided with a neighbour's was dropped along with it, so a
// crowded map lost the very pins it was there to show. Text styles keep the default, so a label
// that would land on another label, or on a route badge, is held back until the map zooms in.
const ITEM_IMAGE_DECLUTTER_MODE = 'none';

// How wide an item or asset icon is drawn, in pixels, whatever size the image was uploaded
// at. Icon images range from 16px glyphs to full-size sign faces and bridge drawings, so
// drawing them at their natural size left some the size of a thumbnail and others covering
// half the map. The user's icon size setting scales from this width, not the natural one.
const ITEM_ICON_WIDTH = 25;

// The zoom level as the map shows it. The view zooms in fractions (constrainResolution is off),
// and the zoom control rounds to the nearest whole level (zoomLevelControl.js) - so a map the user
// sees at 15 can be at 14.6. Every zoom a setting names is compared against this, which makes the
// level itself count: "from zoom 15" holds for all of what is shown as 15, not from 15.0 on.
function getDisplayedZoomLevel(zoomLevel) {
    return Number.isFinite(zoomLevel) ? Math.round(zoomLevel) : zoomLevel;
}

// Whether a zoom-gated setting lets a feature draw at this zoom - the setting's level included,
// either way. -1 means every zoom, which is what the pickers offer as "Any zoom".
function isWithinSettingZoom(zoomLevel, settingZoom, drawWhenZoomedIn) {
    if (settingZoom === undefined || settingZoom === null || settingZoom < 0) {
        return true;
    }

    const zoom = getDisplayedZoomLevel(zoomLevel);

    return drawWhenZoomedIn ? zoom >= settingZoom : zoom <= settingZoom;
}

// Takes the view to a zoom, from the banner that tells the user the map is holding items back
// until they get there - so "zoom in" is a thing they can press rather than a distance to guess.
// Never zooms out: the banner only ever asks for more zoom than the map has.
function zoomMapTo(elementId, targetZoom) {
    const map = maps[elementId];
    if (!map) {
        return;
    }

    const view = map.getView();
    const zoom = view.getZoom() ?? 0;

    if (zoom >= targetZoom) {
        return;
    }

    view.animate({zoom: targetZoom, duration: 400});
}

// One map's size changed without the window changing - the layers panel taking a column beside
// it, for one. OpenLayers measures its viewport once, so it has to be told; the follow-up catches
// a layout that is still settling.
function updateMapSize(elementId) {
    const map = maps[elementId];
    if (!map) {
        return;
    }

    map.updateSize();

    setTimeout(() => {
        try {
            maps[elementId]?.updateSize();
        } catch {
        }
    }, 250);
}

// Every image drawn onto the map's canvases has to be fetched with cross-origin permission, or
// the canvas is tainted and the map cannot be read back out of it - which is what the map export
// reads. A source built here asks for it (crossOrigin), but a source built from a configured
// source string does not, so it is asked for on the image itself before the request is made.
// Same-origin requests - anything through our own tile endpoint - are unaffected by it.
function requestImageCrossOrigin(image) {
    if (image && !image.crossOrigin) {
        image.crossOrigin = 'Anonymous';
    }

    return image;
}

// The address the browser fetches a tile from: our own tile endpoint, which fetches it from the
// source with the referer and token the browser does not have (see MapTileService).
//
// A URL rather than a call into .NET, because the browser can then load tiles the way it loads
// any tiles - several at once, cached, and without going through the circuit, where the work is
// serialised behind everything else the UI is doing. That difference is why a map on a proxied
// source dragged while the same map on OpenStreetMap did not.
function getTileProxyUrl(src, referer, tokenObject) {
    const parameters = new URLSearchParams({src: src});

    if (referer) {
        parameters.set('referer', referer);
    }

    if (tokenObject?.tokenNumber) {
        parameters.set('tokenNumber', tokenObject.tokenNumber);

        if (tokenObject.token) {
            parameters.set('lastToken', tokenObject.token);
        }
    }

    return `/api/map/tile?${parameters.toString()}`;
}

function resolvePendingEditShapeUpdates(elementId) {
    const resolvers = pendingEditShapeUpdateResolvers[elementId];
    if (!resolvers || resolvers.length === 0) {
        return;
    }

    delete pendingEditShapeUpdateResolvers[elementId];

    for (const resolve of resolvers) {
        try {
            resolve();
        } catch {
        }
    }
}

function scheduleEditShapeUpdate(elementId, callback, delay = 100) {
    pendingEditShapeUpdateCounts[elementId] = (pendingEditShapeUpdateCounts[elementId] || 0) + 1;

    return setTimeout(function () {
        try {
            callback();
        } finally {
            pendingEditShapeUpdateCounts[elementId] = Math.max((pendingEditShapeUpdateCounts[elementId] || 1) - 1, 0);

            if (pendingEditShapeUpdateCounts[elementId] === 0) {
                resolvePendingEditShapeUpdates(elementId);
            }
        }
    }, delay);
}

function waitForPendingEditShapeUpdates(elementId) {
    if (!pendingEditShapeUpdateCounts[elementId]) {
        return Promise.resolve();
    }

    return new Promise(function (resolve) {
        if (!pendingEditShapeUpdateResolvers[elementId]) {
            pendingEditShapeUpdateResolvers[elementId] = [];
        }

        pendingEditShapeUpdateResolvers[elementId].push(resolve);
    });
}

// keepElement is for a rebuild rather than a teardown: the map is being made again on the same
// div - a change of base map rebuilds every layer and, where the projection differs, the view and
// every feature with it - so the element has to stay where it is. Blazor rendered that div and
// still holds a reference to it; taking it out from under Blazor and putting a different one back
// leaves its diff pointing at a node that is no longer in the document.
/**
 * Disposes every Bootstrap Tooltip/Popover attached inside the map's viewport and its controls.
 * Bootstrap keeps each instance in a module-level Map keyed by its element until dispose() is
 * called, and the control buttons' listeners close over the map - so without this, every map
 * ever opened stays alive, layers, features, tile caches and all.
 */
function disposeMapBootstrapInstances(map) {
    const bs = window.bootstrap;
    if (!bs)
        return;

    const roots = [];
    if (typeof map.getViewport === 'function')
        roots.push(map.getViewport());
    map.getControls().forEach(control => roots.push(control.element));

    for (const root of roots) {
        if (!root)
            continue;

        for (const el of [root, ...root.querySelectorAll('*')]) {
            try {
                disposeBootstrapTipInstance(bs.Tooltip?.getInstance(el));
                disposeBootstrapTipInstance(bs.Popover?.getInstance(el));
            } catch (e) {
                // Best effort - one bad instance must not stop the rest being released.
            }
        }
    }
}

/**
 * Disposes a Bootstrap Tooltip/Popover so that nothing is left holding its tip. Bootstrap 5.1.3
 * keeps the tip in two module-level registries dispose() never clears: show() files the tip in the
 * instance Map as well as the element the instance was made on, and on a touch device it puts an
 * empty mouseover listener, kept in its event registry, on every child of <body> - the tip among
 * them - that only hide() takes off again. A tip kept keeps its content, and the buttons of a
 * cluster list or a feature pop-up close over the map, so one shown kept that map alive.
 */
function disposeBootstrapTipInstance(instance) {
    if (!instance)
        return;

    // dispose() nulls every field, the tip among them.
    const tip = instance.tip;
    if (tip) {
        try {
            // Without the fade, hide() finishes there and then instead of after a transition that
            // dispose() would cut short. dispose() took the tip away at once anyway.
            tip.classList.remove('fade');
            instance.hide();
        } catch (e) {
            // Best effort - dispose() still takes the tip out of the page.
        }
    }

    instance.dispose();
    releaseBootstrapTip(tip);
}

/**
 * Takes a tip out of Bootstrap's instance Map. Bootstrap has no call for that, but dispose() takes
 * out the element an instance was made on, so an instance made on the tip itself - which takes over
 * its entry - is disposed again at once. A version that does not file tips leaves no entry, and
 * then this does nothing.
 */
function releaseBootstrapTip(tip) {
    const bs = window.bootstrap;
    if (!tip || !bs)
        return;

    for (const type of [bs.Popover, bs.Tooltip]) {
        try {
            if (type?.getInstance(tip))
                new type(tip).dispose();
        } catch (e) {
            // Best effort, as above.
        }
    }
}

/** Disposes a layer and, for a group, every layer inside it, releasing renderers and their tile caches. */
function disposeMapLayer(layer) {
    if (!layer)
        return;

    try {
        if (layer instanceof ol.layer.Group)
            layer.getLayers().getArray().slice().forEach(disposeMapLayer);

        // ol-mapbox-style keeps every layer it has styled, with the whole parsed style, in a registry
        // of its own that nothing ever empties - a layer and a style for every record's map opened.
        // amxForgetLayer (added to olms.js) takes this layer's entries out.
        window.olms?.amxForgetLayer?.(layer);

        layer.dispose();
    } catch (e) {
        console.error(e);
    }
}

/**
 * @param {string} elementId
 * @param {boolean} [keepElement] true when the same element is about to host a new map.
 */
function disposeOfMap(elementId, keepElement = false) {
    try {
        // Signal disposed immediately so in-flight tile load closures bail out
        disposedMaps[elementId] = true;

        // Before the registries are cleared below: disposePopover looks the popover up by id.
        disposePopover(elementId);

        // Also stops the geolocation watch, whose callback otherwise keeps running after the map has
        // gone. Kept across a rebuild on the same element: the callback looks the map up by id, so it
        // follows the new map, and setup only restarts it in some cases.
        disposeUserAnimator(elementId, keepElement);

        let map = maps[elementId];
        if (map)
            releaseMap(map, elementId, keepElement);

        clearMapRegistries(elementId);

        disposeMapLayerPopovers();

        const mapPopover = document.getElementById('map_popover_' + elementId);
        if (mapPopover) {
            mapPopover.remove();
        }
    } catch (error) {
        console.error(error);
    }
}

function releaseMap(map, elementId, keepElement) {
    if (map.mapOptions && map.mapOptions.deviceLocInterval)
        clearInterval(map.mapOptions.deviceLocInterval);

    if (map.markerClickKey) {
        ol.Observable.unByKey(map.markerClickKey);
        map.markerClickKey = null;
    }

    // A measuring session holds a keydown listener on the document, which outlives the map
    // unless the session is ended with it. So does an area select.
    endMeasureSession(elementId);
    resetPolygonSelection(elementId, false);
    map.itemPreview?.dispose();

    map.mapToolbar?.dispose();
    map.mapToolbar = null;

    let mapLayer = itemsLayers[elementId];
    if (mapLayer) {
        mapLayer.setSource(null);
        map.removeLayer(mapLayer);
        disposeMapLayer(mapLayer);
        itemsLayers[elementId] = null;
    }

    let mapLinearLayer = linearItemsLayers[elementId];
    if (mapLinearLayer) {
        mapLinearLayer.setSource(null);
        map.removeLayer(mapLinearLayer);
        disposeMapLayer(mapLinearLayer);
        linearItemsLayers[elementId] = null;
    }

    let heatmapLayer = heatmapLayers[elementId];
    if (heatmapLayer) {
        heatmapLayer.layer?.setSource(null);
        map.removeLayer(heatmapLayer.layer);
        disposeMapLayer(heatmapLayer.layer);
        heatmapLayers[elementId] = null;
    }

    disposeMapBootstrapInstances(map);

    // Iterate a copy: removing from an ol.Collection while forEach walks it skips every other item.
    map.getControls().getArray().slice().forEach(control => {
        map.removeControl(control);
    });
    mapControls[elementId] = null;

    let layerSources = itemLayerSources[elementId];
    if (layerSources) {
        if (layerSources.itemsLayerVectorSource)
            layerSources.itemsLayerVectorSource.dispose();

        if (layerSources.itemsLayerClusterSource)
            layerSources.itemsLayerClusterSource.dispose();

        itemLayerSources[elementId] = null;
    }

    // Iterate a copy, as for the controls above.
    map.getLayers().getArray().slice().forEach(layer => {
        if (layer instanceof ol.layer.Group) {
            layer.getLayers().forEach(subLayer => {
                let src = subLayer.getSource && subLayer.getSource();
                if (src) {
                    if (typeof src.clear === 'function') src.clear();
                    if (typeof src.dispose === 'function') src.dispose();
                }
            });
        } else if (layer) {
            let src = layer.getSource && layer.getSource();
            if (src) {
                if (typeof src.clear === 'function') src.clear();
                if (typeof src.dispose === 'function') src.dispose();
            }
        }
        map.removeLayer(layer);
        disposeMapLayer(layer);
    });

    map.setTarget(null);
    map.dispose();

    if (!keepElement) {
        const mapEl = document.getElementById(elementId);
        if (mapEl) {
            mapEl.remove();
        }
    } else {
        // OpenLayers leaves its viewport behind when the target is cleared, and the next map
        // built on this div would be drawn underneath it.
        const mapEl = document.getElementById(elementId);

        mapEl?.querySelectorAll(':scope > .ol-viewport').forEach(viewport => viewport.remove());
    }
}

/**
 * Clears all global state for a map id. Runs even when no map was registered - disposal can arrive
 * while createMap is still awaiting .NET, or after a build that failed part way, and the registries
 * are filled before the map exists. `delete`, not `= null`, so the dictionaries do not grow by one
 * key for every map opened in a session.
 */
function clearMapRegistries(elementId) {
    pendingEditShapeUpdateCounts[elementId] = 0;
    resolvePendingEditShapeUpdates(elementId);
    delete pendingEditShapeUpdateCounts[elementId];
    delete locationParamTimeouts[elementId];
    delete maps[elementId];
    delete editAssetShapesStore[elementId];
    delete loadedShapesItemsId[elementId];
    delete linearItemExtents[elementId];
    delete dotnetObjectRef[elementId];
    delete routePlanningLayer[elementId];
    delete userPositionLayers[elementId];
    delete editShapeLayers[elementId];
    delete editShapeInteractions[elementId];
    delete undoBuffer[elementId];
    delete isMoving[elementId];
    delete itemsLayers[elementId];
    delete linearItemsLayers[elementId];
    delete heatmapLayers[elementId];
    delete mapControls[elementId];
    delete itemLayerSources[elementId];
    delete popovers[elementId];
    delete _lastMoveEndCenter[elementId];
    delete _lastMoveEndZoom[elementId];
    if (_moveEndFeatureTimers[elementId]) {
        clearTimeout(_moveEndFeatureTimers[elementId]);
        delete _moveEndFeatureTimers[elementId];
    }
}

function getTileRequestsForActiveMapSource(zoom, elementId) {
    let map = maps[elementId];
    if (!map)
        throw new DOMException("No Map Found");

    let tileLayerLayer = map.getLayers().item(0);
    let tileLayer = tileLayerLayer.getLayers().item(0);

    if (!tileLayer)
        throw new DOMException("No Tile Layer Found");

    let tileSource = tileLayer.getSource();
    let tileGrid;

    try {
        tileGrid = tileSource.getTileGrid();
        if (!tileGrid)
            tileGrid = Object.values(tileSource.tileGridForProjection)[0];
    } catch {
    }

    if (!tileGrid)
        throw new DOMException("Unable to download map tiles for this source");

    let mapView = map.getView();
    let mapProjection = mapView.getProjection();

    let extent = mapView.calculateExtent(map.getSize());
    let referer = tileSource.get('referer');

    let tileRequests = [];

    tileGrid.forEachTileCoord(extent, zoom, function (tileCoord) {
        let tileUrl = tileSource.getTileUrlFunction()(tileCoord, 1, mapProjection);
        tileRequests.push({
            tileUrl,
            x: tileCoord[2],
            y: tileCoord[1],
            z: tileCoord[0],
            referer: referer
        });
    });

    return tileRequests;
}

function setConfigProjection(projection = 27700) {
    mapDefaults.appWkId = projection * 1;
    // Propagate to any already-created maps so they pick up the new app projection.
    for (const id in maps) {
        const m = maps[id];
        if (m && !disposedMaps[id]) m.appWkId = mapDefaults.appWkId;
    }
}

function setMapSources(allMapSources, projection = 27700) {
    mapDefaults.mapSources = allMapSources;
    mapDefaults.appWkId = projection * 1;
    for (const id in maps) {
        const m = maps[id];
        if (m && !disposedMaps[id]) {
            m.appWkId = mapDefaults.appWkId;
            // A map sent its own sources (the map configuration editor's preview) keeps them.
            if (!m.ownMapSources) {
                m.mapSources = mapDefaults.mapSources;
            }
        }
    }
}

function setEditShapeGeomerty(geoType, shapeMode) {
    editShapeGeometry = geoType
    editShapeMode = shapeMode;
}

function setEditAssetShapes(assetShapes, elementId) {
    if (!assetShapes)
        return;

    editAssetShapesStore[elementId] = JSON.parse(assetShapes)
}

function setAssetInfoObject(assetInfo, elementId) {
    if (!assetInfo)
        return;

    assetInfoObjectStore[elementId] = JSON.parse(assetInfo)
}

function changeEditShapeGeometryType(type, elementId) {
    let map = maps[elementId];
    let inter = editShapeInteractions[elementId]
    if (!map)
        return;

    if (inter)
        map.removeInteraction(inter.drawEditInteraction);

    var shapes = editAssetShapesStore[elementId];
    if (type == "Polygon") {
        shapes[0].type.type = "polygons";
        shapes[0].type.name = "polygons";
        shapes[0].type.isPoligon = true;
        shapes[0].type.isLine = false;
        shapes[0].type.isPoints = false;
    } else if (type == "LineString") {
        shapes[0].type.type = "lines";
        shapes[0].type.name = "lines";
        shapes[0].type.isLine = true;
        shapes[0].type.isPoligon = false;
        shapes[0].type.isPoints = false;
    } else {
        shapes[0].type.type = "points";
        shapes[0].type.name = "points";
        shapes[0].type.isPoints = true;
        shapes[0].type.isPoligon = false;
    }

    map.mapObjects.editAssetShapes = editAssetShapesStore[elementId];
    editShapeGeometry = type;
    populateEditableFeatures(elementId);
    addEditShapeInteraction(map, elementId, type);
}

function changeEditShapeMode(type, elementId) {
    let map = maps[elementId];
    let inter = editShapeInteractions[elementId]
    if (type == "Draw") {
        inter.allowDelete = false;
        inter.selectInteraction.setActive(false);
        inter.modifyInteraction.setActive(false);
        inter.drawEditInteraction.setActive(true);
    } else if (type == "Modify") {
        inter.allowDelete = false;
        //  inter.selectInteraction.setActive(true);
        inter.modifyInteraction.setActive(true);
        inter.drawEditInteraction.setActive(false);
    } else if (type == "Delete") {
        inter.selectInteraction.setActive(true);
        inter.modifyInteraction.setActive(false);
        inter.drawEditInteraction.setActive(false);
        inter.allowDelete = true;
    }
}

function undoDrawing(elementId) {
    let inter = editShapeInteractions[elementId]
    if (inter && inter.isDrawing) {
        inter.drawEditInteraction.removeLastPoint();
    } else {
        const map = maps[elementId];
        const shapeLayer = editShapeLayers[elementId];
        const shape = map.mapObjects.editableFeatures[elementId][0];

        if (!undoBuffer[elementId] || undoBuffer[elementId].length === 0) {
            return;
        }

        const last = undoBuffer[elementId].pop();

        shapeLayer.getSource().removeFeature(last.feature);

        if (shape.editPoints && shape.editPoints.length > 0) {
            shape.editPoints.pop();
        }

        let editFeature = shapeLayer.getSource().getFeatures().find(f => f.get('attributes')?.shapeObject);

        if (editFeature) {
            const geom = editFeature.getGeometry();
            if (geom instanceof ol.geom.LineString) {
                const coords = geom.getCoordinates();
                coords.pop();
                geom.setCoordinates(coords);
            } else if (geom instanceof ol.geom.Polygon) {
                const coords = geom.getCoordinates()[0];
                coords.pop();
                geom.setCoordinates([coords]);
            }
        }
    }
}

function drawEditStart(drawEvent) {
    this.isDrawing = true;
}

var endOfDrawTrigger;

function drawEditFinished(drawEvent, elementId) {
    var map = maps[elementId];
    let editFeatures = editShapeLayers[elementId].getSource().getFeatures();
    var editFeature;
    var shape;
    //var addMultiple = $("#addMultiple");
    //var isAddMultiple = addMultiple.length > 0 && addMultiple.val() === 'true';  

    for (var i = 0; i < editFeatures.length; i++) {
        if (editFeatures[i]?.get('attributes')?.shapeObject !== undefined) {
            editFeature = editFeatures[i];
            shape = editFeature.get('attributes').shapeObject;
            break;
        }
    }

    if (shape === undefined) {
        shape = map.mapObjects.editableFeatures[elementId][0];
        if (editFeatures.length > 0) {
            editFeature = editFeatures[0];
            editFeature.set('attributes', {shapeObject: shape});
        }
    }

    if (shape === undefined)
        return;

    var newCoordinates = shape.type.name == 'points' || shape.type.name == 'text' ? [drawEvent.feature.getGeometry().getCoordinates()] : shape.type.name == 'polygons' ? drawEvent.feature.getGeometry().getCoordinates()[0] : drawEvent.feature.getGeometry().getCoordinates();
    var coordLength = newCoordinates.length;

    if (shape.type.name == 'polygons') {
        coordLength--;
    }

    for (var i = 0; i < coordLength; i++) {
        var featurePoint =  /*shape.type.name == 'points' ? newCoordinates :*/  newCoordinates[i];
        var coords;
        var longLatCoords;

        if (map.appWkId != map.mapOptions.mapSource.wkId) {
            coords = transformCoordsWkId(featurePoint, map.mapOptions.mapSource.wkId, map.appWkId);
            longLatCoords = transformCoordsWkId(featurePoint, map.mapOptions.mapSource.wkId, 4326);
        } else {
            coords = featurePoint;
            longLatCoords = transformCoordsWkId(featurePoint, map.mapOptions.mapSource.wkId, 4326);
        }

        var editPoint = {
            id: -1,
            x: coords[0],
            y: coords[1],
            xLong: longLatCoords[0],
            yLat: longLatCoords[1],
            edit: true,
        };

        var shapeStyle = new ol.style.Style({
            fill: new ol.style.Fill({
                color: [255, 255, 255, 0.2]
            }),
            stroke: new ol.style.Stroke({
                color: [255, 204, 51, 1],
                width: 2
            }),
            image: new ol.style.Circle({
                radius: 7,
                fill: new ol.style.Fill({
                    color: [255, 204, 51, 1],
                })
            }),
        });

        var lineEditPoint = new ol.Feature({
            geometry: new ol.geom.Point([featurePoint[0], featurePoint[1]]),
            attributes: {
                point: editPoint,
            },
            style: shapeStyle
        });

        shape.editPoints.push(editPoint);

        if (editFeature != undefined && (editFeature.getGeometry() instanceof ol.geom.LineString))
            editFeature.getGeometry().appendCoordinate(featurePoint);

        editShapeLayers[elementId].getSource().addFeature(lineEditPoint);
        if (!undoBuffer[elementId]) undoBuffer[elementId] = [];

        undoBuffer[elementId].push({
            feature: lineEditPoint,
            coord: featurePoint
        });
    }

    if (editFeature != undefined && (editFeature.getGeometry() instanceof ol.geom.Polygon || editFeature.getGeometry() instanceof ol.geom.Circle))
        editFeature.getGeometry().setCoordinates([newCoordinates]);

    setTimeout(function () {
        editShapeLayers[elementId].getSource().removeFeature(drawEvent.feature);
    }, 100);

    this.isDrawing = false;
    this.pointCount = 0;

    drawEvent.stopPropagation();
    drawEvent.preventDefault();
}

function finishCurrentDrawing(elementId) {
    let inter = editShapeInteractions[elementId];
    if (inter && inter.drawEditInteraction && inter.drawEditInteraction.getActive()) {
        try {
            inter.drawEditInteraction.finishDrawing();
        } catch (e) {
            // no sketch in progress, nothing to finish
        }
    }
}

function setEditModify(elementId) {
    let inter = editShapeInteractions[elementId];
    inter.modifyInteraction.setActive(true);
    inter.drawEditInteraction.setActive(false);
}

function getMapCenterCoords(elementId) {
    const map = maps[elementId];
    const view = map?.getView();
    const centre = view?.getCenter();
    if (!centre) return null;
    const projection = view.getProjection().getCode();
    const [long, lat] = ol.proj.toLonLat(centre, projection);
    const [configLong, configLat] = transformCoordsWkId(
        centre, getWKIDCodeFromProjection(projection), map.appWkId);
    return {long, lat, configLong, configLat};
}

function getCenter(elementId) {
    let objectRef = dotnetObjectRef[elementId];

    try {
        let map = maps[elementId];
        if (map) {
            let mapView = map.getView();

            let mapCenter = mapView.getCenter();
            if (mapCenter) {
                let mapProjection = getWKIDCodeFromProjection(mapView.getProjection().getCode());
                let mapCenterCoords = ol.proj.toLonLat(mapCenter);

                let configProjectionCoords = transformCoordsWkId(mapCenter, mapProjection, map.appWkId);

                objectRef.invokeMethodAsync('OnCenterPointCaptured', {
                    lat: mapCenterCoords[1],
                    long: mapCenterCoords[0],
                    configLat: configProjectionCoords[1],
                    configLong: configProjectionCoords[0]
                });
            }
        }
    } catch (error) {
    }
}

function disposePopover(elementId) {
    let popover = popovers[elementId];
    if (popover) {
        disposeBootstrapTipInstance(popover);
        popovers[elementId] = undefined;
        MapModalHelper.removeBackdrop();
    }
}

function disposeMapLayerPopovers() {
    // A copy: removing an element takes it out of the live collection, which skipped every other one.
    let popovers = [...document.getElementsByClassName("map-control-popover")];
    if (popovers.length > 0) {
        for (const popover of popovers) {
            // A tip whose popover is disposed later - another map's, or one this runs ahead of - is
            // no longer in <body> for hide() to take its listener off (see disposeBootstrapTipInstance).
            // Emptied, whatever still holds it holds nothing of the map.
            releaseBootstrapTip(popover);
            popover.replaceChildren();
            popover.remove();
        }

        return true;
    }

    return false;
}

// A feature's summary - the HTML its pop-up and the hover preview both show - fetched once.
//
// Rendering it is the slow part of clicking an item: the server loads the record's whole top-level
// DataStore and renders its MAPSUMMARY form. The hover preview asks for it once the pointer has
// rested on an item for a moment, and the click that followed asked for it all over again, so the
// pop-up waited on a second load queued behind the first, on the same circuit and database. The two
// now share one request per item: a click on an item the preview has shown opens its pop-up at once,
// and one made while the preview is still loading waits only for that load. Kept on the map, so a
// map rebuilt after the user has been to edit a record asks again, and only for a short while, so a
// map left open does not go on showing a summary from long ago.
const FEATURE_SUMMARY_KEEP_MS = 30000;
const FEATURE_SUMMARY_KEEP_COUNT = 20;

function loadFeatureSummary(map, elementId, entityKey, itemId) {
    const objectRef = dotnetObjectRef[elementId];
    if (!objectRef)
        return Promise.resolve(null);

    const summaries = map.amxFeatureSummaries ??= new Map();
    const key = `${entityKey}\u0000${itemId}`;
    const now = Date.now();

    const kept = summaries.get(key);
    if (kept && now - kept.at < FEATURE_SUMMARY_KEEP_MS)
        return kept.content;

    // Only the last few, as each is a rendered form and the pointer crosses many items.
    for (const [summaryKey, summary] of summaries) {
        if (now - summary.at >= FEATURE_SUMMARY_KEEP_MS)
            summaries.delete(summaryKey);
    }
    if (summaries.size >= FEATURE_SUMMARY_KEEP_COUNT)
        summaries.delete(summaries.keys().next().value);

    const content = objectRef.invokeMethodAsync('FeatureSummaryContent', entityKey, itemId);
    const entry = {at: now, content};
    summaries.set(key, entry);

    // A load that failed is not kept: the next hover or click asks again.
    content.catch(() => {
        if (summaries.get(key) === entry)
            summaries.delete(key);
    });

    return content;
}

async function goToFeature(feature) {
    const elementId = feature.mapId;
    if (!elementId)
        return;

    disposePopover(elementId);

    let objectRef = dotnetObjectRef[elementId];
    if (!objectRef)
        return;

    const entityKey = feature.entityKey;
    if (!entityKey || entityKey === STREET_MANAGER_ENTITY_KEY)
        return;

    const identifier = feature.itemId;
    if (!identifier)
        return;

    await saveMapPositionData(elementId, 'openfeature');
    await objectRef.invokeMethodAsync('OpenFeature', {
        EntityKey: entityKey,
        Identifier: identifier,
        DisplayName: feature.name ?? feature.labelText ?? identifier
    });
}

// How many of a cluster's features the list offers before it stops and says how many are left.
// Long enough for the overlaps a user is expected to pick through by hand.
const maxClusterListItems = 50;

// The features behind a cluster, as a list to pick from. A cluster can be several items the map
// has grouped because they are close at this zoom, or - once zoomed all the way in, where the
// cluster distance is 0 - items sitting on the very same spot, which no amount of zooming will
// ever separate. Either way the only way to reach the ones underneath is to be shown them.
function buildClusterListContent(features, elementId, isSelectWithMap = false) {
    const wrapper = document.createElement('div');
    wrapper.setAttribute('class', 'map-cluster-list');

    const heading = document.createElement('div');
    heading.setAttribute('class', 'map-cluster-list-title');
    heading.innerText = `${features.length} items here`;
    wrapper.appendChild(heading);

    const list = document.createElement('div');
    list.setAttribute('class', 'map-cluster-list-items');

    const shown = features.slice(0, maxClusterListItems);

    for (const clusterFeature of shown) {
        const attributes = clusterFeature.get('attributes');

        const item = document.createElement('button');
        item.setAttribute('type', 'button');
        item.setAttribute('class', 'map-cluster-list-item');
        item.innerText = attributes.name ?? attributes.labelText ?? attributes.itemId;
        item.addEventListener('click', async (event) => {
            event.preventDefault();

            disposePopover(elementId);

            await saveMapPositionData(elementId, 'openfeature').then(async () => {
                await goToFeature(attributes);
            });
        }, false);

        list.appendChild(item);
    }

    wrapper.appendChild(list);

    if (features.length > shown.length) {
        const more = document.createElement('div');
        more.setAttribute('class', 'map-cluster-list-more');
        more.innerText = `and ${features.length - shown.length} more`;
        wrapper.appendChild(more);
    }

    // Only worth offering where zooming would actually take them apart - items on the same spot
    // stay on the same spot however far in the map goes.
    if (canSeparateClusterByZooming(features)) {
        const zoomButton = document.createElement('button');
        zoomButton.setAttribute('type', 'button');
        zoomButton.setAttribute('class', 'btn btn-sm btn-outline-secondary w-100 mt-2');
        zoomButton.innerText = 'Zoom to these';
        // Looked up when pressed, not closed over: a list kept alive by anything must not keep its map.
        zoomButton.addEventListener('click', (event) => {
            event.preventDefault();

            disposePopover(elementId);

            const map = maps[elementId];
            if (map) {
                zoomToClusterFeatures(map, features);
            }
        }, false);

        wrapper.appendChild(zoomButton);
    }

    return wrapper;
}

// Whether the cluster's features are at more than one place, and so can be told apart by zooming.
function canSeparateClusterByZooming(features) {
    let first = null;

    for (const feature of features) {
        const coordinates = feature.getGeometry()?.getCoordinates?.();
        if (!coordinates) {
            continue;
        }

        if (first === null) {
            first = coordinates;
            continue;
        }

        if (coordinates[0] !== first[0] || coordinates[1] !== first[1]) {
            return true;
        }
    }

    return false;
}

function zoomToClusterFeatures(map, features) {
    let extent = null;

    for (const feature of features) {
        const geometry = feature.getGeometry();
        if (!geometry) {
            continue;
        }

        extent = extent === null
            ? geometry.getExtent().slice()
            : ol.extent.extend(extent, geometry.getExtent());
    }

    if (!extent) {
        return;
    }

    map.getView().fit(extent, {
        size: map.getSize(),
        padding: [60, 60, 60, 60],
        duration: 300,
        // A cluster of points has no width to fit to, so without this the view would go to the
        // maximum zoom the moment two of them nearly touch.
        maxZoom: Math.max(map.getView().getZoom() + 2, 18)
    });
}

function buildPopoverContent(feature, elementId, isSelectWithMap = false, isWhat3Words = false) {
    const featureName = feature.name ?? feature.labelText;
    const dontShowOpenLink = feature.dontShowOpenLink;

    const popoverWrapperDiv = document.createElement("div");
    popoverWrapperDiv.setAttribute('class', 'd-flex flex-column gap-2 w-100 h-100');

    // Marks the popover as something the resource timeline can accept a drag from, so work can be
    // booked straight off the map (the Scheduler Tool). Inert everywhere else: amxTimelineDrag only
    // looks for these once a host has registered a drag source, and no other map has one.
    if (feature.itemId) {
        popoverWrapperDiv.setAttribute('data-tl-newitem', '');
        popoverWrapperDiv.setAttribute('data-tl-item-id', feature.itemId);
    }

    // Feature details
    const featureDetailsWrapper = document.createElement('div');
    popoverWrapperDiv.setAttribute('class', 'h-100 overflow-auto');

    // Feature title
    const titleContent = document.createTextNode(featureName);
    featureDetailsWrapper.appendChild(titleContent);

    popoverWrapperDiv.appendChild(featureDetailsWrapper);

    if (!dontShowOpenLink) {
        // Go to item button
        const goToItemButton = document.createElement("button");
        goToItemButton.setAttribute('class', 'btn btn-primary mt-2 w-100 text-center');
        goToItemButton.innerText = isSelectWithMap ? 'Select' : 'Open';
        goToItemButton.addEventListener('click', async (event) => {
            event.preventDefault();
            await saveMapPositionData(elementId, "openfeature").then(async () => {
                await goToFeature(feature);
            });
        }, false);
        featureDetailsWrapper.appendChild(goToItemButton);
    }

    if (isWhat3Words) {
        const removeWhat3WordsMarker = document.createElement("button");
        removeWhat3WordsMarker.setAttribute('class', 'btn btn-primary mt-2 w-100 text-center');
        removeWhat3WordsMarker.innerText = "Remove what3words marker";
        removeWhat3WordsMarker.addEventListener('click', async (event) => {
            if (showWhat3WordsMarker) {
                showWhat3WordsMarker = false;
                let linearItemsLayer = getLinearItemsLayerUnderlyingSource(elementId);
                if (linearItemsLayer) {
                    let feature = linearItemsLayer.getFeatureById('what-3-words');
                    if (feature) {
                        linearItemsLayer.removeFeature(feature);
                    }
                    disposePopover(elementId);
                }
            }
        }, false);

        featureDetailsWrapper.appendChild(removeWhat3WordsMarker);
    }

    popoverWrapperDiv.appendChild(featureDetailsWrapper);

    return popoverWrapperDiv;
}

function getWKIDCodeFromProjection(projectionText) {
    let textSplit = projectionText.split(':');

    return parseInt(textSplit[1]);
}

async function setupMap(elementId, objectRef, locationData, editAssetShapes) {
    if (!elementId)
        return;

    // BaseMapComponent sends the location data as a .NET stream (DotNetStreamReference) rather than an
    // interop argument, so a large report is not built as one multi-megabyte message on the server.
    if (locationData && typeof locationData.arrayBuffer === 'function') {
        const buffer = await locationData.arrayBuffer();
        locationData = JSON.parse(new TextDecoder().decode(buffer));
    }

    // Clean up any orphaned maps whose DOM elements no longer exist
    // This handles cases where disposeOfMap was never called (e.g. async void Dispose)
    for (let existingId in maps) {
        if (maps[existingId] && existingId !== elementId) {
            let existingEl = document.getElementById(existingId);
            if (!existingEl) {
                try {
                    disposeOfMap(existingId);
                } catch (e) {
                    // Force cleanup of global state even if dispose fails
                    disposedMaps[existingId] = true;
                    maps[existingId] = null;
                    dotnetObjectRef[existingId] = null;
                }
            }
        }
    }

    // A map still registered on this element has to go first, or it is simply overwritten in maps[]
    // and its item sources reused. The component's own rebuild paths dispose it before calling here.
    if (maps[elementId])
        disposeOfMap(elementId, true);

    dotnetObjectRef[elementId] = objectRef;

    setEditAssetShapes(editAssetShapes, elementId);

    await createMap(elementId, locationData);
}

async function createMap(elementId, locationData) {
    if (!elementId)
        return;

    if (!locationData)
        return;

    isNaviagtedForDataView = locationData.isNaviagtedForDataView;
    shownIconAtCentre = false;
    showWhat3WordsMarker = false;

    loadedShapesItemsId[elementId] = new Set();

    try {
        const mapOptions = getMapOptions(locationData);
        const mapObjects = getMapObjects();

        // Use MapStartPosition (country-based default) as the initial center instead of
        // a hardcoded coordinate that would show Africa/Atlantic Ocean.
        let startCenter = [0, 0];
        let startZoom = 5;
        const mapStartPos = locationData.mapStartPosition;
        if (mapStartPos && mapStartPos.item1 != null && mapStartPos.item2 != null) {
            // mapStartPosition is Tuple<lat, lon> — OpenLayers uses [lon, lat]
            const lonLat = [mapStartPos.item2, mapStartPos.item1];
            startCenter = transformCoordsWkId(lonLat, 4326, mapOptions.mapSource.wkId);
        }

        const startView = new ol.View({
            projection: 'EPSG:' + mapOptions.mapSource.wkId,
            center: startCenter,
            zoom: startZoom,
            constrainResolution: false,
            constrainRotation: false, // prevent snap-to-north when compass-driven rotation is near 0
            maxZoom: 23,
            minZoom: 0
        });

        const mapLayerGroup = createMapLayerGroup(mapObjects, mapOptions, elementId);
        const geoLayers = createGeoLayers(mapOptions, mapObjects, null, elementId);

        // Everything here belongs to the base map rather than to the map: these are the layers a
        // change of source replaces, and marking them is what lets it be done without taking the
        // map down - see swapMapSourceLayers.
        markLayersFromMapSource(mapLayerGroup, geoLayers);
        interactionLayer = createInteractionLayer();
        const hasGeoLayers = geoLayers && geoLayers.length > 0;

        const mapLayers = [
            mapLayerGroup, interactionLayer
        ].concat(geoLayers);

        let hasFeatures = false;
        let hideFeaturesUntilZoomedIn = false;
        let mapProjection = getWKIDCodeFromProjection(startView.getProjection().getCode());

        let features = [];
        if (locationData?.itemLocations?.length > 0) {
            hideFeaturesUntilZoomedIn = locationData?.totalLocationCount >= maxNumberOfMapFeatures;

            features = await getFeaturesForData(locationData.itemLocations, mapProjection, elementId, hideFeaturesUntilZoomedIn);

            hasFeatures = true;
        }

        let itemsLayer = createItemsLayer(features, mapOptions, elementId);

        itemsLayers[elementId] = itemsLayer;

        mapLayers.push(itemsLayer);

        if (!hasFeatures) {
            hasFeatures = locationData.isSelectWithMap === true;
        }

        let linearFeatures = await getLinearFeaturesForData(locationData.itemLocations, mapProjection, elementId, hideFeaturesUntilZoomedIn);
        registerLoadedLinearItems(elementId, linearFeatures);
        registerLoadedLocationItems(elementId, locationData.itemLocations);

        let linearItemsLayer = createLinearItemsLayer(linearFeatures, mapOptions, elementId);
        if (linearItemsLayer) {
            linearItemsLayers[elementId] = linearItemsLayer;
            mapLayers.push(linearItemsLayer);
        }

        if (mapOptions.editable) {
            let editShapeLayer = createEditShapeLayer(elementId, mapOptions.mapSource.wkId);
            editShapeLayers[elementId] = editShapeLayer;

            mapLayers.push(editShapeLayer);
        }

        const mapEl = document.getElementById(elementId);
        const map = new ol.Map({
            interactions: ol.interaction.defaults.defaults({dragPan: false, mouseWheelZoom: false}).extend([
                new ol.interaction.DragPan({
                    condition: function (event) {
                        if (map.mapOptions.isFullscreenActive === true || map.mapOptions.disableTwoFingerPan === true)
                            return true;

                        // Require two fingers only for touch; allow mouse drag freely
                        if (event.originalEvent.pointerType === 'touch') {
                            return this.getPointerCount() === 2;
                        }

                        return true;
                    },
                }),
                new ol.interaction.MouseWheelZoom(),
            ]),
            target: mapEl,
            layers: mapLayers,
            view: startView,
        });

        map.clearStoredShapes = true;
        map.hideFeaturesUntilZoomedIn = hideFeaturesUntilZoomedIn;
        map.itemIdsToHide = [];

        map.mapOptions = mapOptions;
        map.mapObjects = mapObjects;
        // The map configuration editor's preview sends the one configuration it shows; every other map
        // offers the site's configurations.
        map.ownMapSources = Array.isArray(locationData?.mapSources);
        map.mapSources = map.ownMapSources ? locationData.mapSources : mapDefaults.mapSources;
        // Capture the app projection per-map so later maps/source inits that overwrite
        // the module-global `appWkId` don't break coordinate transforms for this map.
        map.appWkId = mapDefaults.appWkId;
        // Disposed while this was awaiting .NET, and the element has gone: release the map rather
        // than register one that nothing will ever dispose.
        if (disposedMaps[elementId] && !document.getElementById(elementId)) {
            releaseMap(map, elementId, false);
            clearMapRegistries(elementId);
            return;
        }

        maps[elementId] = map;

        // Labels above the lines and shapes of every layer - see liftMapLabels.
        liftMapLabels(map, elementId);
        delete disposedMaps[elementId];

        setItemsLayerSource(elementId);
        map.once('postrender', () => setItemsLayerSource(elementId));
        watchMapProfile(map, elementId);

        map.on('pointerdrag', () => {
            followUser = false;
        });

        let userPositionData = null;
        if (locationData.loadUsersPositionAtStart === true) {
            await getUserPosition(elementId, false).then((userPosition) => {
                userPositionData = userPosition;

                setUsersPosition(elementId, userPosition, locationData.defaultToUserLocation === true);
            });
        }

        //draw editable features after map loaded this is becasue to get the mapprojection during drawing
        if (mapOptions.editable) {
            addEditShapeInteraction(map, elementId, editShapeGeometry);
        }

        if (hasFeatures) {
            map.itemPreview = attachMapItemPreview(map, {
                id: elementId,
                isSelectWithMap: mapOptions.isSelectWithMap,
                open: attributes => goToFeature(attributes),
                visibleMembers: features => getVisibleClusterFeatures(features, elementId),
                isCoincident: features => !canSeparateClusterByZooming(features),
                highlight: createLineHoverHighlight(map),
                blocked: () => map.mapOptions.editable || map.mapOptions.polygonSelectionEnabled
                    || isMoving[elementId] || isMeasuring(elementId) || !!popovers[elementId]
                    || (typeof isChoosingMapExportArea === 'function' && isChoosingMapExportArea(elementId)),
                load: attributes => loadFeatureSummary(map, elementId, attributes.entityKey, attributes.itemId)
            });
            const popupEl = document.createElement("div");
            popupEl.setAttribute('id', 'map_popover_' + elementId);

            const popup = new ol.Overlay({
                element: popupEl,
                positioning: 'bottom-center',
                stopEvent: false,
            });

            map.addOverlay(popup);

            map.on('click', async function (evt) {
                // Drawing and measuring use clicks to place vertices. Keep assets and clusters
                // under those vertices from opening popups or navigating away during the session.
                if (map.mapOptions.polygonSelectionEnabled || isMeasuring(elementId))
                    return;

                // The same while an export area is being framed: the map is only being looked at
                // through the box, so a press on the dimmed map still pans it but picks nothing.
                // Asked for by name because the export control is a separate script file.
                if (typeof isChoosingMapExportArea === 'function' && isChoosingMapExportArea(elementId))
                    return;

                // What was under the pointer, when more than one thing was: the cluster's own
                // features, so the user can be asked which one they meant.
                let clickedClusterFeatures = null;

                const feature = map.forEachFeatureAtPixel(evt.pixel, function (topLevelFeature) {
                    let feature = topLevelFeature;

                    let features = topLevelFeature.get('features');
                    if (features) {
                        const openable = getVisibleClusterFeatures(features, elementId)
                            .filter((clusterFeature) => clusterFeature.get('attributes')?.itemId !== undefined);

                        // Several items here. Clicking used to open whichever was first, with no
                        // sign the others existed - below zoom 15 it did nothing at all. Now the
                        // cluster itself is what was clicked and the list decides.
                        if (openable.length > 1) {
                            clickedClusterFeatures = openable;

                            return topLevelFeature;
                        }

                        feature = openable[0] ?? features[0];
                    }

                    let featureAttributes = feature.get('attributes');
                    if (featureAttributes === undefined)
                        return undefined;

                    let featureItemId = featureAttributes.itemId;
                    if (featureItemId === undefined && feature.getId() != "what-3-words")
                        return undefined;

                    // A feature from a layer the user has made unselectable is passed over, so
                    // whatever is under it can still be clicked.
                    if (!isAmxLayerFeatureSelectable(feature))
                        return undefined;

                    return feature;
                }, {
                    hitTolerance: 10,  // Increase the tolerance to make the interaction less sensitive
                    // Nothing on a vector tile layer can be picked (its features carry no attributes),
                    // and searching the basemap means every road and symbol within 10px of the click.
                    layerFilter: layer => !(layer instanceof ol.layer.VectorTile)
                });

                disposePopover(elementId);
                disposeMapLayerPopovers();

                if (!feature) {
                    return;
                }

                if (clickedClusterFeatures) {
                    popupEl.innerHTML = '';
                    popup.setPosition(evt.coordinate);

                    let clusterPopover = new bootstrap.Popover(popupEl, {
                        container: 'body',
                        placement: 'top',
                        html: true,
                        customClass: 'map-control-popover',
                        content: buildClusterListContent(clickedClusterFeatures, elementId, mapOptions.isSelectWithMap)
                    });

                    clusterPopover.show();
                    MapModalHelper.showBackdrop(() => {
                        disposePopover(elementId);
                    });

                    popovers[elementId] = clusterPopover;

                    return;
                }

                const featureAttributes = feature.get('attributes');

                // A layer with its pop-up switched off opens the record straight away, as every
                // item does while the map's own pop-ups are off.
                const hasNoRecord = featureAttributes?.entityKey === STREET_MANAGER_ENTITY_KEY;
                if (!hasNoRecord && (!toggleOnOffOpens || !isAmxLayerFeaturePopupShown(feature))) {
                    await goToFeature(featureAttributes);
                } else {
                    popupEl.innerHTML = '';

                    popup.setPosition(evt.coordinate);

                    const isWhat3Words = feature.getId() != undefined && feature.getId() == "what-3-words";
                    if (isWhat3Words) {
                        let popover = new bootstrap.Popover(popupEl, {
                            container: 'body',
                            placement: 'top',
                            html: true,
                            customClass: 'map-control-popover',
                            content: buildPopoverContent(featureAttributes, elementId, mapOptions.isSelectWithMap, isWhat3Words)
                        });

                        popover.show();
                        MapModalHelper.showBackdrop(() => {
                            disposePopover(elementId);
                        });

                        popovers[elementId] = popover;

                        return;
                    }

                    let loadingPopover = new bootstrap.Popover(popupEl, {
                        container: 'body',
                        placement: 'top',
                        html: true,
                        customClass: 'map-control-popover',
                        content: '<p>Loading...</p>'
                    });

                    loadingPopover.show();
                    MapModalHelper.showBackdrop(() => {
                        disposePopover(elementId);
                    });

                    if (dotnetObjectRef[elementId]) {
                        await loadFeatureSummary(map, elementId, featureAttributes.entityKey, featureAttributes.itemId).then((content) => {
                            // Nothing to show, or the map was closed or rebuilt while it loaded - a pop-up
                            // filed after disposeOfMap cleared the registries would keep this map.
                            if (content == null || maps[elementId] !== map)
                                return;

                            const dontShowOpenLink = featureAttributes.dontShowOpenLink;

                            const popoverWrapperDiv = document.createElement("div");
                            popoverWrapperDiv.setAttribute('class', 'd-flex flex-column gap-2 w-100 h-100');

                            // Feature content
                            popoverWrapperDiv.insertAdjacentHTML('beforeend', content);

                            if (!dontShowOpenLink) {
                                // Go to item button
                                const goToItemButton = document.createElement("button");
                                goToItemButton.setAttribute('class', 'btn btn-primary mt-2 w-100 text-center');
                                goToItemButton.innerText = mapOptions.isSelectWithMap ? 'Select' : 'Open';
                                goToItemButton.addEventListener('click', async (event) => {
                                    event.preventDefault();
                                    await saveMapPositionData(elementId, "openfeature").then(async () => {
                                        await goToFeature(featureAttributes);
                                    });
                                }, false);
                                popoverWrapperDiv.appendChild(goToItemButton);
                            }

                            // Disposed before the content pop-up is made on the same element, and its
                            // backdrop removed before that is shown over it, or on a touch device
                            // Bootstrap keeps both (see disposeBootstrapTipInstance). Hiding it was not
                            // enough: its tip stayed in Bootstrap's instance Map.
                            disposeBootstrapTipInstance(loadingPopover);
                            loadingPopover = null;
                            MapModalHelper.removeBackdrop();

                            let contentPopover = new bootstrap.Popover(popupEl, {
                                container: 'body',
                                placement: 'top',
                                html: true,
                                customClass: 'map-control-popover',
                                content: popoverWrapperDiv
                            });

                            contentPopover.show();
                            MapModalHelper.showBackdrop(() => {
                                disposePopover(elementId);
                            });

                            popovers[elementId] = contentPopover;
                        }).finally(() => {
                            // Still up when there was no summary to show, or it did not load.
                            if (loadingPopover)
                                disposeBootstrapTipInstance(loadingPopover);
                        });
                    }
                }
            });
        }

        map.on('movestart', () => {
            isMoving[elementId] = true;

            disposePopover(elementId);
        });

        map.on('moveend', async () => {
            isMoving[elementId] = false;

            await mapMoveEnd(elementId, hasFeatures);

            if (mapOptions.isMapPinned === true) {
                trySaveMapStateParams(elementId);
            }

            if (shownIconAtCentre) {
                addMarkerAtCenter(map, elementId);
            }
        });

        map.getView().on('change:resolution', async (event) => {
            let zoomLevel = map.getView().getZoom();
            await NotifyZoomChangeEvent(elementId, zoomLevel);

            // Update saved tracking zoom level when user zooms during active tracking
            if (map.mapOptions.trackUsersPosition) {
                trackingZoomLevel[elementId] = zoomLevel;
            }

            var view = event.target;

            let myLayers = map.getLayers();
            if (myLayers?.length > 0) {
                myLayers.getArray().map(function (layer) {
                    if (layer instanceof ol.source.Tile) {
                        var source = layer.getSource();
                        if (source instanceof ol.source.Cluster) {
                            if (view.getZoom() >= 15) {
                                source.setDistance(0);
                                return;
                            }

                            source.setDistance(40);
                        }
                    }
                });
            }
        });

        setTimeout(async () => {
            await setPositionData(elementId, locationData, userPositionData, hasFeatures, hasGeoLayers);
        });

        if (editShapeMode == "Modify") {
            setEditModify(elementId);
        }
    } catch (error) {
        // Never silently, and never quietly either. A map that throws while it is being built adds
        // nothing to the page, so the failure is passed back to the component that asked for it -
        // which puts up its failed state rather than leaving a spinner running over nothing.
        console.error('The map could not be built:', elementId, error);

        throw error;
    }
}

function addMarkerAtCenter(map, elementId) {
    const mapView = map.getView();
    const mapProjection = mapView.getProjection();

    if (!map.markerLayer) {
        map.markerLayer = new ol.layer.Vector({
            source: new ol.source.Vector(),
        });
        map.addLayer(map.markerLayer);
    }

    const vectorSource = map.markerLayer.getSource();
    vectorSource.clear();

    const initialCenter = mapView.getCenter();
    const markerFeature = new ol.Feature({
        geometry: new ol.geom.Point(initialCenter)
    });

    const markerStyle = new ol.style.Style({
        image: new ol.style.Circle({
            radius: 15,
            fill: new ol.style.Fill({
                color: 'black'
            }),
            stroke: new ol.style.Stroke({
                color: 'white',
                width: 2,
            }),
        }),
        text: new ol.style.Text({
            text: '+',
            font: 'bold 30px Arial',
            fill: new ol.style.Fill({
                color: 'white',
            }),
            textAlign: 'center',
            textBaseline: 'middle'
        }),
    });

    markerFeature.setStyle(markerStyle);

    vectorSource.addFeature(markerFeature);

    function addMarker(coordinates) {
        vectorSource.removeFeature(markerFeature);
        const markerStyle = new ol.style.Style({
            image: new ol.style.Circle({
                radius: 15,
                fill: new ol.style.Fill({
                    color: 'black'
                }),
                stroke: new ol.style.Stroke({
                    color: 'white',
                    width: 2,
                }),
            }),
            text: new ol.style.Text({
                text: '+',
                font: 'bold 30px Arial',
                fill: new ol.style.Fill({
                    color: 'white',
                }),
                textAlign: 'center',
                textBaseline: 'middle'
            }),
        });

        markerFeature.setStyle(markerStyle);
        markerFeature.getGeometry().setCoordinates(coordinates);
        vectorSource.addFeature(markerFeature);
    }

    addMarker(mapView.getCenter());

    // Called again on every pan while the marker shows: replace the listener rather than stack them.
    if (map.markerClickKey)
        ol.Observable.unByKey(map.markerClickKey);

    map.markerClickKey = map.on('singleclick', function (event) {
        map.forEachFeatureAtPixel(event.pixel, function (feature) {
            if (feature === markerFeature) {
                // Trigger method in What3WordsMapControl
                const control = mapControls[elementId].what3WordsControl;
                if (control && typeof control.what3words === 'function') {
                    control.what3words(what3WordslatAndLong);
                }
            }
        });
    });

    const [lon, lat] = ol.proj.toLonLat(mapView.getCenter(), mapProjection);
    what3WordslatAndLong = [lon, lat];
}

function removeMarkerAtCenter(map) {
    if (map.markerClickKey) {
        ol.Observable.unByKey(map.markerClickKey);
        map.markerClickKey = null;
    }

    if (map.markerLayer) {

        map.removeLayer(map.markerLayer);
        map.markerLayer = null;
    } else {
        console.log('No marker layer found.');
    }
}

// Subtle, non-interactive marker used to indicate a search result location.
// Renders a red ring with a transparent centre (so the map underneath stays visible)
// plus a small solid red centre dot. Deliberately has NO click handler and uses a
// dedicated layer so clicks pass through to any underlying map features.
function searchMarkerStyle() {
    return [
        new ol.style.Style({
            image: new ol.style.Circle({
                radius: 12,
                fill: null,
                stroke: new ol.style.Stroke({
                    color: 'rgba(220, 20, 20, 0.9)',
                    width: 2.5
                })
            })
        }),
        new ol.style.Style({
            image: new ol.style.Circle({
                radius: 2.5,
                fill: new ol.style.Fill({
                    color: 'rgba(220, 20, 20, 0.95)'
                })
            })
        })
    ];
}

function addSearchMarker(map, coordinates) {
    if (!map.searchMarkerLayer) {
        map.searchMarkerLayer = new ol.layer.Vector({
            source: new ol.source.Vector(),
            // Keep the search marker above other layers but non-interactive.
            zIndex: 9999
        });
        map.addLayer(map.searchMarkerLayer);
    }

    const vectorSource = map.searchMarkerLayer.getSource();
    vectorSource.clear();

    const markerFeature = new ol.Feature({
        geometry: new ol.geom.Point(coordinates ?? map.getView().getCenter())
    });
    markerFeature.setStyle(searchMarkerStyle());
    vectorSource.addFeature(markerFeature);
}

function removeSearchMarker(map) {
    if (map.searchMarkerLayer) {
        map.removeLayer(map.searchMarkerLayer);
        map.searchMarkerLayer = null;
    }
}

// Shows a small "Hide search marker" link at the top-centre of the map. Clicking it
// removes the search marker and hides the link. Idempotent - reuses an existing link.
function showHideSearchMarkerLink(map, elementId) {
    const mapEl = document.getElementById(elementId);
    if (!mapEl)
        return;

    let link = mapEl.querySelector('.hide-search-marker-link');
    if (!link) {
        link = document.createElement('a');
        link.className = 'hide-search-marker-link';
        link.href = '#';
        link.textContent = 'Hide search marker';
        link.style.position = 'absolute';
        // Under the map toolbar rather than behind it - see --map-toolbar-offset in app.css.
        link.style.top = 'var(--map-toolbar-offset, 8px)';
        link.style.left = '50%';
        link.style.transform = 'translateX(-50%)';
        link.style.zIndex = '1000';
        link.style.padding = '2px 10px';
        link.style.background = 'rgba(255, 255, 255, 0.9)';
        link.style.borderRadius = '4px';
        link.style.fontSize = '12px';
        link.style.color = '#dc1414';
        link.style.textDecoration = 'underline';
        link.style.cursor = 'pointer';
        link.style.boxShadow = '0 1px 3px rgba(0,0,0,0.3)';
        link.addEventListener('click', function (e) {
            e.preventDefault();
            removeSearchMarker(map);
            hideHideSearchMarkerLink(map, elementId);
        });
        mapEl.appendChild(link);
    }
    link.style.display = 'block';
}

function hideHideSearchMarkerLink(map, elementId) {
    const mapEl = document.getElementById(elementId);
    if (!mapEl)
        return;
    const link = mapEl.querySelector('.hide-search-marker-link');
    if (link)
        link.style.display = 'none';
}

function createInteractionLayer() {
    const source = new ol.source.Vector();
    const newSource = new ol.layer.Vector({
        source: source,
        style: new ol.style.Style({
            fill: new ol.style.Fill({
                color: [255, 255, 255, 0.2]
            }),
            stroke: new ol.style.Stroke({
                color: [234, 44, 11, 1],//ffcc33
                width: 2
            }),
            image: new ol.style.Circle({
                radius: 7,
                fill: new ol.style.Fill({
                    color: [234, 44, 11, 1]
                })
            }),
            //feature: features
        }),
    });
    newSource.setZIndex(9999);
    return newSource;
}

function createEditShapeLayer(elementId, wikId) {
    const shapeStyle = new ol.style.Style({
        fill: new ol.style.Fill({
            color: [255, 204, 51, 0.3] //orange
        }),
        stroke: new ol.style.Stroke({
            color: [255, 204, 51, 1],
            width: 2
        }),
        image: new ol.style.Circle({
            radius: 7,
            fill: new ol.style.Fill({
                color: [255, 204, 51, 1]
            })
        }),
    });
    // let map = maps[elementId];
    //const mapProjection = getWKIDCodeFromProjection(map.getView().getProjection().getCode());
    let attr = {mapOptionsWkId: wikId};
    var newfeatures = [];
    let editableFeatures = editAssetShapesStore[elementId];
    if (editableFeatures) {
        for (let i = 0; i < editableFeatures.length; i++) {
            const editShape = editableFeatures[i];
            var attributes = editShape.attributes != null ? editShape.attributes : attr;
            if (editShape.attributes != null && attr != null)
                attributes = Object.assign(attributes, attr);
            var features = [];
            switch (editShape.type.name) {
                case 'points':
                    features = createEditPoint(editShape, shapeStyle, attributes);
                    break;
                case 'lines':
                    features = createEditLine(editShape, shapeStyle, attributes);
                    break;
                case 'text':
                    features = createEditPoint(editShape, shapeStyle, attributes);
                    break;
                case 'polygons':
                    features = createEditPolygon(editShape, shapeStyle, attributes);
                    break;
                case 'box':
                    features = createEditPolygon(editShape, shapeStyle, attributes);
                    break;
                case 'arrow':
                    features = createEditLine(editShape, shapeStyle, attributes);
                    break;
                case 'circle':
                    features = createEditCircle(editShape, shapeStyle, attributes);
                    break;
                case 'line':
                    features = createEditLine(editShape, shapeStyle, attributes);
                    break;
            }
            features.forEach(feature => {
                if (feature instanceof ol.Feature) {
                    newfeatures.push(feature);
                }
            });
        }
    }
    const source = new ol.source.Vector({features: newfeatures});
    const layer = new ol.layer.VectorImage({
        source: source,
        style: function (feature, resolution) {
            var attributes = feature.get('attributes');
            if (attributes !== undefined && attributes !== null && attributes.mapTextConfig !== undefined && attributes.mapTextConfig !== null) {
                return getMapTextConfigStyle(attributes.mapTextConfig, resolution, feature);
            } else if (attributes !== undefined && attributes !== null && attributes.markup) {
                return getMarkupStyle(attributes, feature);
            }

            return shapeStyle;
        },
        //features: features
    });
    return layer;
}

function populateEditableFeatures(elementId) {
    try {
        let map = maps[elementId];
        const mapProjection = getWKIDCodeFromProjection(map.getView().getProjection().getCode());
        let attr = {mapOptionsWkId: mapProjection};

        const shapeStyle = new ol.style.Style({
            fill: new ol.style.Fill({
                color: [255, 204, 51, 0.3]
            }),
            stroke: new ol.style.Stroke({
                color: [255, 204, 51, 1],
                width: 2
            }),
            image: new ol.style.Circle({
                radius: 7,
                fill: new ol.style.Fill({
                    color: [255, 204, 51, 1]
                })
            }),
        });

        var features = [];

        //**** create editable features here
        let editableFeatures = editAssetShapesStore[elementId];
        if (editableFeatures) {
            for (let i = 0; i < editableFeatures.length; i++) {
                const editShape = editableFeatures[i];
                var attributes = editShape.attributes != null ? editShape.attributes : attr;
                if (editShape.attributes != null && attr != null)
                    attributes = Object.assign(attributes, attr);
                switch (editShape.type.type) { //shape.type.name not getting the right value after jsonConvert
                    case 'points':
                    case 'text':
                        //features.push may be need if mixed shapes are allowed to check with Will T
                        features = createEditPoint(editShape, shapeStyle, attributes);
                        break;
                    case 'lines':
                    case 'arrow':
                    case 'line':
                        features = createEditLine(editShape, shapeStyle, attributes);
                        break;
                    case 'polygons':
                    case 'box':
                        features = createEditPolygon(editShape, shapeStyle, attributes);
                        break;
                    case 'circle':
                        features = createEditCircle(editShape, shapeStyle, attributes);
                        break;
                    default:
                        break;
                }
            }
        }

        if (features?.length > 0) {
            let editShapeLayer = getEditShapeLayerUnderlyingSource(elementId);
            if (editShapeLayer) {
                editShapeLayer.clear();
                editShapeLayer.addFeatures(features);
            }
        }
    } catch (e) {
    }
}

function addEditShapeInteraction(map, elementId, editShapeGeometry) {
    let layer = editShapeLayers[elementId];
    if (!map || !layer)
        return;

    let mapProjection = getWKIDCodeFromProjection(map.getView().getProjection().getCode());
    let editableFeatures = editAssetShapesStore[elementId];
    if (!editableFeatures || editableFeatures.length === 0)
        return;

    let firstEditableFeature = editableFeatures[0];
    let inter = {};
    /******* Select Interaction ****/
    inter.isDrawing = false;
    inter.pointCount = 0;
    inter.selectInteraction = new ol.interaction.Select({
        layers: [layer],
        multi: true,
        hitTolerance: 7,
        style: new ol.style.Style({
            fill: new ol.style.Fill({
                color: [255, 255, 255, 0.2]
            }),
            stroke: new ol.style.Stroke({
                color: [255, 204, 51, 1],
                width: 2
            }),
            image: new ol.style.Circle({
                radius: 7,
                fill: new ol.style.Fill({
                    color: [0, 153, 255, 1]
                })
            })
        })
    });

    inter.selectInteraction.on('select', function (e) {
        const selectedFeatures = e.target.getFeatures().getArray();

        if (inter.allowDelete && selectedFeatures.length > 0) {
            let editPoint;
            let editShape;
            for (var i = 0; i < selectedFeatures.length; i++) {
                let attr = selectedFeatures[i]?.get('attributes');
                if (attr?.point !== null && attr?.point !== undefined)
                    editPoint = selectedFeatures[i];
                if (attr?.shapeObject !== null && attr?.shapeObject !== undefined)
                    editShape = selectedFeatures[i];
            }

            if (editShape === undefined) {
                const editLayerFeatures = layer.getSource().getFeatures();
                for (i = 0; i < editLayerFeatures.length; i++) {
                    if (editLayerFeatures[i]?.get('attributes')?.shapeObject !== null
                        && editLayerFeatures[i]?.get('attributes')?.shapeObject !== undefined) {
                        editShape = editLayerFeatures[i];
                        break;
                    }
                }
            }

            deleteShapePoint(editPoint, editShape, firstEditableFeature, elementId);
        }
    });
    /******* End of select Interaction***/

    /***** Modify Interaction  */
    inter.modifyInteraction = new ol.interaction.Modify({
        source: layer.getSource(),
        insertVertexCondition: function (event) {
            var features = layer.getSource().getFeaturesAtCoordinate(event.coordinate);
            if (features.length > 0) {
                var attributes = features[0].get('attributes');
                if (attributes !== undefined && attributes !== null && (attributes.markupType === 'arrow' || attributes.markupType === 'box' || attributes.markupType === 'circle' || attributes.markupType === 'line' || attributes.mapTextConfig)) {
                    return false;
                }
            } else {
                if (map.mapOptions.annotationMode)
                    return false;
                else
                    return true;
            }

            return true;
        }
    });

    var clonedGeometry;
    inter.modifyInteraction.on('modifystart', function (event) {
        if (event.features.getLength() > 0 && (event.features.item(0).get('attributes')?.markup || event.features.item(0).get('attributes')?.mapTextConfig)) {
            clonedGeometry = event.features.item(0).getGeometry().clone();
        }
    })

    inter.modifyInteraction.on('modifyend', function (event) {
        inter.dragMarkerMoving = true; //added to disable popups while dragging, piggibacks on drag marker boolean value
    });

    inter.modifyInteraction.on('modifyend', function (event) {
        const editFeatures = map.getFeaturesAtPixel(event.mapBrowserEvent.pixel, {
            layerFilter: function (flayer) {
                return flayer == layer;
            }
        });

        if (event.features.getLength() > 0 && (event.features.item(0).get('attributes')?.markup)) {
            if (event.features.item(0).get('attributes')?.markupType === 'box') {
                var coordinates = event.features.item(0).getGeometry().getCoordinates()[0];
                var oldCoordinates = clonedGeometry.getCoordinates()[0];
                var index;

                for (var i = 0; i <= 3; i++) //no point in checking the last cooridinate as it will match the 0 one
                {
                    if (coordinates[i][0] !== oldCoordinates[i][0] && coordinates[i][1] !== oldCoordinates[i][1]) //check if this is the modified point
                    {
                        index = i;
                        break;
                    }
                }

                if (index === 0) {
                    coordinates[3][0] = coordinates[0][0];
                    coordinates[1][1] = coordinates[0][1];

                } else if (index === 3) {
                    coordinates[0][0] = coordinates[3][0];
                    coordinates[4][0] = coordinates[3][0];
                    coordinates[2][1] = coordinates[3][1];

                } else if (index === 2) {
                    coordinates[3][1] = coordinates[2][1];
                    coordinates[1][0] = coordinates[2][0];

                } else {
                    coordinates[0][1] = coordinates[1][1];
                    coordinates[4][1] = coordinates[1][1];
                    coordinates[2][0] = coordinates[1][0];
                }
                event.features.item(0).getGeometry().setCoordinates([coordinates]);

            } else if (event.features.item(0).get('attributes').markupType === 'circle') {
                event.features.item(0).get('attributes').radius = event.features.item(0).getGeometry().getRadius();
            }
            clonedGeometry = undefined;
            return;
        }


        var baseEditFeature;


        if (editFeatures != null && editFeatures.length > 1) {
            for (var i = 0; i < editFeatures.length; i++) {
                if (editFeatures[i].get('attributes').shapeObject != undefined) {
                    baseEditFeature = editFeatures[i];
                    break;
                }
            }

            for (var i = 0; i < editFeatures.length; i++) {
                if (editFeatures[i].get('attributes').point != undefined)
                    if (inter.allowDelete) {
                        deleteShapePoint(editFeatures[i], baseEditFeature, event.mapBrowserEvent.pixel, firstEditableFeature, elementId);
                    } else {

                        shapePointChange(editFeatures[i], mapProjection, elementId);
                    }
            }
        } else {
            const selectedFeatures = layer.getSource().getFeatures();
            for (let j = 0; j < selectedFeatures.length; j++) {
                if (selectedFeatures[j].get('attributes')?.shapeObject != undefined) {
                    baseEditFeature = selectedFeatures[j];
                    break;
                }
            }
            if (editFeatures.length > 0 && editFeatures[0].get('attributes')?.point != undefined) {
                if (inter.allowDelete)
                    deleteShapePoint(editFeatures[0], baseEditFeature, event.mapBrowserEvent.pixel, firstEditableFeature, elementId);
                else
                    shapePointChange(editFeatures[0], mapProjection, elementId);
            } else if (baseEditFeature !== undefined) {
                shapeChanged(baseEditFeature, mapProjection, elementId);
            }
        }
        inter.selectInteraction.getFeatures().clear();
        inter.dragMarkerMoving = false; //added to disable popups while dragging, piggibacks on drag marker boolean value

    });
    map.addInteraction(inter.selectInteraction);
    map.addInteraction(inter.modifyInteraction);
    /***** End of Modify Interaction  */

    /***** Draw Interaction  */
    inter.drawEditInteraction = new ol.interaction.Draw({
        source: layer.getSource(),
        type: editShapeGeometry ?? "LineString"
    });
    inter.drawEditInteraction.on('drawstart', drawEditStart);
    inter.drawEditInteraction.on('drawend', function (e) {
        drawEditFinished(e, elementId)
    });
    map.addInteraction(inter.drawEditInteraction);
    /***** End of Draw Interaction  */
    if (map.mapObjects.editableFeatures[elementId][0].editPoints.length > 0 && map.mapObjects.editableFeatures[elementId][0].type.name === 'polygons') {
        inter.drawEditInteraction.setActive(false);
    } else {
        inter.selectInteraction.setActive(false);
        inter.modifyInteraction.setActive(false);
    }
    editShapeInteractions[elementId] = inter;
}

function checkIfPointIsInExtent(pointCoords, elementId) {
    let map = maps[elementId];
    const extent = map.getView().calculateExtent(map.getSize());
    return pointCoords[0] < extent[0] || pointCoords[0] > extent[2] || pointCoords[1] < extent[1] || pointCoords[1] > extent[3];
}

function shapeChanged(feature, projectionWkId, elementId) {
    scheduleEditShapeUpdate(elementId, function () {
        if (feature.get('attributes').shapeObject != undefined) {
            var shape = feature.get('attributes').shapeObject;
            var featureCoords;
            var coordsLength;
            var map = maps[elementId];
            switch (shape.type.type) {
                case 'points':
                    featureCoords = [feature.getGeometry().getCoordinates()];
                    coords = featureCoords.length;
                    break;
                case 'polygons':
                    featureCoords = feature.getGeometry().getCoordinates()[0];
                    coordsLength = featureCoords.length - 1;
                    break;
                default:
                    featureCoords = feature.getGeometry().getCoordinates();
                    coordsLength = featureCoords.length;
            }
            let editLayer = editShapeLayers[elementId];
            for (var i = 0; i < coordsLength; i++) {
                featurePoint = featureCoords[i];

                //ignore non-visible points they can't be the currently added point if they're not visible
                if (checkIfPointIsInExtent(featurePoint, elementId))
                    continue;

                var featuresAtPoint = map.getFeaturesAtPixel(map.getPixelFromCoordinate(featurePoint), {
                    layerFilter: function (layer) {
                        return layer == editLayer;
                    },
                    hitTolerance: 0
                });
                //check if the line is the only feature at point
                if (featuresAtPoint != null && featuresAtPoint.length == 1) {
                    var coords;
                    var longLatCoords;
                    if (map.appWkId != projectionWkId) {
                        coords = transformCoordsWkId(featurePoint, projectionWkId, map.appWkId);
                        longLatCoords = transformCoordsWkId(featurePoint, projectionWkId, 4326);
                    } else {
                        coords = featurePoint;
                        longLatCoords = transformCoordsWkId(featurePoint, projectionWkId, 4326);
                    }

                    var editPoint = {
                        id: -1,
                        x: coords[0],
                        y: coords[1],
                        xLong: longLatCoords[0],
                        yLat: longLatCoords[1],
                        edit: true,
                    };

                    var shapeStyle = new ol.style.Style({
                        fill: new ol.style.Fill({
                            color: [255, 255, 255, 0.2]
                        }),
                        stroke: new ol.style.Stroke({
                            color: [255, 204, 51, 1],
                            width: 2
                        }),
                        image: new ol.style.Circle({
                            radius: 7,
                            fill: new ol.style.Fill({
                                color: [255, 204, 51, 1],
                            })
                        }),
                    });


                    var lineEditPoint = new ol.Feature({
                        geometry: new ol.geom.Point([featurePoint[0], featurePoint[1]]),
                        attributes: {
                            point: editPoint,
                        },
                        style: shapeStyle
                    });

                    shape.editPoints.splice(i, 0, editPoint);
                    editLayer.getSource().addFeature(lineEditPoint);
                }

            }

        }
        /*** TO BE DONE important*/
        /*$('#' + containerId).trigger('shapeModified', { editableFeatures: mapObjects.editableFeatures });*/

    }, 100);
}

function getEditShapePoints(elementId) {
    return JSON.stringify(editAssetShapesStore[elementId]);
}

function shapePointChange(feature, projectionWkId, elementId) {
    //var feature = this;
    if (feature.get('attributes').point != undefined) {

        const editPoint = feature.get('attributes').point;
        let linePointCoords;
        let coords;
        let longLatCoords;

        const map = maps[elementId];
        const appWkId = map ? map.appWkId : mapDefaults.appWkId;

        if (appWkId != projectionWkId) {
            linePointCoords = feature.getGeometry().getCoordinates();
            coords = transformCoordsWkId(linePointCoords, projectionWkId, appWkId);
            longLatCoords = transformCoordsWkId(linePointCoords, projectionWkId, 4326);

            editPoint.x = coords[0];
            editPoint.y = coords[1];
            editPoint.xLong = longLatCoords[0];
            editPoint.yLat = longLatCoords[1];
            editPoint.edit = true;
        } else {
            linePointCoords = feature.getGeometry().getCoordinates();
            coords = linePointCoords;
            longLatCoords = transformCoordsWkId(linePointCoords, projectionWkId, 4326);

            editPoint.x = coords[0];
            editPoint.y = coords[1];
            editPoint.xLong = longLatCoords[0];
            editPoint.yLat = longLatCoords[1];
            editPoint.edit = true;
        }
        // const index = mapObjects.editableFeatures[0].editPoints.indexOf(editPoint);

    }
    /**** TO BE DONE important** */
    //$('#' + containerId).trigger('shapeModified', { editableFeatures: mapObjects.editableFeatures });
}

function deleteShapePoint(pointFeature, baseEditFeature, firstEditableFeature, elementId) {
    let inter = editShapeInteractions[elementId];
    let editLayer = editShapeLayers[elementId];
    const shapeType = firstEditableFeature.type.type;
    inter.selectInteraction.getFeatures().clear();

    const editPoint = pointFeature.get('attributes')?.point;
    if (editPoint) {
        const index = firstEditableFeature.editPoints.indexOf(editPoint);
        if (index != -1) {
            firstEditableFeature.editPoints.splice(index, 1);
        }
        editLayer.getSource().removeFeature(pointFeature);

        if (shapeType !== 'points') {
            const coordinates = baseEditFeature.getGeometry().getCoordinates();

            if (shapeType === 'polygons') {
                coordinates[0].splice(index, 1);

                if (index === 0) {
                    coordinates[0][coordinates[0].length - 1] = [coordinates[0][0][0], coordinates[0][0][1]];
                }

            } else if (shapeType === 'lines') {
                coordinates.splice(index, 1);
            }

            baseEditFeature.getGeometry().setCoordinates(coordinates);
        }
    }
    /* TO BE DONE Important *** /
    /*$('#' + containerId).trigger('shapeModified', { editableFeatures: mapObjects.editableFeatures });*/
}

// Tells the map component the zoom (ZoomChangeEvent), which it decides the "zoom in to see items"
// message from. The view reports a new resolution on every frame of a zoom, and each report was a
// call into .NET and a render of the component - most of what a zoom sent to the server. Now one
// call is in flight at a time: zooms reported meanwhile fold into the latest, which goes as soon as
// the call returns. The component acts only on the zoom it was last given, so it ends where it did
// before, and a caller that awaits this returns once the latest zoom has been told.
async function NotifyZoomChangeEvent(elementId, zoomLevel) {
    const map = maps[elementId];
    if (!map)
        return;

    if (isSelectWithMapSearchActive(map))
        return;

    if (!dotnetObjectRef[elementId])
        return;

    const notify = map.amxZoomNotify ??= {sending: null, next: undefined};
    notify.next = zoomLevel;
    if (!notify.sending) {
        notify.sending = sendLatestZoom(elementId, map, notify);
    }

    return notify.sending;
}

async function sendLatestZoom(elementId, map, notify) {
    // Nothing is sent before the caller has kept this call as the one in flight.
    await null;

    let failure = null;
    try {
        while (notify.next !== undefined) {
            const zoomLevel = notify.next;
            notify.next = undefined;

            const objectRef = dotnetObjectRef[elementId];
            if (maps[elementId] !== map || !objectRef || isSelectWithMapSearchActive(map))
                continue;

            try {
                await objectRef.invokeMethodAsync('ZoomChangeEvent', zoomLevel);
                failure = null;
            } catch (error) {
                failure = error;
            }
        }
    } finally {
        notify.sending = null;
    }

    if (failure) {
        throw failure;
    }
}

function isSelectWithMapSearchActive(map) {
    return map?.mapOptions?.isSelectWithMap === true
        && (map?.mapOptions?.selectWithMapSearchTerm?.trim()?.length ?? 0) > 0;
}

// The asset picker opens on an empty map and fills in once the user has searched. A different
// thing entirely from a map with more items than it can draw at once, which used to be lumped in
// with it - see getItemsLayerStyle.
//
// Not every picker opens empty, though. The ones that pick from what is around the user - asset,
// parent and multi-asset selection, see SelectAssetWithMap - are built with forceShowLocalAssets,
// which is what has the map fetch the local assets in view on every pan, at any zoom and with or
// without a search. Those have something to draw before the first search, and calling them
// "awaiting" one was why GetAdditionalShapes came back full of shapes and the map stayed empty.
function isAwaitingSelectWithMapSearch(map) {
    if (map?.mapOptions?.isSelectWithMap !== true || isSelectWithMapSearchActive(map)) {
        return false;
    }

    return map.mapOptions.forceShowLocalAssets !== true;
}

async function AddMapControls(elementId, hasFeatures, isMapControlsShown) {
    let map = maps[elementId];
    if (!map)
        return;

    try {
        let controls = {};

        // The labelled toolbar across the top of the map (mapToolbar.js). Built before any control,
        // because each one is handed a slot in it - or the body of one of its menus - as its
        // `target`, rather than being left to OpenLayers to stack down the left of the map.
        const toolbar = new MapToolbar(map, elementId);
        map.mapToolbar = toolbar;

        // The bottom-right corner: the feature counts, and beside them the navigation cluster -
        // zoom in, the zoom level and zoom out, and above those zoom to fit, full screen and the
        // location controls. One stack, so nothing in it has to know the width of anything beside
        // it. In the stop-event container with the map's other controls, so pressing one of them is
        // held back from the map: hung on the viewport, a press on a corner button also landed on
        // the map underneath it - a measuring point, a polygon vertex or a feature click.
        const cornerControls = document.createElement('div');
        cornerControls.setAttribute('class', 'map-corner-controls');

        const cornerBottom = document.createElement('div');
        cornerBottom.setAttribute('class', 'map-corner-bottom');

        cornerControls.append(cornerBottom);
        (map.getOverlayContainerStopEvent?.() ?? map.getViewport()).appendChild(cornerControls);

        const nav = createMapNavCluster(map, cornerBottom);
        toolbar.setNavigation(nav.nav, cornerBottom);
        const userLocationContainer = nav.tools;

        // The route builder overlay's map shows the stops of a route that has already been
        // planned (RouteBuilderOverlay - not the older routePlannerEnabled planner below), so
        // the controls that change which items are drawn have nothing left to choose - and
        // filtering one out would leave the route running through a stop that is no longer on the
        // map. They are left off that map; see ItemLocationViewModel.IsRouteBuilder.
        const isRouteBuilder = map.mapOptions?.isRouteBuilder === true;

        controls.zoomLevelControl = new ZoomLevelControl({
            elementId: elementId,
            target: nav.zoom
        });
        map.addControl(controls.zoomLevelControl);

        controls.toggleMapTools = new toggleMapTools({
            mapId: elementId,
            target: toolbar.target('collapse'),
            showControl: () => {
                return true;
            },
            updateMapControlsPreference: async (showControls) => {
                return await updateMapControlsPreference(elementId, showControls);
            },
            isControlsShown: isMapControlsShown
        });
        map.addControl(controls.toggleMapTools);

        // The layers control is also where the base map is chosen, so it is offered on a map whose
        // current source has no overlay layers of its own as long as there is another base map to
        // move to. Gated on the layers alone, a source configured without any took the control off
        // the map - and with it the only way back to a source that has some.
        //
        // On a map drawn from the site's own configurations it is always offered: the panel is also
        // where a layer is added to the map. A map handed its own sources - the configuration
        // editor's preview - shows what it was given and nothing more.
        let layerArray = map.mapOptions?.mapSource?.layers;
        let hasBaseMapChoice = getMapSourcesForModal(elementId).length > 1;

        if (layerArray?.length > 0 || hasBaseMapChoice || !map.ownMapSources) {
            controls.mapLayerControlControl = new MapLayerControlControl({
                map: map,
                elementId: elementId,
                target: toolbar.target('layers'),
                getObjectRef: () => dotnetObjectRef[elementId]
            });
            map.addControl(controls.mapLayerControlControl);
        }

        // The user's saved profiles of the map - its layers and its settings together. Not on a map
        // handed its own sources (the configuration editor's preview), which shows what it was
        // given rather than how this user last left their maps.
        if (!map.ownMapSources) {
            controls.mapProfileControl = new MapProfileControl({
                map: map,
                elementId: elementId,
                target: toolbar.target('profile'),
                getObjectRef: () => dotnetObjectRef[elementId]
            });
            map.addControl(controls.mapProfileControl);
        }

        // How the map draws what it shows - on the bar, after Export.
        controls.mapSettingsControl = new MapSettingsControl({
            map: map,
            elementId: elementId,
            target: toolbar.target('settings'),
            getObjectRef: () => dotnetObjectRef[elementId]
        });
        map.addControl(controls.mapSettingsControl);

        if (!map.mapOptions.showEditOptions && !map.mapOptions.isSelectWithMap && map.mapOptions.canGoFullscreen) {
            controls.fullscreenControl = new fullscreenControl({
                elementId: elementId,
                target: nav.tools,
                toggleFullscreen: async () => {
                    return await toggleFullscreen(elementId);
                }
            });
            map.addControl(controls.fullscreenControl);
        }

        // Street View at the map's centre, in a popup. Only where a Google Street View key is set.
        if (map.mapOptions.showStreetView && !map.mapOptions.showEditOptions && !map.mapOptions.isSelectWithMap) {
            controls.streetViewControl = new streetViewControl({
                elementId: elementId,
                target: toolbar.target('streetview'),
                getMap: () => map,
                getObjectRef: () => dotnetObjectRef[elementId]
            });
            map.addControl(controls.streetViewControl);
        }

        if (map.mapOptions.showMapPin !== undefined && map.mapOptions.showMapPin === true) {
            controls.pinMapControlControl = new PinMapControl({
                mapId: elementId,
                target: toolbar.target('pin'),
                isMapPinned: () => {
                    return map.mapOptions.isMapPinned;
                },
                toggleMapPinned: async () => {
                    map.mapOptions.isMapPinned = !map.mapOptions.isMapPinned;

                    await saveMapPositionData(elementId, "pinclick");
                }
            });

            map.addControl(controls.pinMapControlControl);
        }
        // What3Words has no control of its own on the map - it is one of the searches in
        // the map search modal, which drives this helper through the *FromModal functions.
        controls.what3WordsControl = new What3WordsMapControl({
            mapId: elementId,
            map: map,
            apiKey: "4ZNFPOCQ",
            target: null,
            objectRef: dotnetObjectRef[elementId],
            getObjectRef: () => dotnetObjectRef[elementId],
            addMarker: () => {
                if (!shownIconAtCentre) {
                    shownIconAtCentre = true;
                    addMarkerAtCenter(map, elementId);
                }
            },
            removeMarker: () => {
                if (shownIconAtCentre) {
                    shownIconAtCentre = false;
                    removeMarkerAtCenter(map, elementId);
                }
            },
            setSearchWhat3WordsPosition: async (res) => {
                if (res && res.XCoordinate && res.YCoordinate) {
                    const wkId = res.CoordinateType?.split(':')[1];
                    // var zoom = 18;
                    let zoomLevel = 18;
                    let sourceCoordinates = [res.XCoordinate, res.YCoordinate];
                    if (wkId != map.mapOptions.mapSource.wkId) {
                        sourceCoordinates = transformCoordsWkId(sourceCoordinates, wkId, map.mapOptions.mapSource.wkId != 0 ? map.mapOptions.mapSource.wkId : 3857);
                    }

                    if (map.mapOptions.positionData) {
                        map.mapOptions.positionData.center = sourceCoordinates;

                        if (zoomLevel != undefined)
                            map.mapOptions.positionData.zoomLevel = zoomLevel;
                    }

                    const view = map.getView();
                    if (zoomLevel) {
                        view.setZoom(zoomLevel);
                    }
                    view.setCenter(sourceCoordinates);

                    await markWhat3Words(res, wkId, map, elementId);
                } else {
                    console.error("Invalid What3Words response:", res);
                }
            }
        });

        // Map search: every way of searching the map - coordinates and postal code through
        // the Blazor-side IMapSearchService, and What3Words through the helper set up above.
        controls.mapSearchControl = new mapSearchControl({
            mapId: elementId,
            map: map,
            target: toolbar.target('search'),
            objectRef: dotnetObjectRef[elementId],
            getObjectRef: () => dotnetObjectRef[elementId],
            setSearchPosition: async (res) => {
                if (res && res.xCoordinate !== undefined && res.yCoordinate !== undefined) {
                    const wkId = res.coordinateType?.split(':')[1];
                    let zoomLevel = 18;
                    let sourceCoordinates = [res.xCoordinate, res.yCoordinate];
                    if (wkId != map.mapOptions.mapSource.wkId) {
                        sourceCoordinates = transformCoordsWkId(sourceCoordinates, wkId, map.mapOptions.mapSource.wkId != 0 ? map.mapOptions.mapSource.wkId : 3857);
                    }

                    if (map.mapOptions.positionData) {
                        map.mapOptions.positionData.center = sourceCoordinates;
                        map.mapOptions.positionData.zoomLevel = zoomLevel;
                    }

                    const view = map.getView();
                    view.setZoom(zoomLevel);
                    view.setCenter(sourceCoordinates);

                    // Subtle, non-clickable search marker at the located point, plus a
                    // top-of-map link to hide it.
                    addSearchMarker(map, sourceCoordinates);
                    showHideSearchMarkerLink(map, elementId);
                } else {
                    console.error('Invalid map search response:', res);
                }
            }
        });

        map.addControl(controls.mapSearchControl);

        // Export: composes what is on screen into a PNG, JPG or PDF. The options are chosen in
        // the Blazor-side modal; the capture itself has to happen here, where the canvases are.
        controls.mapExportControl = new mapExportControl({
            mapId: elementId,
            map: map,
            target: toolbar.target('export'),
            objectRef: dotnetObjectRef[elementId],
            getObjectRef: () => dotnetObjectRef[elementId]
        });

        map.addControl(controls.mapExportControl);

        // Measure: how far it is between points on the map, and how much ground an area of it
        // covers. Offered on every map rather than only one with items on it - what is being
        // measured is the ground, and an empty map is as good a thing to measure as a full one.
        // Not while the map is being drawn on for something else, though: the location editor and
        // the asset picker already own the click.
        if (!map.mapOptions.showEditOptions && !map.mapOptions.editable && !map.mapOptions.isSelectWithMap) {
            controls.measureControl = new measureControl({
                elementId: elementId,
                map: map,
                target: toolbar.target('measure'),
                isMeasuring: () => {
                    return isMeasuring(elementId);
                },
                toggleMeasuring: async (active) => {
                    await toggleMeasuring(elementId, active);
                },
                getMode: () => {
                    return measureSessions[elementId]?.mode ?? 'distance';
                },
                setMode: (mode) => {
                    setMeasureMode(elementId, mode);
                },
                undoLastPoint: () => {
                    undoLastMeasurePoint(elementId);
                },
                clearMeasurements: () => {
                    clearMeasurements(elementId);
                },
                getUnit: (mode) => {
                    return getMeasurementUnits(map)[mode] ?? 'auto';
                },
                setUnit: async (mode, unit) => {
                    await setMeasurementUnit(elementId, mode, unit);
                }
            });

            map.addControl(controls.measureControl);
        }

        if (map.mapOptions.loadUsersPosition === true) {
            //add show user location toggle control
            controls.showUserLocationControl = new showUserLocationControl({
                elementId: elementId,
                isActive: map.mapOptions.showUserLocation,
                target: userLocationContainer,
                toggleUserLocationVisibility: async () => {
                    return await toggleUserLocationVisibility(elementId);
                }
            });
            map.addControl(controls.showUserLocationControl);

            //add user location control
            controls.goToCurrentPosControl = new userLocationControl({
                elementId: elementId,
                target: userLocationContainer,
                goToCurrentPosition: async () => {
                    goToCurrentPosition(elementId);
                }
            });
            map.addControl(controls.goToCurrentPosControl);
        }

        if (hasFeatures) {
            controls.zoomToFitControl = new zoomToFitControl({
                elementId: elementId,
                target: nav.tools,
                zoomToFit: zoomToFit
            });

            map.addControl(controls.zoomToFitControl);

            controls.labelToggleControl = new labelToggleControl({
                elementId: elementId,
                target: toolbar.target('labels'),
                Toggle: async () => {
                    await toggleMapLabels(elementId);

                    getItemsLayerUnderlyingSource(elementId)?.changed();
                    getLinearItemsLayerUnderlyingSource(elementId)?.changed();
                },
                IsEnabled: () => {
                    return map.mapOptions.mapLabelsEnabled;
                }
            });

            map.addControl(controls.labelToggleControl);

            // Heatmap toggle - enables/disables an ol.layer.Heatmap overlay and opens the
            // Blazor-side settings panel. Session-only; never touches the report config.
            if (!isRouteBuilder) {
                controls.heatmapControl = new heatmapControl({
                    elementId: elementId,
                    target: toolbar.target('heatmap'),
                    Toggle: async () => {
                        const enabled = toggleHeatmap(elementId);
                        const objectRef = dotnetObjectRef[elementId];
                        if (objectRef) {
                            try {
                                await objectRef.invokeMethodAsync('ToggleHeatmapPanel', enabled);
                            } catch (error) {
                                console.error('Error toggling the heatmap panel:', error);
                            }
                        }
                    },
                    IsEnabled: () => {
                        return isHeatmapEnabled(elementId);
                    }
                });

                map.addControl(controls.heatmapControl);
            }
        }

        // Data split - brings the report's own data in beside the map. Only the report Map
        // tab asks for it; every other map has no data pane to open.
        if (map.mapOptions.showDataSplitControl === true) {
            controls.dataSplitControl = new dataSplitControl({
                elementId: elementId,
                target: toolbar.target('data'),
                isOpen: () => {
                    return map.mapOptions.dataSplitOpen === true;
                },
                isSideBySide: () => {
                    return map.mapOptions.dataSplitSideBySide === true;
                },
                toggleOpen: async () => {
                    map.mapOptions.dataSplitOpen = !map.mapOptions.dataSplitOpen;

                    const objectRef = dotnetObjectRef[elementId];
                    if (objectRef) {
                        try {
                            await objectRef.invokeMethodAsync('ToggleDataSplit', map.mapOptions.dataSplitOpen);
                        } catch (error) {
                            console.error('Error toggling the data split:', error);
                        }
                    }
                },
                toggleOrientation: async () => {
                    map.mapOptions.dataSplitSideBySide = !map.mapOptions.dataSplitSideBySide;

                    const objectRef = dotnetObjectRef[elementId];
                    if (objectRef) {
                        try {
                            await objectRef.invokeMethodAsync('ToggleDataSplitOrientation', map.mapOptions.dataSplitSideBySide);
                        } catch (error) {
                            console.error('Error changing the data split orientation:', error);
                        }
                    }
                }
            });

            map.addControl(controls.dataSplitControl);

            controls.dataSplitControl.refresh();
        }

        if (!map.mapOptions.editable && !map.mapOptions.isSelectWithMap && !map.mapOptions.showEditOptions && map.mapOptions.ShowPolygonSelection !== undefined && map.mapOptions.ShowPolygonSelection === true) {
            controls.polygonSelectControl = new polygonSelectControl({
                elementId: elementId,
                map: map,
                target: toolbar.target('select'),
                isPolygonSelectionActive: () => {
                    return map.mapOptions.polygonSelectionEnabled;
                },
                isMultiSelectionActive: () => {
                    return map.mapOptions.multiSelectionEnabled;
                },
                showViewItemsButton: () => {
                    return (map.mapOptions.showPolygonViewItemsBtn !== undefined && map.mapOptions.showPolygonViewItemsBtn === true);
                },
                togglePolygonSelection: async () => {
                    await togglePolygonSelection(map.mapOptions.polygonSelectionEnabled = !map.mapOptions.polygonSelectionEnabled, elementId);
                },
                stopPolygonSelection: async () => {
                    await stopPolygonSelection(elementId);
                },
                undoLast: async () => {
                    await undoLastSelectionArea(elementId);
                },
                clearAreas: async () => {
                    await clearSelectionAreas(elementId);
                },
                viewSelectedItems: async () => {
                    await viewSelectedPolygonItems(elementId);
                }
            });
            map.addControl(controls.polygonSelectControl);

            (async () => {
                try {
                    await getToggleOnOffOpen(elementId);

                    controls.popoverToggle = new popoverToggle({
                        elementId: elementId,
                        target: toolbar.target('popups'),
                        toggleOnOff: async () => {
                            await setToggleOnOffOpen(elementId);
                        },
                        isOnOff: () => {
                            return toggleOnOffOpens;
                        }
                    });

                    map.addControl(controls.popoverToggle);

                } catch (error) {

                }
            })();

            //Route planner
            controls.routePlannerControl = new routePlannerControl({
                elementId: elementId,
                map: map,
                toggleRoutePlanner: async () => {
                    await togglePolygonSelection(false, elementId, true);

                    let routeFeatureLayer = routePlanningLayer[elementId];
                    if (routeFeatureLayer) {
                        routeFeatureLayer.setSource(null);
                        map.removeLayer(routeFeatureLayer);
                        routePlanningLayer[elementId] = null;

                        zoomToFit(elementId);
                    }

                    await planRouteForFeatures(null, elementId);
                }
            });
            //  map.addControl(controls.routePlannerControl);
        }

        if (!isRouteBuilder && map.mapOptions.filterCategories?.length > 0) {
            controls.filterControl = new filterControl({
                elementId: elementId,
                map: map,
                target: toolbar.target('key'),
                getObjectRef: () => dotnetObjectRef[elementId],
                itemsLayer: itemsLayers[elementId],
                linearItemsLayer: linearItemsLayers[elementId],
                filterCategories: map.mapOptions.filterCategories,
                onChange: () => setItemsLayerSource(elementId),
                getIconSrc: (icon) => {
                    return iconStyleCache[icon];
                }
            });
            map.addControl(controls.filterControl);
        }

        if (!isRouteBuilder && map.mapOptions.canShowLocalAssets !== undefined && map.mapOptions.canShowLocalAssets === true && map.mapOptions.forceShowLocalAssets !== true) {
            controls.showLocalAssetsControl = new ShowLocalAssetsControl({
                elementId: elementId,
                minZoom: LOCAL_ASSETS_MIN_ZOOM,
                target: toolbar.target('nearby'),
                toggle: async () => {
                    map.mapOptions.shapesVisibility = !map.mapOptions.shapesVisibility;

                    let objectRef = dotnetObjectRef[elementId];
                    if (objectRef) {
                        await objectRef.invokeMethodAsync('ToggleShapesVisibility', map.mapOptions.shapesVisibility);
                    }

                    getItemsLayerUnderlyingSource(elementId)?.changed();
                    getLinearItemsLayerUnderlyingSource(elementId)?.changed();

                    await getMapExtent(elementId, getAdditionalShapes);
                },
                isEnabled: () => {
                    return map.mapOptions.shapesVisibility ?? false;
                }
            });
            map.addControl(controls.showLocalAssetsControl);
        }

        if (!isRouteBuilder && map.mapOptions.canShowToggleCompleted !== undefined && map.mapOptions.canShowToggleCompleted === true) {
            controls.toggleCompletedControl = new ToggleCompletedControl({
                elementId: elementId,
                target: toolbar.target('completed'),
                toggle: async () => {
                    map.mapOptions.displayCompleted = !map.mapOptions.displayCompleted;
                    setItemsLayerSource(elementId);

                    let objectRef = dotnetObjectRef[elementId];
                    if (objectRef) {
                        await objectRef.invokeMethodAsync('ToggleDisplayCompleted', map.mapOptions.displayCompleted);
                    }

                    getItemsLayerUnderlyingSource(elementId)?.changed();
                    getLinearItemsLayerUnderlyingSource(elementId)?.changed();
                },
                isEnabled: () => {
                    return map.mapOptions.displayCompleted ?? true;
                }
            });
            map.addControl(controls.toggleCompletedControl);
        }

        if (map.mapOptions?.canAddItem === true) {
            controls.addItemControl = new AddItemControl({
                map: map,
                elementId: elementId,
                target: toolbar.target('additem'),
                addItem: async () => {
                    let objectRef = dotnetObjectRef[elementId];
                    if (!objectRef)
                        return;

                    return await objectRef.invokeMethodAsync('AddNewItem');
                }
            });
            map.addControl(controls.addItemControl);
        }

        if (map.mapOptions.isSelectWithMap === true) {
            controls.selectWithMapSearchControl = new selectWithMapSearchControl({
                elementId: elementId,
                map: map,
                target: toolbar.target('search'),
                zoomToFit: () => zoomToFit(elementId),
                getObjectRef: () => dotnetObjectRef[elementId]
            });
            map.addControl(controls.selectWithMapSearchControl);
        }

        //add all controls
        mapControls[elementId] = controls;

        // Give every control its label: a word beside the icon in the toolbar, a hover label in
        // the navigation cluster.
        try {
            applyMapControlLabels(map);
        } catch {
        }

        // Watch the toolbar from here on: empty menus, running tools, toggles and running out of
        // width are all decided there.
        toolbar.start();

        // After the label pass, not before: it writes the plain label over every control it
        // knows, which would drop the layer count back out of the layer control's label.
        updateMapLayerActiveCount(elementId);

        setTimeout(() => {
            controls.toggleMapTools.toggleButtonDisplay();
        }, 500);
    } catch (error) {
        // A control that throws takes every control after it off the map, so the reason has to be
        // findable rather than swallowed here.
        console.error('The map controls could not all be added:', elementId, error);
    }
}

async function toggleFullscreen(elementId) {
    let map = maps[elementId];
    if (!map)
        return;

    let objectRef = dotnetObjectRef[elementId];
    if (!objectRef)
        return;

    return await objectRef.invokeMethodAsync('ToggleFullscreenMode').then((result) => {
        toggleMapFullscreenElementMove(result);

        return result;
    });
}

function toggleMapFullscreenElementMove(result, isFromBaseComponent = false) {
    let map = maps[result.mapElementRef];
    if (!map)
        return;

    if (result.isActive) {
        // Move to fullscreen popup element
        // The heat map settings render inside the map element, so they go with it.
        $("#" + result.mapElementRef).appendTo("#" + result.fullscreenElRef);
    } else {
        // Move back to original element
        $("#" + result.mapElementRef).appendTo("#" + result.parentElementRef);
    }

    map.mapOptions.isFullscreenActive = result.isActive;

    setTimeout(function () {
        map.updateSize();
    }, 1000);

    if (isFromBaseComponent === true) {
        let controls = mapControls[result.mapElementRef];
        if (!controls)
            return;

        controls.fullscreenControl.toggleButtonStyling(result.isActive);
    }
}

function setMapControlsState(showControls) {
    isMapControlsShown = showControls;
}

async function updateMapControlsPreference(elementId, showControls) {
    let map = maps[elementId];
    if (!map)
        return;

    let objectRef = dotnetObjectRef[elementId];
    if (!objectRef)
        return;

    return await objectRef.invokeMethodAsync('UpdateMapControlsPreference', showControls);
}

function goToCurrentPosition(elementId) {
    let map = maps[elementId];
    if (!map)
        return;

    // Prefer the true target position from the animation state — the feature's
    // geometry may be mid-interpolation and lag behind the actual user position.
    const animState = userPosAnim[elementId];
    let center = animState?.target;

    if (!center) {
        // Fall back to the feature geometry if no animation state exists
        let userPositionFeature = map.userPositionFeature;
        if (!userPositionFeature)
            return;

        let extent = getFeaturesExtent([userPositionFeature]);
        if (!extent)
            return;

        center = ol.extent.getCenter(extent);
    }

    const view = map.getView();
    if (view) {
        const savedZoom = trackingZoomLevel[elementId];
        const currentZoom = view.getZoom();

        if (savedZoom != null) {
            // Restore or keep the user's chosen zoom level during tracking
            view.cancelAnimations();
            view.animate({
                center: center,
                zoom: savedZoom,
                duration: 350,
                easing: ol.easing.easeOut
            });
        } else if (currentZoom >= 18) {
            // Already zoomed in close enough for tracking, smoothly pan instead of snapping
            view.cancelAnimations();
            view.animate({
                center: center,
                duration: 350,
                easing: ol.easing.easeOut
            });
        } else {
            // Zoom is too low for tracking — animate to a close street-level view
            const trackingMinZoom = 19;
            view.cancelAnimations();
            view.animate({
                center: center,
                zoom: trackingMinZoom,
                duration: 500,
                easing: ol.easing.easeOut
            });
            // Store the initial zoom level so user zoom changes are preserved
            trackingZoomLevel[elementId] = trackingMinZoom;
        }
    }
}

/**
 * Smooth animated center follow — used during tracking to gently drift
 * the view center toward the user position without jarring snaps.
 */
function _smoothFollowCenter(elementId, targetCoords) {
    const map = maps[elementId];
    if (!map) return;
    const view = map.getView();
    if (!view) return;

    view.cancelAnimations();
    view.animate({
        center: targetCoords,
        duration: 400,
        easing: ol.easing.easeOut
    });
}

function hideUserPosition(elementId) {
    let map = maps[elementId];
    if (!map) return;

    let layer = userPositionLayers[elementId];
    if (layer) {
        map.removeLayer(layer);
        delete userPositionLayers[elementId];
    }

    delete map.userPositionFeature;
    disposeUserAnimator(elementId);

    const view = map.getView();
    if (view) view.setRotation(0);

    toggleDirectionStyle(elementId, false);
}

async function toggleUserLocationVisibility(elementId) {
    let map = maps[elementId];
    if (!map) return;

    let objectRef = dotnetObjectRef[elementId];
    if (!objectRef) return;

    return await objectRef.invokeMethodAsync('ToggleUserLocationVisibility').then((result) => {
        map.mapOptions.showUserLocation = result.isVisible;
        map.mapOptions.trackUsersPosition = result.isTracking;

        if (!result.isVisible) {
            hideUserPosition(elementId);
        }

        return result.isVisible;
    });
}

function trySaveMapStateParams(elementId) {
    let locationParamTimeout = locationParamTimeouts[elementId];

    if (locationParamTimeout) {
        clearTimeout(locationParamTimeout);
        locationParamTimeout = null;
    }

    locationParamTimeouts[elementId] = setTimeout(async () => {
        await saveMapPositionData(elementId);
    }, 1000);
}

async function saveMapPositionData(elementId, eventTarget) {
    let objectRef = dotnetObjectRef[elementId];
    if (!objectRef)
        return;

    // A pin-less map still writes its position back. What the pin guards is the shared store -
    // saving an unpinned view there would clear the pin the user set on another map - and that
    // is decided in SaveMapPositionData, which knows whether this map carries the pin. The copy
    // kept in the page state is per record, and is what brings a record's own map back to where
    // the user left it after they open a feature from it and come back.

    let p = getPositionData(elementId);
    if (p == null)
        return;

    if (eventTarget && eventTarget === "openfeature") {
        p.isNavigation = true;
    } else if (eventTarget && eventTarget === "viewselectedpolygons") {
        p.isNavigation = false;
        p.ViewSelectedPolygons = true;
    } else {
        p.isNavigation = false;
    }
    return await objectRef.invokeMethodAsync('SaveMapPositionData', {
        ZoomLevel: p.zoomLevel,
        Center: p.center,
        WkId: p.wkId,
        MapPinned: p.mapPinned,
        IsNavigation: p.isNavigation,
        ViewSelectedPolygons: p.ViewSelectedPolygons
    });
}

// Track last center/zoom per map so moveend from rotation-only changes
// (e.g. compass tracking) doesn't trigger expensive data reloads.
let _lastMoveEndCenter = {};
let _lastMoveEndZoom = {};

// Per-map debounce timers for the expensive feature-loading calls inside moveend.
// Prevents hammering the server when tracking continuously moves the map center.
let _moveEndFeatureTimers = {};

async function mapMoveEnd(elementId, loadFeaturesWhenPanning) {
    updateMapPositionData(elementId);
    setItemsLayerSource(elementId);

    if (loadFeaturesWhenPanning && loadFeaturesWhenPanning === true) {
        // Only reload data when the map has actually panned or zoomed,
        // not when only the rotation changed (compass tracking).
        const map = maps[elementId];
        if (map) {
            const view = map.getView();
            const center = view.getCenter();
            const zoom = view.getZoom();
            const prevCenter = _lastMoveEndCenter[elementId];
            const prevZoom = _lastMoveEndZoom[elementId];

            // Check if center or zoom actually changed (with small tolerance)
            const centerChanged = !prevCenter
                || Math.abs(center[0] - prevCenter[0]) > 0.01
                || Math.abs(center[1] - prevCenter[1]) > 0.01;
            const zoomChanged = prevZoom === undefined || Math.abs(zoom - prevZoom) > 0.001;

            _lastMoveEndCenter[elementId] = center;
            _lastMoveEndZoom[elementId] = zoom;

            if (!centerChanged && !zoomChanged) {
                // Rotation-only change — skip expensive data reload
                return;
            }
        }

        // Debounce the expensive feature/shape loading so rapid moveend events
        // (e.g. from continuous user-position tracking) don't flood the server.
        // Use a longer delay when tracking is active since movements are frequent
        // and incremental; use a shorter delay for manual pan/zoom so the UI
        // stays responsive.
        if (_moveEndFeatureTimers[elementId]) {
            clearTimeout(_moveEndFeatureTimers[elementId]);
        }

        const isTracking = followUser && map?.mapOptions?.trackUsersPosition;
        const debounceMs = isTracking ? 1500 : 300;

        _moveEndFeatureTimers[elementId] = setTimeout(async () => {
            _moveEndFeatureTimers[elementId] = null;
            await getMapExtent(elementId, getAssetShapesAndPopulateShapes);
            await getMapExtent(elementId, getAssetFeaturesFromExtent);
            await getMapExtent(elementId, getAdditionalShapes);
        }, debounceMs);
    }
}

const overlapBadgeVisibleItemLimit = 50;

// Count only item markers that would be drawn inside the current viewport. The spatial lookup
// narrows the candidates; the pixel check excludes the corners of its bounding box on a rotated
// map. Stop as soon as the configured limit is exceeded.
function hasMoreVisibleItemsThan(map, elementId, limit) {
    const source = getItemsLayerUnderlyingSource(elementId);
    const size = map.getSize();
    if (!source || !size) {
        return false;
    }

    const extent = map.getView().calculateExtent(size);
    const context = getFeatureVisibilityContext(elementId);
    let count = 0;

    for (const feature of source.getFeaturesInExtent(extent)) {
        const geometry = feature.getGeometry();
        if (geometry?.getType() !== 'Point') {
            continue;
        }

        const pixel = map.getPixelFromCoordinate(geometry.getCoordinates());
        if (!pixel || pixel[0] < 0 || pixel[0] > size[0] || pixel[1] < 0 || pixel[1] > size[1]) {
            continue;
        }

        if (isFeatureDrawn(feature, context) && ++count > limit) {
            return true;
        }
    }

    return false;
}

// How close together items have to be to be grouped, right now. At 0, only points with precisely
// the same coordinates share a feature, so the map can still offer a choice between them.
function getDesiredClusterDistance(map, elementId) {
    const options = map.mapOptions.clusteringOptions ?? {};

    if (map.mapOptions.allowClustering !== true) {
        return 0;
    }

    const distance = options.clusterDistance ?? 20;

    // "Until zoomed in to" - group while the map is showing more ground than the items can be
    // told apart on, and stop once the user is close enough to see them individually.
    if (options.useZoom === true) {
        return map.getView().getZoom() <= (options.zoomLevel ?? 0) ? distance : 0;
    }

    // "While the map is busy" uses the items actually visible in this view.
    if (options.useVisibleAssets === true) {
        return hasMoreVisibleItemsThan(map, elementId, options.visibleAssetCount ?? 0) ? distance : 0;
    }

    return distance;
}

// Keep separate counts readable when distinct coincident locations are only a few pixels apart.
// This runs only for sparse, ungrouped views, so trying the nearby positions is inexpensive.
function getOverlapBadgeLayout(map, elementId) {
    const size = map.getSize();
    const source = getItemsLayerUnderlyingSource(elementId);
    const layout = new Map();
    if (!size || !source) return layout;

    const extent = map.getView().calculateExtent(size);
    const context = getFeatureVisibilityContext(elementId);
    const locations = new Map();
    for (const feature of source.getFeaturesInExtent(extent)) {
        const geometry = feature.getGeometry();
        if (geometry?.getType() !== 'Point' || !isFeatureDrawn(feature, context)) continue;
        const coordinates = geometry.getCoordinates();
        const pixel = map.getPixelFromCoordinate(coordinates);
        if (!pixel || pixel[0] < 0 || pixel[0] > size[0] || pixel[1] < 0 || pixel[1] > size[1]) continue;

        const key = `${coordinates[0]}|${coordinates[1]}`;
        const location = locations.get(key) ?? {key, pixel, count: 0};
        location.count++;
        locations.set(key, location);
    }

    const markers = [...locations.values()];
    const placed = [];
    for (const location of markers.filter(value => value.count > 1)
        .sort((a, b) => a.pixel[1] - b.pixel[1] || a.pixel[0] - b.pixel[0])) {
        const radius = getOverlapBadgeRadius(location.count);
        const x = radius + 8;
        const y = radius + 6;
        const candidates = [[x, -y], [-x, -y], [x, y], [-x, y], [0, -radius - 14],
            [0, radius + 14], [radius + 15, 0], [-radius - 15, 0]];
        let best = candidates[0];
        let bestScore = Infinity;
        for (const [index, offset] of candidates.entries()) {
            const centre = [location.pixel[0] + offset[0], location.pixel[1] + offset[1]];
            let score = index * 0.01; // Keep the usual top-right position when it is clear.
            for (const marker of markers) {
                if (marker === location) continue;
                const overlap = Math.max(0, radius + 13 - Math.hypot(
                    centre[0] - marker.pixel[0], centre[1] - marker.pixel[1]));
                score += overlap * overlap * 3;
            }
            for (const badge of placed) {
                const overlap = Math.max(0, radius + badge.radius + 2 - Math.hypot(
                    centre[0] - badge.centre[0], centre[1] - badge.centre[1]));
                score += overlap * overlap * 20;
            }
            if (centre[0] - radius < 0 || centre[0] + radius > size[0]
                || centre[1] - radius < 0 || centre[1] + radius > size[1]) score += 1000;
            if (score < bestScore) {
                bestScore = score;
                best = offset;
            }
        }
        layout.set(location.key, best);
        placed.push({centre: [location.pixel[0] + best[0], location.pixel[1] + best[1]], radius});
    }
    return layout;
}

function setItemsLayerSource(elementId) {
    try {
        let map = maps[elementId];
        let itemsLayer = itemsLayers[elementId];

        const layerSources = itemLayerSources[elementId];
        const clusterSource = layerSources?.itemsLayerClusterSource;
        if (!map || !itemsLayer || !clusterSource) {
            return;
        }

        const distance = getDesiredClusterDistance(map, elementId);
        // The overlap badge is useful only once the current view is sparse enough to inspect.
        const showOverlapCountPills = !map.mapOptions.isSinglePosition && distance === 0
            && !hasMoreVisibleItemsThan(map, elementId, overlapBadgeVisibleItemLimit - 1);
        const overlapCountPillsChanged = map.showOverlapCountPills !== showOverlapCountPills;
        map.showOverlapCountPills = showOverlapCountPills;
        map.overlapBadgeLayout = showOverlapCountPills ? getOverlapBadgeLayout(map, elementId) : null;
        const minDistance = distance > 0
            ? Math.min(map.mapOptions.clusteringOptions?.clusterMinDistance ?? 20, distance) : 0;
        if (distance === 0 && clusterSource.getMinDistance() !== 0) {
            clusterSource.setMinDistance(0);
        }
        if (clusterSource.getDistance() !== distance) {
            clusterSource.setDistance(distance);
        }
        if (distance > 0 && clusterSource.getMinDistance() !== minDistance) {
            clusterSource.setMinDistance(minDistance);
        }
        if (itemsLayer.getSource() !== clusterSource) {
            itemsLayer.setSource(clusterSource);
        }
        layerSources.itemsLayerIsClustered = distance > 0;
        if (overlapCountPillsChanged || showOverlapCountPills) {
            itemsLayer.changed();
        }
    } catch (e) {
    }
}

function getMapObjects() {
    let mapObjects = {};
    mapObjects.assets = [];
    mapObjects.polygons = [];
    mapObjects.layers = [];
    mapObjects.editableFeatures = editAssetShapesStore;
    mapObjects.mapAnnotations = [];
    mapObjects.localAssetAnnotations = [];
    mapObjects.layersVisibilityStates = {baseLayers: {}, geoLayers: {}};

    return mapObjects;
}

// How a base map is named in a profile and in the picker. The key is the configuration row's id,
// but an installation with no map configuration at all falls back to a built-in pair that has no
// ids - so the name stands in for one. Either way it is stable across a reordering of the list,
// which an index would not be.
function mapSourceKeyOf(source) {
    return source?.key || source?.name || '';
}

// A source out of the configured list, by its key. Anything that cannot be matched - a profile
// naming a source that has since been removed - comes back null, and the caller falls back to the
// configured default rather than leaving the map with no base map at all.
function findMapSourceByKey(sources, key) {
    if (!sources?.length || !key)
        return null;

    return sources.find(source => mapSourceKeyOf(source) === key) ?? null;
}

// The base maps this map could be drawn from, for the layers panel's picker. The projection comes
// with them: switching to a source in a different one cannot be done by swapping tiles, because
// every feature on the map was placed in the projection the map was built in.
function getMapSourcesForModal(elementId) {
    const map = maps[elementId];
    if (!map)
        return [];

    const sources = map.mapSources ?? mapDefaults.mapSources ?? [];
    const currentKey = mapSourceKeyOf(map.mapOptions?.mapSource);

    return sources
        .filter(source => mapSourceKeyOf(source))
        .map(source => ({
            key: mapSourceKeyOf(source),
            name: source.name,
            wkId: source.wkId,
            isCurrent: mapSourceKeyOf(source) === currentKey
        }));
}

// Takes the layers a source brought off the map and puts the new source's in their place, leaving
// everything else - the items, the linear features, the view, anything being drawn or measured -
// exactly where it was. The map is not remade, so nothing on it flickers and nothing is reloaded.
//
// The new overlays go back under the items rather than on the end of the stack: a source's layers
// draw beneath the assets, and appending them would put them over the top.
function swapMapSourceLayers(elementId, newSource) {
    const map = maps[elementId];
    if (!map)
        return false;

    const layers = map.getLayers();
    const previous = layers.getArray().filter(layer => layer.get(MAP_SOURCE_LAYER_FLAG));

    map.mapOptions.mapSource = newSource;

    const mapLayerGroup = createMapLayerGroup(map.mapObjects, map.mapOptions, elementId);
    const geoLayers = createGeoLayers(map.mapOptions, map.mapObjects, null, elementId) ?? [];

    markLayersFromMapSource(mapLayerGroup, geoLayers);

    previous.forEach(layer => {
        disposeLayerSources(layer);
        map.removeLayer(layer);
    });

    // The base map goes to the bottom, where every other layer draws over it.
    layers.insertAt(0, mapLayerGroup);

    const itemsLayer = itemsLayers[elementId];
    const itemsIndex = itemsLayer ? layers.getArray().indexOf(itemsLayer) : -1;
    let insertAt = itemsIndex > -1 ? itemsIndex : layers.getLength();

    geoLayers.forEach(layer => {
        layers.insertAt(insertAt, layer);
        insertAt++;
    });

    // The new source's layers space their labels as the old ones did, and draw them on top.
    applyLayerLabelSpacing(map);
    liftMapLabels(map, elementId);

    // The badge on the layers control counts what is switched on, and what is switched on is now
    // a different set of layers.
    try {
        updateMapLayerActiveCount(elementId);
    } catch {
    }

    return true;
}

// A removed layer's source keeps its tiles and its features until it is told not to.
function disposeLayerSources(layer) {
    const targets = layer instanceof ol.layer.Group ? layer.getLayers().getArray() : [layer];

    targets.forEach(target => {
        const source = target?.getSource?.();
        if (!source)
            return;

        try {
            if (typeof source.clear === 'function') source.clear();
            if (typeof source.dispose === 'function') source.dispose();
        } catch {
        }
    });
}

// Layers the user added from the layers panel (MapAddLayerModal), drawn as a source's own overlays are
// but not part of the source: a change of base map leaves them where they are, and the component puts
// them back after a rebuild and on the next map it draws. Their configurations are kept beside the
// source's (map.addedLayerConfigs), where the layers panel reads a layer's label and selection options.
const ADDED_LAYER_FLAG = 'amxAddedLayer';

// Adds layers to the map, built from the same configuration a source's overlays are built from
// (LayerData). One already added under the same name is replaced. `show` switches them on: an added
// layer is shown at once, while one put back after a rebuild keeps whatever the user last chose.
// Returns the names of the layers now on the map.
function addMapOverlays(elementId, layerConfigs, show) {
    const map = maps[elementId];
    if (!map || !layerConfigs?.length)
        return [];

    // Never in a group: the panel lists added layers on their own, each with its own actions.
    const configs = layerConfigs.map(config => ({...config, groupName: ''}));
    const names = configs.map(config => config.name);

    removeAddedLayers(map, names);

    const options = {...map.mapOptions, mapSource: {...map.mapOptions.mapSource, layers: configs}};
    const geoLayers = createGeoLayers(options, map.mapObjects, null, elementId) ?? [];

    map.addedLayerConfigs = [...(map.addedLayerConfigs ?? []), ...configs];

    // Under the items, as a source's overlays go: the records on the map stay on top.
    const layers = map.getLayers();
    const itemsLayer = itemsLayers[elementId];
    const itemsIndex = itemsLayer ? layers.getArray().indexOf(itemsLayer) : -1;
    let insertAt = itemsIndex > -1 ? itemsIndex : layers.getLength();

    geoLayers.forEach(layer => {
        layer.set(ADDED_LAYER_FLAG, true);

        if (show === true)
            layer.setVisible(true);

        layers.insertAt(insertAt, layer);
        insertAt++;
    });

    applyLayerLabelSpacing(map);
    liftMapLabels(map, elementId);
    updateLayerVisibleState(elementId);

    return geoLayers.map(layer => layer.get('title'));
}

// Takes an added layer off the map. True when there was one by that name.
function removeMapOverlay(elementId, name) {
    const map = maps[elementId];
    if (!map)
        return false;

    const removed = removeAddedLayers(map, [name]);
    updateLayerVisibleState(elementId);

    return removed;
}

// An added layer just saved into the configuration the map is drawn from: from now on it is one of the
// source's own layers, listed with them and taken off with them when the base map changes. The source
// object is the map's cached copy of the configuration, so it keeps the layer if the user comes back to it.
function keepMapOverlay(elementId, name) {
    const map = maps[elementId];
    const config = map?.addedLayerConfigs?.find(added => added.name === name);
    if (!map || !config)
        return false;

    map.getLayers().getArray()
        .filter(layer => layer.get(ADDED_LAYER_FLAG) && layer.get('title') === name)
        .forEach(layer => {
            layer.unset(ADDED_LAYER_FLAG);
            layer.set(MAP_SOURCE_LAYER_FLAG, true);
        });

    map.mapOptions.mapSource.layers = [...(map.mapOptions.mapSource.layers ?? []), config];
    map.addedLayerConfigs = map.addedLayerConfigs.filter(added => added !== config);

    return true;
}

function removeAddedLayers(map, names) {
    const targets = map.getLayers().getArray()
        .filter(layer => layer.get(ADDED_LAYER_FLAG) && names.includes(layer.get('title')));

    targets.forEach(layer => {
        disposeLayerSources(layer);
        map.removeLayer(layer);
    });

    map.addedLayerConfigs = (map.addedLayerConfigs ?? []).filter(config => !names.includes(config.name));

    return targets.length > 0;
}

// The layers panel's choice of base map.
//
// Redrawn in place wherever it can be - which is wherever the new source is in the projection the
// map was built in. Where it is not, the map has to be made again: the view and every feature on
// it were placed in that projection, and no amount of swapping tiles moves them. Either way the
// component is told, so the choice is what the map opens on next time and what a profile saves.
async function setMapSourceForModal(elementId, mapSourceKey) {
    const map = maps[elementId];
    const objectRef = dotnetObjectRef[elementId];

    if (!map || !objectRef)
        return false;

    const sources = map.mapSources ?? mapDefaults.mapSources ?? [];
    const newSource = findMapSourceByKey(sources, mapSourceKey);
    const currentSource = map.mapOptions?.mapSource;

    if (!newSource || mapSourceKeyOf(newSource) === mapSourceKeyOf(currentSource))
        return false;

    let swapped = false;

    if (newSource.wkId === currentSource?.wkId) {
        try {
            swapped = swapMapSourceLayers(elementId, newSource);
        } catch (error) {
            // Put the source back so the rebuild below starts from a map that agrees with itself.
            console.error('Error redrawing the map on the new source:', error);

            map.mapOptions.mapSource = currentSource;
            swapped = false;
        }
    }

    try {
        await objectRef.invokeMethodAsync('SetMapSourceAsync', mapSourceKey, !swapped);

        return true;
    } catch (error) {
        console.error('Error changing the map source:', error);

        return false;
    }
}

// The maps of these reports offer the "Completed items" toggle - actions are what an item's
// completion means. Elsewhere a column whose name merely contains "complete" (a completion date, a
// percentage) would otherwise put the toggle on the map.
const COMPLETED_TOGGLE_ENTITY_KEYS = ['actionbatch', 'assetactions'];

function getMapOptions(locationData) {
    if (!locationData)
        return;

    let mapOptions = {};
    let co = {};
    co.clusterDistance = locationData.clusteringData.clusterDistance;
    co.clusterMinDistance = locationData.clusteringData.clusterMinDistance;
    co.useVisibleAssets = locationData.clusteringData.useVisibleAssets;
    co.visibleAssetCount = locationData.clusteringData.visibleAssetNumber;
    co.useZoom = locationData.clusteringData.useZoom;
    co.zoomLevel = locationData.clusteringData.zoomLevel;
    // Which base map the map is drawn from. The list's first entry is the configured default -
    // GetMapSources puts the one carrying the user's own layer states there - and a chosen source
    // takes its place. Chosen by key rather than by index so a profile saved months ago still
    // names the same source after the list has been reordered.
    const availableSources = locationData.mapSources ?? mapDefaults.mapSources ?? null;

    mapOptions.mapSource = findMapSourceByKey(availableSources, locationData.selectedMapSourceKey)
        ?? (availableSources ? availableSources[0] : null);
    mapOptions.clusteringOptions = co;
    mapOptions.showClusterControl = true;
    mapOptions.positionData = null;
    mapOptions.allowClustering = locationData.allowClustering;
    mapOptions.isSinglePosition = locationData.isSinglePosition === true;

    mapOptions.annotationMode = false;
    mapOptions.entityKey = locationData.entityKey;
    mapOptions.isSelectWithMap = locationData.isSelectWithMap;
    mapOptions.showEditOptions = locationData.showEditOptions;
    mapOptions.defaultToUserLocation = locationData.defaultToUserLocation;
    mapOptions.amxLayerLoad = getAmxLayerData;
    mapOptions.loadUsersPosition = locationData.loadUsersPosition;
    mapOptions.showUserLocation = locationData.showUserLocation;
    mapOptions.trackUsersPosition = locationData.trackUsersPosition;
    mapOptions.canGoFullscreen = locationData.canGoFullscreen;
    mapOptions.showStreetView = locationData.showStreetView === true;

    // Data split - the report Map tab only; see dataSplitControl.js.
    mapOptions.showDataSplitControl = locationData.showDataSplitControl ?? false;
    mapOptions.dataSplitOpen = locationData.dataSplitOpen ?? false;
    mapOptions.dataSplitSideBySide = locationData.dataSplitSideBySide ?? false;
    mapOptions.editable = locationData.editMap;
    mapOptions.hasFastEdit = locationData.hasFastEdit;
    mapOptions.filterCategories = locationData.mapLegendFilterCategories;

    // Local Assets
    mapOptions.canShowLocalAssets = locationData.canShowLocalAssets ?? false;
    mapOptions.forceShowLocalAssets = locationData.forceShowLocalAssets ?? false;
    mapOptions.shapesVisibility = locationData.shapesVisibility ?? false;
    mapOptions.linearLoadRule = normaliseLinearLoadRule(locationData.linearLoading);
    // "Completed items" is offered only on action maps (COMPLETED_TOGGLE_ENTITY_KEYS); any other map
    // draws its completed items, so one never stays hidden with no way to bring it back.
    mapOptions.canShowToggleCompleted = (locationData.canShowToggleCompleted ?? false)
        && COMPLETED_TOGGLE_ENTITY_KEYS.includes((locationData.entityKey ?? '').toLowerCase());
    mapOptions.displayCompleted = mapOptions.canShowToggleCompleted ? (locationData.displayCompleted ?? true) : true;

    //map pin
    // The pin is only offered where it can hold something: the location editor sets a position
    // rather than keeps one, the asset picker always opens on the asset being chosen, and
    // disableMapPin says the same of a map that exists to show one record's own location.
    mapOptions.disableMapPin = locationData.disableMapPin === true;
    mapOptions.showMapPin = locationData.showEditOptions !== true && locationData.isSelectWithMap !== true
        && !mapOptions.disableMapPin;
    // Pinned is a stored user preference, not a per-map default - the button has to open in the
    // state the user left it in, on whichever map they are looking at. Read from the position
    // data rather than assumed false, because the position is not always restored through
    // restorePosition (defaulting to the user's location handles it first, for one).
    mapOptions.isMapPinned = mapOptions.showMapPin && locationData.positionData?.mapPinned === true;
    // polygon selection  
    mapOptions.polygonSelectionEnabled = locationData.polygonSelectionEnabled;
    mapOptions.multiSelectionEnabled = locationData.multiSelectionEnabled;
    mapOptions.addPolygonSeletToMultiselect = false;
    mapOptions.hasPolygon = locationData.hasPolygon;
    mapOptions.showPolygonViewItemsBtn = locationData.showPolygonViewItemsBtn;
    mapOptions.ShowPolygonSelection = locationData.showPolygonSelection;
    mapOptions.disableTwoFingerPan = locationData.disableTwoFingerPan;
    mapOptions.canAddItem = locationData.canAddItem;

    mapOptions.mapLabelsEnabled = locationData.mapLabelsEnabled;

    // Whether the configured layers' own labels obey the labels toggle as well (see
    // getFeatureLabels). Off unless the page asks, so a record map draws them as it always has.
    mapOptions.layerLabelsFollowToggle = locationData.layerLabelsFollowToggle === true;

    // Measuring: the units this user last chose, and the installation's own configured unit,
    // which is what decides whether "Auto" works in metres or in feet.
    mapOptions.measurementUnits = {
        distance: locationData.measurementUnits?.distance || 'auto',
        area: locationData.measurementUnits?.area || 'auto'
    };

    try {
        setMeasurementFamily(locationData.configuredMeasuringUnit);
    } catch {
    }

    return mapOptions;
}

async function getFeaturesForData(itemLocations, wkId, elementId, hide) {
    const features = [];

    if (itemLocations?.length > 0) {
        // One item at a time, each behind its own guard. An item the map cannot place - a
        // coordinate it has no transform for, a shape it cannot read - used to throw out of here
        // and out of createMap with it, so a single bad record left the page with no map at all.
        // Reported once rather than once per item: a projection nothing can transform fails for
        // every item on the map, and thousands of identical lines bury the one that matters.
        let firstFailure = null;
        let failures = 0;

        for (let i = 0; i < itemLocations.length; i++) {
            try {
                const assetFeatures = await createAssetFeatures(itemLocations[i], wkId, elementId, hide);
                for (let j = 0; j < assetFeatures.length; j++) {
                    features.push(assetFeatures[j]);
                }
            } catch (error) {
                failures++;
                firstFailure ??= error;
            }
        }

        if (failures > 0) {
            console.error('Could not place ' + failures + ' of ' + itemLocations.length
                + ' items on the map. The first failure was:', firstFailure);
        }
    }

    return features;
}

async function getLinearFeaturesForData(itemLocations, wkId, elementId, hide) {
    const features = [];

    if (itemLocations?.length > 0) {
        for (let i = 0; i < itemLocations.length; i++) {
            let item = itemLocations[i];
            let assetFeatures = [];

            await createAssetShapes(assetFeatures, item.shapes, {
                dontShowOpenLink: item.dontShowOpenLink,
                filter: item.filter,
                isESU: item.isESU,
                mapOptionsWkId: wkId,
                appWkId: maps[elementId]?.appWkId ?? mapDefaults.appWkId
            }, elementId);

            // Which item each shape belongs to, as registerLoadedLocationItems keys it, so the
            // point's icon can find its shape's extent (getLinearItemExtents).
            const linearItemKey = getLoadedItemKey(item.assetId ?? item.id);
            for (let j = 0; j < assetFeatures.length; j++) {
                const attributes = assetFeatures[j].get('attributes');
                if (attributes) {
                    attributes.linearItemKey ??= linearItemKey;
                }
                features.push(assetFeatures[j]);
            }
        }
    }

    return features;
}

function createUserPositionLayer(userPositionFeature) {
    let source = new ol.source.Vector({
        features: [userPositionFeature]
    });

    return new ol.layer.Vector({
        source: source,
        zIndex: 1,
    });
}

// An extent can only be passed to view.fit() when it holds four finite numbers and is not
// "empty". OpenLayers treats maxX < minX (or maxY < minY) as empty and throws
// "Cannot fit empty extent provided as `geometry`". An unpopulated vector source - or one
// whose features all have a null geometry - returns exactly that empty extent:
// [Infinity, Infinity, -Infinity, -Infinity].
function isFittableExtent(extent) {
    return Array.isArray(extent)
        && extent.length === 4
        && extent.every(function (v) {
            return typeof v === 'number' && isFinite(v);
        })
        && extent[2] >= extent[0]
        && extent[3] >= extent[1];
}

function getFeaturesExtent(features) {
    try {
        const coordinates = features
            .map(function (f) {
                return f.getGeometry();
            })
            .filter(function (g) {
                return g instanceof ol.geom.Point;
            })
            .map(function (g) {
                return g.getCoordinates();
            })
            .filter(function (coord) {
                return coord[0] !== 0 && coord[1] !== 0;
            });

        if (coordinates?.length > 0) {
            return ol.extent.boundingExtent(coordinates);
        }
    } catch (e) {
    }

    return null;
}

function getLinearFeaturesExtent(features) {
    try {
        const coordinates = features
            .map(function (f) {
                return f.getGeometry();
            })
            .map(function (g) {
                return g.getCoordinates();
            })
            .filter(function (coord) {
                return coord[0] !== 0 && coord[1] !== 0;
            }).map((c) => {
                var firstItem = c[0];
                var lastItem = c[c.length - 1];

                return [firstItem, lastItem];
            });

        if (!coordinates)
            return null;

        let coords = [];

        if (Array.isArray(coordinates)) {
            for (var i = 0; i < coordinates.length; i++) {
                let coordsArry = coordinates[i];

                if (Array.isArray(coordsArry)) {
                    if (Array.isArray(coordsArry[0])) {
                        for (var j = 0; j < coordsArry.length; j++) {
                            let coordsA = coordsArry[j];
                            if (Array.isArray(coordsA)) {
                                coords.push(coordsA);
                            } else {
                                let point = [coordsA[0], coordsA[1]];
                                coords.push(point);
                            }
                        }
                    } else {
                        let point = [coordsArry[0], coordsArry[1]];
                        coords.push(point);
                    }
                } else {
                    let point = [coordsArry[0], coordsArry[1]];
                    coords.push(point);
                }
            }
        } else { // don't think case will happen as should aways be more than 1 coord for linear features!
            let point = [coordinates[0], coordinates[1]];
            coords.push(point);
        }

        if (coords?.length > 0) {
            return ol.extent.boundingExtent(coords);
        }
    } catch (e) {
    }

    return null;
}

// Shows an extent given in another projection - the map configuration editor's preview opens on the
// area a service covers. Ignored when the extent or its projection can't be used.
function fitMapToExtent(elementId, extent, wkId) {
    const map = maps[elementId];
    if (!map || disposedMaps[elementId] || !Array.isArray(extent) || extent.length !== 4 || extent.some(v => !isFinite(v))) {
        return false;
    }

    try {
        const view = map.getView();
        const from = 'EPSG:' + wkId;
        if (!ol.proj.get(from)) {
            return false;
        }

        const fitted = ol.proj.transformExtent(extent, from, view.getProjection());
        if (fitted.some(v => !isFinite(v))) {
            return false;
        }

        view.fit(fitted, {size: map.getSize(), maxZoom: 16});
        return true;
    } catch (error) {
        console.warn('Could not show the extent', extent, wkId, error);
        return false;
    }
}

async function zoomToFit(elementId) {
    let features = [];
    let extent = null;

    let pointFeatures = getItemsLayerUnderlyingSource(elementId)?.getFeatures();
    if (pointFeatures?.length > 0) {
        let pointExtent = getFeaturesExtent(pointFeatures);
        if (isFittableExtent(pointExtent))
            extent = pointExtent;
    }

    //if (!extent) { // ALWAYS DO THIS NOW
    let linearFeatures = getLinearItemsLayerUnderlyingSource(elementId)?.getFeatures();
    if (linearFeatures?.length > 0) {
        let linearExtent = getLinearFeaturesExtent(linearFeatures);
        if (isFittableExtent(linearExtent)) {
            // combine the extents
            if (!extent) {
                extent = linearExtent;
            } else {
                extent[0] = Math.min(extent[0], linearExtent[0]);
                extent[1] = Math.min(extent[1], linearExtent[1]);
                extent[2] = Math.max(extent[2], linearExtent[2]);
                extent[3] = Math.max(extent[3], linearExtent[3]);
            }
        }
    }
    //}

    // Might also want to change this code to combine with extent already found, as above for linear features
    if (extent === null) {
        const editShapes = getEditShapeLayerUnderlyingSource(elementId);
        let editShapesFeatures = editShapes?.getFeatures();

        if (editShapesFeatures?.length > 0) {
            // Features with a null geometry are still returned by getFeatures() but contribute
            // nothing to the source extent, so this can still come back empty.
            let shapesExtent = editShapes.getExtent();
            if (isFittableExtent(shapesExtent))
                extent = shapesExtent;
        }
    }

    if (isFittableExtent(extent)) {
        const view = maps[elementId]?.getView();

        if (view) {
            view.fit(extent, {size: maps[elementId].getSize(), padding: [10, 10, 10, 10], maxZoom: 18});
        }
    }

    // Fully disable user tracking so the map stays at the fitted extent
    // (the blue dot remains visible but the map no longer follows the user)
    followUser = false;
    await disableUserTracking(elementId);
}

async function pauseMapRotation(elementId) {
    let map = maps[elementId];
    if (!map) return;

    // Set the per-map flag so olSetRotation ignores incoming rotation updates
    rotationPaused[elementId] = true;

    // Reset view rotation to north
    const view = map.getView();
    if (view) view.setRotation(0);

    // Clear rotation animation state
    delete lastRotation[elementId];
    const rs = rotationState[elementId];
    if (rs) {
        rs.target = 0;
        rs.current = 0;
    }

    // Hide the direction cone on the user-position dot
    toggleDirectionStyle(elementId, false);

    // Tell C# to pause compass-driven rotation (stops compass sensor but keeps tracking active)
    let objectRef = dotnetObjectRef[elementId];
    if (objectRef) {
        try {
            await objectRef.invokeMethodAsync('PauseRotation');
        } catch (e) { /* swallow */
        }
    }
}

async function disableUserTracking(elementId) {
    let map = maps[elementId];
    if (!map || !map.mapOptions.trackUsersPosition)
        return;

    let objectRef = dotnetObjectRef[elementId];
    if (!objectRef)
        return;

    await objectRef.invokeMethodAsync('DisableUserTracking').then((result) => {
        map.mapOptions.trackUsersPosition = result.isTracking;

        // Reset rotation to north when tracking is turned off
        const view = map.getView();
        if (view) view.setRotation(0);
        delete lastRotation[elementId];
        const rs = rotationState[elementId];
        if (rs) {
            rs.target = 0;
            rs.current = 0;
        }

        toggleDirectionStyle(elementId, false);
    });
}

/****
 * Usage getCurrentMap == used for teting only so far
 var mapObj = getCurrentMap(); //this may not be correct to be done
 if (!mapObj.elementId)
 return;
 map = mapObj.map;
 elementId = mapObj.elementId;
 ****/

function getCurrentMap() {
    let mapObj = {map: null, elementId: ""};//to be done  //Object.prototype.hasOwnProperty.call is not a correct approach :: adding elementid to the features is one way but must be remembered everytime we add features 
    for (let prop in maps) {
        if (Object.prototype.hasOwnProperty.call(maps, prop)) {
            let obj = maps[prop];
            if (obj.values_.target) {
                mapObj.map = obj;
                mapObj.elementId = prop;
            }
        }
    }
    return mapObj;
}

let customIconFill = new ol.style.Fill({
    color: [255, 255, 255, 1]
});

let customIconStroke = new ol.style.Stroke({
    color: [255, 0, 0, 1],
    width: 4
});

// How a selected line or polygon is drawn. The point's green circle and tick is an image style,
// which renders on points only, so a line needs its own treatment. The line keeps its own style -
// its colour is what says which kind of asset it is - and is marked around it instead: a soft green
// halo and a white casing under it, a ringed cap on each end so its extent reads, and the same
// green tick as a point at its middle, so a selection looks the same whatever its shape.
//
// Adds to the array it is given rather than copying it: the arrow icons on a line are pushed onto
// it after an await, and have to land in the array the map actually draws.
function decorateSelectedFeatureStyles(feature, styles) {
    const geometry = feature.getGeometry();
    const hasOwnStyle = styles.length > 0;
    const lineWidth = styles.reduce((width, style) => Math.max(width, style?.getStroke?.()?.getWidth?.() ?? 0), 0) || 4;

    styles.unshift(
        new ol.style.Style({
            stroke: new ol.style.Stroke({
                color: 'rgba(30, 185, 30, 0.45)',
                width: lineWidth + 12,
                lineCap: 'round',
                lineJoin: 'round'
            })
        }),
        new ol.style.Style({
            stroke: new ol.style.Stroke({
                color: 'rgba(255, 255, 255, 0.9)',
                width: lineWidth + 4,
                lineCap: 'round',
                lineJoin: 'round'
            })
        })
    );

    if (!hasOwnStyle) {
        styles.push(new ol.style.Style({
            stroke: new ol.style.Stroke({color: 'rgba(30, 185, 30, 0.95)', width: lineWidth})
        }));
    }

    if (geometry.getType().endsWith('Polygon')) {
        styles.push(new ol.style.Style({
            fill: new ol.style.Fill({color: 'rgba(30, 185, 30, 0.2)'})
        }));
    }

    const lineEnds = getSelectionLineStrings(geometry)
        .flatMap(line => {
            const coordinates = line.getCoordinates();
            return coordinates.length > 1 ? [coordinates[0], coordinates[coordinates.length - 1]] : [];
        });

    if (lineEnds.length > 0) {
        styles.push(new ol.style.Style({
            zIndex: 9999,
            geometry: new ol.geom.MultiPoint(lineEnds),
            image: new ol.style.Circle({
                declutterMode: 'none',
                radius: 5,
                fill: new ol.style.Fill({color: 'white'}),
                stroke: new ol.style.Stroke({color: 'rgba(30, 185, 30, 0.95)', width: 2.5})
            })
        }));
    }

    const badgePoint = getSelectionBadgePoint(geometry);
    if (badgePoint) {
        const badgeStyle = getSelectedFeatureStyle();
        badgeStyle.setGeometry(badgePoint);
        styles.push(badgeStyle);
    }

    return styles;
}

// How a line under the pointer is marked: drawn again over the map in its own colours, a little
// wider, with a faint halo of its colour round it - so it stands out from the lines beside it and
// still reads as the kind of asset it is. Only lines: a point already shows its preview, and a
// polygon's fill would cover what is under it.
//
// The line is drawn on a layer of its own, outside the map's layers, rather than by restyling it
// where it is: changing a feature redraws the whole of its layer, every item on the map, and the
// pointer crosses many lines.
const LINE_HOVER_EXTRA_WIDTH = 2;
const LINE_HOVER_HALO_WIDTH = 8;
const LINE_HOVER_HALO_OPACITY = 0.35;

function createLineHoverHighlight(map) {
    let layer = null;
    let hovered = null;
    let hoveredLayer = null;

    const style = (feature, resolution) => getLineHoverStyles(feature, hoveredLayer, resolution);

    return (feature, itemsLayer) => {
        const target = feature && getSelectionLineStrings(feature.getGeometry()).length > 0 ? feature : null;
        if (target === hovered)
            return;

        hovered = target;
        hoveredLayer = target ? itemsLayer : null;

        if (!layer) {
            if (!target)
                return;

            layer = new ol.layer.Vector({
                source: new ol.source.Vector(),
                style,
                updateWhileAnimating: true,
                updateWhileInteracting: true
            });
            layer.set('amxLineHover', true);
            layer.setMap(map);
        }

        const source = layer.getSource();
        source.clear(true);
        if (target)
            source.addFeature(target);
        layer.changed();
    };
}

function getLineHoverStyles(feature, layer, resolution) {
    const styleFunction = feature.getStyleFunction() ?? layer?.getStyleFunction?.();
    const ownStyle = styleFunction ? styleFunction(feature, resolution) : null;

    // The strokes that draw the line itself - not its end dots, arrows, caps or label, which are
    // drawn on geometries of their own and stay as they are under it.
    const strokes = (Array.isArray(ownStyle) ? ownStyle : [ownStyle])
        .filter(item => item && !item.getGeometry() && item.getStroke?.())
        .map(item => item.getStroke())
        .filter(stroke => getStrokeOpacity(stroke) > 0);

    if (strokes.length === 0)
        return [];

    const line = strokes[strokes.length - 1];
    const width = strokes.reduce((widest, stroke) => Math.max(widest, stroke.getWidth() ?? 1), 0);
    const styles = [];

    const halo = getLineHoverHaloColour(line.getColor());
    if (halo) {
        styles.push(new ol.style.Style({
            stroke: new ol.style.Stroke({
                color: halo,
                width: width + LINE_HOVER_HALO_WIDTH,
                lineCap: 'round',
                lineJoin: 'round'
            })
        }));
    }

    for (const stroke of strokes) {
        styles.push(new ol.style.Style({
            stroke: new ol.style.Stroke({
                color: stroke.getColor(),
                width: (stroke.getWidth() ?? 1) + LINE_HOVER_EXTRA_WIDTH,
                lineDash: stroke.getLineDash(),
                lineDashOffset: stroke.getLineDashOffset(),
                lineCap: stroke.getLineCap() ?? 'round',
                lineJoin: stroke.getLineJoin() ?? 'round'
            })
        }));
    }

    return styles;
}

// A line's colour comes as an "r,g,b,a" setting split into strings, an array or a CSS colour.
function getStrokeColourArray(colour) {
    if (Array.isArray(colour))
        return colour.map(Number);

    if (typeof colour === 'string') {
        try {
            return ol.color.asArray(colour);
        } catch {
            return null;
        }
    }

    return null;
}

function getStrokeOpacity(stroke) {
    const colour = getStrokeColourArray(stroke.getColor());
    // A pattern or gradient, which has no opacity to read, is drawn.
    if (!colour)
        return 1;

    return colour.length > 3 && !isNaN(colour[3]) ? colour[3] : 1;
}

function getLineHoverHaloColour(colour) {
    const parts = getStrokeColourArray(colour);
    if (!parts || parts.length < 3 || parts.slice(0, 3).some(isNaN))
        return null;

    const opacity = parts.length > 3 && !isNaN(parts[3]) ? parts[3] : 1;
    return [parts[0], parts[1], parts[2], opacity * LINE_HOVER_HALO_OPACITY];
}

function isLinearOrAreaGeometry(geometry) {
    const geometryType = geometry?.getType?.() ?? '';
    return geometryType.endsWith('LineString') || geometryType.endsWith('Polygon');
}

function getSelectionLineStrings(geometry) {
    switch (geometry.getType()) {
        case 'LineString':
            return [geometry];
        case 'MultiLineString':
            return geometry.getLineStrings();
        default:
            return [];
    }
}

// The middle of the line - of the longest part, for a multi-part one - or a point inside the area.
function getSelectionBadgePoint(geometry) {
    try {
        switch (geometry.getType()) {
            case 'LineString':
            case 'MultiLineString': {
                const lines = getSelectionLineStrings(geometry);
                if (lines.length === 0) {
                    return null;
                }

                const longest = lines.reduce((a, b) => b.getLength() > a.getLength() ? b : a);
                return new ol.geom.Point(longest.getCoordinateAt(0.5));
            }
            case 'Polygon':
                return new ol.geom.Point(geometry.getInteriorPoint().getCoordinates().slice(0, 2));
            case 'MultiPolygon':
                return new ol.geom.Point(geometry.getInteriorPoints().getPoint(0).getCoordinates().slice(0, 2));
            default:
                return null;
        }
    } catch {
        return null;
    }
}

// The green circle and tick a selected point is drawn as - and the badge in the middle of a
// selected line or polygon, which decorateSelectedFeatureStyles places.
function getSelectedFeatureStyle() {
    const selectedGreen = 'rgba(30, 185, 30, 0.95)';

    return new ol.style.Style({
        // Draw selection above coincident asset markers, regardless of source order.
        zIndex: 10000,
        image: new ol.style.Circle({
            declutterMode: 'none',
            radius: 9,
            fill: new ol.style.Fill({color: selectedGreen}),
            stroke: new ol.style.Stroke({color: 'white', width: 1})
        }),
        text: new ol.style.Text({
            declutterMode: 'none',
            text: '\u2713',
            fill: new ol.style.Fill({color: 'white'}),
            font: 'bold 13px sans-serif',
            offsetY: 0
        })
    });
}

// Everything the "would this be drawn" questions need that is the same for every feature on a map
// at this moment: the zoom, the settings, the sets they are asked about.
//
// Worked out once and handed down, because the questions below run for every feature on the map
// on every render - at a zoom where the whole dataset is grouped into a few hundred clusters that
// is tens of thousands of times, and re-reading the zoom and the settings for each one was most
// of what the pass actually did.
// What a render of the map costs, when anyone is asking.
//
// Turned on from the console with `amxMapProfile(true)` and off again with `amxMapProfile(false)`;
// off, the counters below cost a single property read per feature. Panning a map with it on logs
// one line per render: how long the styling took, how many features were styled, and how many
// cluster members were examined to count the bubbles. That is enough to say which of those is
// the expensive one rather than reasoning about it.
let mapProfiling = false;

const mapProfile = {
    renders: 0,
    since: 0,
    animate: false,
    viewAnimating: 0,
    viewInteracting: 0,
    tilesQueued: 0,
    styleMs: 0,
    itemStyles: 0,
    clusterStyles: 0,
    clusterMembers: 0,
    linearStyles: 0
};

function amxMapProfile(on) {
    mapProfiling = on !== false;

    mapProfile.renders = 0;
    mapProfile.since = 0;

    console.log(`Map profiling ${mapProfiling ? 'on - pan or zoom the map' : 'off'}.`);

    return mapProfiling;
}

// On window so it can be called from the browser console without knowing where it lives.
window.amxMapProfile = amxMapProfile;

function resetMapProfile() {
    mapProfile.styleMs = 0;
    mapProfile.itemStyles = 0;
    mapProfile.clusterStyles = 0;
    mapProfile.clusterMembers = 0;
    mapProfile.linearStyles = 0;
}

function reportMapProfile(elementId) {
    if (!mapProfiling) {
        return;
    }

    const itemsSource = getItemsLayerUnderlyingSource(elementId);
    const linearSource = getLinearItemsLayerUnderlyingSource(elementId);

    // Everything else with features in it - the map layer control's geo layers and the AMX
    // entity layers. Those hold icons too and are not clustered, so a map that feels heavy
    // despite the item layer being grouped is usually carrying its features here.
    let otherLayerFeatures = 0;

    for (const layer of maps[elementId]?.getLayers()?.getArray() ?? []) {
        const layers = layer instanceof ol.layer.Group ? layer.getLayers().getArray() : [layer];

        for (const candidate of layers) {
            const source = candidate.getSource?.();

            if (!source || typeof source.getFeatures !== 'function') {
                continue;
            }

            if (source === itemsSource || source === linearSource || candidate.getVisible() !== true) {
                continue;
            }

            otherLayerFeatures += source.getFeatures().length;
        }
    }

    // How often the map is repainting, and whether anything is still waiting to load. A vector
    // basemap costs the same again on every repaint, so a map that repaints while nobody is
    // touching it is the difference between slow and unusable - and this says which it is.
    const now = performance.now();

    mapProfile.renders++;

    if (mapProfile.since === 0) {
        mapProfile.since = now;
    }

    const seconds = (now - mapProfile.since) / 1000;
    const rate = seconds > 0 ? (mapProfile.renders / seconds).toFixed(1) : '?';

    // Why the map wanted this frame, from OpenLayers' own frame state - see the postrender
    // handler. "animating" means a layer asked for another frame; the view hints mean the view
    // itself is moving under an animation or the user's hand.
    const why = mapProfile.animate ? 'a layer wants another frame'
        : mapProfile.viewAnimating > 0 ? 'the view is animating'
            : mapProfile.viewInteracting > 0 ? 'the user is interacting'
                : 'nothing asked - one-off';

    console.log(
        `map render: ${rate}/s (${why}), ${mapProfile.styleMs.toFixed(1)}ms styling, ${mapProfile.tilesQueued} tiles queued` +
        ` | ${mapProfile.clusterStyles} clusters drawn (${mapProfile.clusterMembers} members counted)` +
        ` | ${mapProfile.itemStyles} item styles, ${mapProfile.linearStyles} linear styles` +
        ` | items source ${itemsSource?.getFeatures().length ?? 0}, linear ${linearSource?.getFeatures().length ?? 0}` +
        `, other visible layers ${otherLayerFeatures} (never clustered)`);

    resetMapProfile();
}

function getFeatureVisibilityContext(elementId) {
    const map = maps[elementId];
    if (!map) {
        return null;
    }

    return {
        map: map,
        elementId: elementId,
        settings: getMapSettings(map),
        zoomLevel: map.getView().getZoom(),
        resolution: map.getView().getResolution(),
        iconScale: getIconScale(map),
        loadedShapes: loadedShapesItemsId[elementId],
        linearExtents: getLinearItemExtents(elementId),
        awaitingSearch: isAwaitingSelectWithMapSearch(map),
        searchActive: isSelectWithMapSearchActive(map),
        hasHiddenIds: (map.itemIdsToHide?.length ?? 0) > 0,
        filterControl: mapControls?.[elementId]?.filterControl
    };
}

// Every rule that hides a feature outright, in one place.
//
// Lifted out of getItemsLayerStyle so that counting a cluster's members can ask the same question
// without building the styles it would then throw away - see isFeatureDrawn. One definition, so
// the two cannot answer differently.
function isFeatureHiddenInContext(context, featureAttributes) {
    if (!context) {
        return false;
    }

    const map = context.map;
    const zoomLevel = context.zoomLevel;
    // The picker draws nothing until the user has searched.
    if (context.awaitingSearch) {
        return true;
    }

    // A map with more items than it can draw at once used to hide every one of them below zoom
    // 15, which is why nothing appeared until the user had zoomed a long way in. Where clustering
    // is on there is no need: a cluster is one bubble to draw however many features are behind
    // it, so the map can show all of them from any zoom. Without clustering there is nothing to
    // draw them as, and the zoom floor is still the only thing standing between the user and
    // twenty thousand icons.
    if (zoomLevel < 15 && map.hideFeaturesUntilZoomedIn === true && map.mapOptions?.allowClustering !== true) {
        return true;
    }

    if (featureAttributes !== undefined) {
        if (!map.mapOptions.displayCompleted && featureAttributes.isCompleted !== undefined) {
            if (featureAttributes.isCompleted === true) {
                return true;
            }
        }

        // The linear features of an item - the run of road, the length of cable. Turned off
        // altogether, or held to a zoom, from the map settings.
        if (featureAttributes.isLinearHideableShape || featureAttributes.isHideableShape) {
            if (context.settings.linearFeaturesVisible === false) {
                return true;
            }

            if (!isWithinSettingZoom(zoomLevel, context.settings.linearFeaturesVisibleZoomLevel, true)) {
                return true;
            }
        }

        // Local assets - the shapes GetAdditionalShapes brings back for whatever is in view.
        // Drawn when they are switched on, or when the map was built to show them.
        //
        // Deliberately no zoom rule of its own. Which zoom local assets load at is
        // getAdditionalShapes' decision (LOCAL_ASSETS_MIN_ZOOM), and a second, lower floor here
        // could only ever throw away shapes the map had already been and fetched - which is what
        // it did on a forceShowLocalAssets picker, where the fetch has no zoom floor at all.
        if (featureAttributes.isHideableShape && !context.searchActive) {
            if (!map.mapOptions.shapesVisibility && map.mapOptions.forceShowLocalAssets !== true) {
                return true;
            }
        }
    }

    if (context.hasHiddenIds && featureAttributes !== undefined && map.itemIdsToHide.indexOf(featureAttributes.itemId) !== -1) {
        return true;
    }

    return false;
}

// The map's key filters. Kept apart from the rules above because it is asked later than they are:
// a text or markup feature is drawn before the filters get a say, and moving this earlier would
// quietly start hiding those.
function isFeatureFilteredOutInContext(context, featureAttributes) {
    if (!context?.filterControl) {
        return false;
    }

    if (featureAttributes?.filter === null || featureAttributes?.filter === undefined) {
        return false;
    }

    for (const key in featureAttributes.filter) {
        if (!context.filterControl.getFilterVisibility(key, featureAttributes.filter[key])) {
            return true;
        }
    }

    return false;
}

// Whether a feature would be drawn, without drawing it.
//
// Counting a cluster's members used to run the whole style function over every one of them and
// discard the result - a full styling pass over the map on every render, for two numbers on a
// bubble. This asks the same questions and stops at the answer.
//
// The tail mirrors the point branches of getItemsLayerStyle: keep the two together.
function isFeatureDrawn(feature, context) {
    if (!context) {
        return false;
    }

    const featureAttributes = feature.get('attributes');

    // What the user has picked is always on the map - the same first rule as the style function.
    if (isFeatureSelected(context.elementId, featureAttributes)) {
        return true;
    }

    if (isFeatureHiddenInContext(context, featureAttributes)) {
        return false;
    }

    if (featureAttributes?.mapTextConfig || featureAttributes?.markup) {
        return true;
    }

    if (isFeatureFilteredOutInContext(context, featureAttributes)) {
        return false;
    }

    if (feature.get('style') != null) {
        return true;
    }

    if (!isIconVisibleInContext(context, featureAttributes)) {
        return false;
    }

    return featureAttributes.baseColor
        || featureAttributes.icon != null
        || (featureAttributes.dotColor1 != null || featureAttributes.dotColor2 != null);
}

function isFeatureSelected(elementId, featureAttributes) {
    const selectedIds = selectedFeatureIdsPerMap[elementId];
    // What the areas of an area select take in, while it runs - see refreshPolygonSelection.
    const areaIds = polygonSelectionPreviewIds[elementId];

    if ((!selectedIds && !areaIds) || !featureAttributes?.itemId)
        return false;

    const itemId = featureAttributes.itemId.toLowerCase();

    return !!selectedIds?.has(itemId) || !!areaIds?.has(itemId);
}

function getItemsLayerStyle(feature, resolution) {
    let featureAttributes = feature.get('attributes');
    let elementId = featureAttributes.mapId;

    const profileStart = mapProfiling ? performance.now() : 0;

    if (mapProfiling) {
        if (feature.getGeometry() instanceof ol.geom.LineString || feature.getGeometry() instanceof ol.geom.MultiLineString) {
            mapProfile.linearStyles++;
        } else {
            mapProfile.itemStyles++;
        }
    }

    try {
        return getItemsLayerStyleInner(feature, resolution, featureAttributes, elementId);
    } finally {
        if (mapProfiling) {
            mapProfile.styleMs += performance.now() - profileStart;
        }
    }
}

function getItemsLayerStyleInner(feature, resolution, featureAttributes, elementId) {
    let map;

    if (featureAttributes) {
        if (elementId) {
            map = maps[elementId];
        }
    } else {
        map = getCurrentMap();
    }

    // Ahead of every rule below that can hide a feature: what the user has picked has to stay on
    // the map, or there is no way to see the selection or to click it off again.
    if (isFeatureSelected(elementId, featureAttributes)) {
        if (!isLinearOrAreaGeometry(feature.getGeometry())) {
            return getSelectedFeatureStyle();
        }

        const ownStyle = feature.get('style');
        const ownStyles = (Array.isArray(ownStyle) ? [...ownStyle] : [ownStyle]).filter(Boolean);
        return decorateSelectedFeatureStyles(feature, ownStyles);
    }

    let mapView = map.getView();
    let mapProjection = getWKIDCodeFromProjection(mapView.getProjection().getCode());

    let zoomLevel = mapView.getZoom();

    // Built once here and handed to each of the questions below, rather than each of them
    // working the same answers out again.
    const visibilityContext = getFeatureVisibilityContext(elementId);

    if (isFeatureHiddenInContext(visibilityContext, featureAttributes)) {
        return [];
    }

    if (featureAttributes !== undefined && featureAttributes !== null) {
        if (featureAttributes.mapTextConfig) {
            return getMapTextConfigStyle(featureAttributes.mapTextConfig, resolution, feature, map);
        }

        if (featureAttributes.markup) {
            return getMarkupStyle(featureAttributes, feature);
        }

        if (isFeatureFilteredOutInContext(visibilityContext, featureAttributes)) {
            return [];
        }
    }

    if (feature.get('style') != null) {
        let style = Array.isArray(feature.get('style')) ? feature.get('style')[2] : feature.get('style');
        if (featureAttributes !== undefined && featureAttributes.labelText != null) {
            // A feature from the tenant's own feature service is labelled whatever the labels
            // toggle says - getText labels unconditionally when given a layer - unless the map
            // was built to have layer labels follow the toggle, in which case it is treated like
            // any other item and the toggle decides.
            var layer;
            if (featureAttributes.isAmxFeatureServiceFeature && map?.mapOptions?.layerLabelsFollowToggle !== true) {
                layer = itemsLayers[elementId];//feature.getLayer(map);
            }

            let labelText = getText(featureAttributes, layer, map);

            // A feature service's labels are layer labels, so they follow the layer's Show labels
            // and wait for its label zoom ("Label min zoom level"), as an Esri or WFS layer's do in
            // getFeatureLabels.
            if (featureAttributes.isAmxFeatureServiceFeature
                && !isAmxLayerLabelShown(feature, zoomLevel)) {
                labelText = '';
            }

            // ...and keep the layer label spacing too.
            if (featureAttributes.isAmxFeatureServiceFeature
                && !hasLayerLabelRoom(map, feature, resolution, labelText, LABEL_TEXT_FONT, {
                    ...getLabelPlacement(getMapSettings(map).dataLabelsPosition),
                    padding: LABEL_TEXT_PADDING
                })) {
                labelText = '';
            }

            style.setText(createLabelTextStyle(map, labelText));
        }

        var geometry = feature.getGeometry();
        if (geometry.getCoordinates !== undefined && geometry instanceof ol.geom.LineString) {
            var styles = Array.isArray(feature.get('style')) ? feature.get('style').slice(0, 3) : [style];
            var thickness = styles[0].getStroke().getWidth();
            var lineStyle = styles.length > 1 ? styles[2] : styles[0];

            if (feature.get('capLine')) {

                try {
                    const coordinates = geometry.getCoordinates();
                    const capLengthPixels = 10;
                    const capLength = capLengthPixels * resolution; // resolution = map units per pixel, works for any CRS
                    const capWidth = 4;

                    // ----- START CAPLINE -----
                    const start = coordinates[0];
                    const next = coordinates[1];

                    const dx1 = next[0] - start[0];
                    const dy1 = next[1] - start[1];
                    const angle1 = Math.atan2(dy1, dx1);

                    // Perpendicular angle
                    const perpAngle1 = angle1 + Math.PI / 2;

                    const offsetX1 = Math.cos(perpAngle1) * capLength / 2;
                    const offsetY1 = Math.sin(perpAngle1) * capLength / 2;

                    const capLineStart = [
                        [start[0] - offsetX1, start[1] - offsetY1],
                        [start[0] + offsetX1, start[1] + offsetY1]
                    ];

                    styles.push(new ol.style.Style({
                        geometry: new ol.geom.LineString(capLineStart),
                        stroke: new ol.style.Stroke({
                            color: 'black',
                            width: capWidth
                        })
                    }));

                    // ----- END CAPLINE -----
                    const end = coordinates[coordinates.length - 1];
                    const prev = coordinates[coordinates.length - 2];

                    const dx2 = end[0] - prev[0];
                    const dy2 = end[1] - prev[1];
                    const angle2 = Math.atan2(dy2, dx2);

                    const perpAngle2 = angle2 + Math.PI / 2;

                    const offsetX2 = Math.cos(perpAngle2) * capLength / 2;
                    const offsetY2 = Math.sin(perpAngle2) * capLength / 2;

                    const capLineEnd = [
                        [end[0] - offsetX2, end[1] - offsetY2],
                        [end[0] + offsetX2, end[1] + offsetY2]
                    ];

                    styles.push(new ol.style.Style({
                        geometry: new ol.geom.LineString(capLineEnd),
                        stroke: new ol.style.Stroke({
                            color: 'black',
                            width: capWidth
                        })
                    }));
                } catch (error) {
                    console.error("Error drawing caplines:", error);
                }
            } else {
                if (thickness < 5) {
                    var circleStyle = new ol.style.Circle({
                        fill: lineStyle.getFill(),
                        stroke: lineStyle.getStroke(),
                        radius: thickness > 1 ? thickness - 1 : 1,
                        declutterMode: ITEM_IMAGE_DECLUTTER_MODE
                    });

                    styles.push(new ol.style.Style({
                        geometry: new ol.geom.Point(geometry.getCoordinates()[0]),
                        image: circleStyle
                    }));
                    styles.push(new ol.style.Style({
                        geometry: new ol.geom.Point(geometry.getCoordinates()[geometry.getCoordinates().length - 1]),
                        image: circleStyle
                    }));
                }
            }

            if (feature.getGeometry() instanceof ol.geom.LineString && feature.get('hasArrows')) {

                var counter = 0;
                var totalPoints = geometry.getCoordinates().length - 1;
                var increment = totalPoints <= 10 ? 2 : 5;

                geometry.forEachSegment(async function (start, end) {

                    var arrowSrc = await getIconSrc(elementId, "map_arrow_w");
                    if (!arrowSrc)
                        return;

                    var dx = end[0] - start[0];
                    var dy = end[1] - start[1];
                    var rotation = Math.atan2(dy, dx);

                    if (totalPoints === 1) {
                        var midPoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
                        styles.push(new ol.style.Style({
                            geometry: new ol.geom.Point(midPoint),
                            image: new ol.style.Icon({
                                declutterMode: ITEM_IMAGE_DECLUTTER_MODE,
                                src: arrowSrc,
                                anchor: [0.75, 0.5],
                                rotateWithView: true,
                                rotation: -rotation,
                                scale: (zoomLevel * 0.04 < 1 ? 0.04 * zoomLevel : 1),
                                color: lineStyle.getFill().getColor(),
                            })
                        }));

                        if (ol.sphere.getLength(geometry, {projection: 'EPSG:' + mapProjection}) > 100) {
                            var quarterPoint1 = [(start[0] + midPoint[0]) / 2, (start[1] + midPoint[1]) / 2];
                            var quarterPoint2 = [(midPoint[0] + end[0]) / 2, (midPoint[1] + end[1]) / 2];

                            styles.push(new ol.style.Style({
                                geometry: new ol.geom.Point(quarterPoint1),
                                image: new ol.style.Icon({
                                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE,
                                    src: arrowSrc,
                                    anchor: [0.75, 0.5],
                                    rotateWithView: true,
                                    rotation: -rotation,
                                    scale: (zoomLevel * 0.04 < 1 ? 0.04 * zoomLevel : 1),
                                    color: lineStyle.getFill().getColor(),
                                })
                            }));
                            styles.push(new ol.style.Style({
                                geometry: new ol.geom.Point(quarterPoint2),
                                image: new ol.style.Icon({
                                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE,
                                    src: arrowSrc,
                                    anchor: [0.75, 0.5],
                                    rotateWithView: true,
                                    rotation: -rotation,
                                    scale: (zoomLevel * 0.04 < 1 ? 0.04 * zoomLevel : 1),
                                    color: lineStyle.getFill().getColor(),
                                })
                            }));
                            styles.push(new ol.style.Style({
                                geometry: new ol.geom.Point(end),
                                image: new ol.style.Icon({
                                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE,
                                    src: arrowSrc,
                                    anchor: [0.75, 0.5],
                                    rotateWithView: true,
                                    rotation: -rotation,
                                    scale: (zoomLevel * 0.04 < 1 ? 0.04 * zoomLevel : 1),
                                    color: lineStyle.getFill().getColor(),
                                })
                            }));
                        }
                    } else if ((counter % increment === 0) || (counter === totalPoints - 1)) {
                        // arrows
                        if (zoomLevel > 12) {//draw arrows only when zoomLevel is higher than 12 to avoid pile of arrows display
                            styles.push(new ol.style.Style({
                                geometry: new ol.geom.Point(end),
                                image: new ol.style.Icon({
                                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE,
                                    src: arrowSrc,
                                    anchor: [0.75, 0.5],
                                    rotateWithView: true,
                                    rotation: -rotation,
                                    scale: (zoomLevel * 0.04 < 1 ? 0.04 * zoomLevel : 1),
                                    color: lineStyle.getFill().getColor(),
                                })
                            }));
                        }
                    }
                    counter++;
                });
            }

            if (featureAttributes.customIconValue !== undefined && featureAttributes.customIconValue !== null && featureAttributes.customIconValue !== '') {
                let customIcon;

                let customIconTextStyle = new ol.style.Text({
                    text: featureAttributes.customIconValue,
                    fill: new ol.style.Fill({
                        color: [0, 0, 0, 1]
                    }),
                    font: 'bold 14px sans-serif'
                });

                switch (featureAttributes.customIconShape) {
                    case 'square':
                        break;
                    default:
                        customIcon = new ol.style.Circle({
                            fill: customIconFill,
                            stroke: customIconStroke,
                            radius: 30,
                            declutterMode: ITEM_IMAGE_DECLUTTER_MODE
                        });
                        break;
                }

                let customIconGeometry;

                if (geometry instanceof ol.geom.MultiPoint) {
                    let points = geometry.getPoints();

                    customIconGeometry = points[points.length / 2];
                } else if (geometry instanceof ol.geom.Point) {
                    customIconGeometry = geometry
                } else if (geometry instanceof ol.geom.LineString) {
                    customIconGeometry = new ol.geom.Point(geometry.getCoordinateAt(0.5))
                } else if (geometry instanceof ol.geom.Polygon) {
                    customIconGeometry = new ol.geom.Point(geometry.getInteriorPoint().getCoordinates());
                }

                let customIconStyle = new ol.style.Style({
                    geometry: customIconGeometry,
                    image: customIcon,
                    text: customIconTextStyle,
                });

                styles.push(customIconStyle)
            }

            return styles;
        }

        return feature.get('style');
    }

    var text = getText(featureAttributes, undefined, map);
    var resultStyle = [];

    if (isIconVisibleInContext(visibilityContext, featureAttributes)) {
        // Space between item labels: a label that would crowd another item's is dropped until the
        // map zooms in, and the item keeps its icon. Asked only for an item that is drawn, so an
        // item the map is not showing takes no room.
        if (text && !hasItemLabelRoom(map, elementId, feature, resolution, text)) {
            text = '';
        }

        if (featureAttributes.baseColor) {
            resultStyle.push(new ol.style.Style({
                image: new ol.style.Circle({
                    radius: 8,
                    fill: new ol.style.Fill({
                        color: featureAttributes.baseColor
                    }),
                    stroke: new ol.style.Stroke({
                        color: featureAttributes.baseColor,
                        thickness: 1,
                    }),
                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE
                }),
                // A feature drawn as a plain coloured circle read a text style that only ever
                // existed inside the shape branch above, so it was always undefined here and
                // these went unlabelled however the label control was set.
                text: text ? createLabelTextStyle(map, text) : null,
            }));
        } else if (feature.get('attributes').icon != null) {
            let icon = featureAttributes.icon;

            var assetStyle = iconStyleCache[icon];

            /*assetStyle.getImage().setScale((zoomLevel * 0.08 < 2 ? 0.08 * zoomLevel : 2) + mapSettingsControl.getScaleVariation());
            assetStyle.getText().setScale((zoomLevel * 0.05 < 1 ? 0.05 * zoomLevel : 1) + mapSettingsControl.getScaleVariation() / 2);
            assetStyle.getText().setText(text);*/

            // The cache is filled as icons are fetched, so a style can be missing here - a
            // failed or still-running fetch. Reading through it threw out of the style
            // function, which took the rest of the layer's features down with it.
            if (assetStyle) {
                // The label as the settings have it, not only its text - see updateLabelTextStyle.
                updateLabelTextStyle(assetStyle.getText(), map, text);

                // Icon size, from the map settings. Set here rather than on the cached style
                // because the cache is shared by every map on the page, and two maps can be
                // showing the same icon at different sizes - OpenLayers reads the style as it
                // draws each feature, so the size the map asked for is the size it gets.
                setIconScale(assetStyle.getImage(), map);

                resultStyle.push(assetStyle);
            }
        }

        if (featureAttributes.dotColor1 != null && featureAttributes.dotColor2 != null) {
            resultStyle.push(new ol.style.Style({
                image: new ol.style.Icon({
                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE,
                    anchor: [-1, 1.5],
                    anchorXUnits: 'fraction',
                    anchorYUnits: 'fraction',
                    opacity: 1,
                    color: featureAttributes.dotColor1,
                    src: 'content/images/icons/circle16.png',
                    scale: zoomLevel * 0.04 < 1 ? 0.04 * zoomLevel : 1

                })
            }));
            resultStyle.push(new ol.style.Style({
                image: new ol.style.Icon({
                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE,
                    anchor: [-1, 0.45],
                    anchorXUnits: 'fraction',
                    anchorYUnits: 'fraction',
                    opacity: 1,
                    color: featureAttributes.dotColor2,
                    src: 'content/images/icons/circle16.png',
                    scale: zoomLevel * 0.04 < 1 ? 0.04 * zoomLevel : 1
                })
            }));
        } else if (featureAttributes.dotColor1 != null && featureAttributes.dotColor2 == null) {
            resultStyle.push(new ol.style.Style({
                image: new ol.style.Icon({
                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE,
                    anchor: [-0.75, 1.5],
                    anchorXUnits: 'fraction',
                    anchorYUnits: 'fraction',
                    opacity: 1,
                    color: featureAttributes.dotColor1,
                    src: 'content/images/icons/circle16.png',
                    scale: zoomLevel * 0.04 < 1 ? 0.04 * zoomLevel : 1
                })
            }));
        } else if (featureAttributes.dotColor1 == null && featureAttributes.dotColor2 != null) {
            resultStyle.push(new ol.style.Style({
                image: new ol.style.Icon({
                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE,
                    anchor: [-0.75, 1.5],
                    anchorXUnits: 'fraction',
                    anchorYUnits: 'fraction',
                    opacity: 1,
                    color: featureAttributes.dotColor2,
                    src: 'content/images/icons/circle16.png',
                    scale: zoomLevel * 0.04 < 1 ? 0.04 * zoomLevel : 1
                })
            }));
        }
    }
    return resultStyle;
}

function getAssetLinearItemVisibility(attributes, elementId) {
    let map = maps[elementId];
    let loadedShapes = loadedShapesItemsId[elementId];

    const shapeLoaded = loadedShapes && loadedShapes.has(getLoadedItemKey(attributes.itemId));
    if (shapeLoaded) {
        return true;
    }

    return false;
}

function isIconVisibleInContext(context, attributes) {
    if (!context || !attributes.icon || attributes == '') {
        return false;
    }

    const settings = context.settings;
    const zoomLevel = context.zoomLevel;

    let loadedShapes = context.loadedShapes;

    // The key is only worth building where there is a set to look it up in. A shape the map
    // settings are not drawing just now (switched off, or held to a closer zoom) is no shape at
    // all as far as the icon goes: the icon is then the only thing showing where the item is.
    // Only an asset's own icon gives way to its shape: a record on an asset (a work required
    // being added in DataView, a defect on a road) is not the line, so its icon always shows.
    const itemKey = loadedShapes?.size > 0 && isAssetIcon(attributes) ? getLoadedItemKey(attributes.itemId) : null;
    const shapeLoaded = itemKey !== null && loadedShapes.has(itemKey) && areLinearFeaturesDrawn(context);
    if (shapeLoaded) {
        // The item has linear features drawn for it, so its icon is the second way of saying
        // where it is. With "Its icon" on, as on the old web, the user says how far out it is
        // drawn: every icon out to their zoom, none past it, whatever the shape looks like.
        if (settings.itemLinearIconsVisible === true) {
            return isWithinSettingZoom(zoomLevel, settings.itemLinearIconsVisibleZoomLevel, false);
        }

        // Off (the default), the icon stands in for the shape only while the shape is too small
        // to make out, and gives way once the shape can be seen for itself. A zoom alone cannot
        // say when that is: zoomed out to a county a trunk road is already a clear line across
        // the screen while a gully's few metres of pipe are a dot under the icon at street level,
        // so the icon goes by how big its own shape is drawn now.
        return !isLinearShapePlainlyVisible(context, itemKey);
    }

    // An item with no linear features of its own has nothing but its icon, so this gate is only
    // a zoom floor where the user has set one.
    return isWithinSettingZoom(zoomLevel, settings.itemIconsVisibleZoomLevel, true);
}

// Whether a point is an asset's own icon, the only kind its asset's linear features stand in for.
// A point that says nothing of its entity (local assets, older callers) is taken as an asset's.
function isAssetIcon(attributes) {
    const entityKey = attributes?.entityKey;
    return !entityKey || String(entityKey).toLowerCase() === 'assets';
}

// How many icon widths an item's shape has to span on screen before it is plain to see without
// its icon. Shorter than that, the line is a stub the icon sits on or covers, and taking the icon
// away would leave the user hunting for a few pixels of colour. Once the shape reaches past the
// icon on both sides it is the better marker of the two.
const LINEAR_SHAPE_CLEAR_OF_ICON = 1.5;

// Whether an item's linear features are drawn big enough, at the map's resolution now, to show
// where it is without its icon: the diagonal of their extent against the icon's drawn width - so a
// road running corner to corner counts at its length, not at the longer side of its box.
// An item whose shape the map holds no extent for (pruned out of view, or not yet arrived) is not.
function isLinearShapePlainlyVisible(context, itemKey) {
    const extent = context?.linearExtents?.get(itemKey);
    const resolution = context?.resolution;
    if (!extent || !(resolution > 0)) {
        return false;
    }

    const spanPixels = Math.hypot(extent[2] - extent[0], extent[3] - extent[1]) / resolution;
    const iconPixels = ITEM_ICON_WIDTH * (context.iconScale ?? 1);

    return spanPixels >= LINEAR_SHAPE_CLEAR_OF_ICON * iconPixels;
}

// The keys an item's linear feature answers to, as loadedShapesItemsId keys them: its item id and
// the key it was loaded under (populateShapes). Never the id of a record on the asset (a work
// required or defect on a road): that record's icon is not the asset's, so the asset's shape must
// not hide it - only an icon with the shape's own asset id gives way to it.
function getLinearFeatureItemKeys(attributes) {
    const keys = [];
    for (const id of [attributes?.itemId, attributes?.linearItemKey]) {
        const key = getLoadedItemKey(id);
        if (key !== null && !keys.includes(key)) {
            keys.push(key);
        }
    }

    return keys;
}

// Each item's linear features on the map as one extent per item, in the view's projection. The
// items layer asks this for every icon on every frame, so it is worked out again only when the
// linear source changes - every add, removal, prune or clear moves its revision on.
function getLinearItemExtents(elementId) {
    const source = getLinearItemsLayerUnderlyingSource(elementId);
    if (!source) {
        return null;
    }

    const revision = source.getRevision();
    const cached = linearItemExtents[elementId];
    if (cached && cached.source === source && cached.revision === revision) {
        return cached.extents;
    }

    const extents = new Map();
    for (const feature of source.getFeatures()) {
        const geometry = feature.getGeometry();
        if (!geometry) {
            continue;
        }

        const featureExtent = geometry.getExtent();
        for (const key of getLinearFeatureItemKeys(feature.get('attributes'))) {
            const known = extents.get(key);
            if (known) {
                ol.extent.extend(known, featureExtent);
            } else {
                extents.set(key, ol.extent.clone(featureExtent));
            }
        }
    }

    linearItemExtents[elementId] = {source, revision, extents};

    return extents;
}

function getClusterItemsLayerStyle(feature, resolution, elementId) {
    let map = maps[elementId];
    let zoomLevel = map.getView().getZoom();

    // The picker draws nothing until the user has searched. A map with too many items to draw is
    // no longer hidden here: those are the maps clustering exists for, and the bubble below costs
    // one style however many features are behind it.
    if (isAwaitingSelectWithMapSearch(map)) {
        return [];
    }

    if (zoomLevel < 15 && map.hideFeaturesUntilZoomedIn === true && map.mapOptions?.allowClustering !== true) {
        return [];
    }

    let features = feature.get('features');

    // With grouping on, every item is a numbered circle. At distance 0 only coincident features
    // share a wrapper; draw their icon and a count badge so the other items remain discoverable.
    if (features?.length) {
        const grouped = itemLayerSources[elementId]?.itemsLayerIsClustered === true;
        if (!grouped && features.length === 1) {
            return getItemsLayerStyle(features[0], resolution);
        }

        const visibleFeatures = getVisibleClusterFeatures(features, elementId);

        if (visibleFeatures.length === 0)
            return [];

        const selectedFeature = visibleFeatures.find(member => isFeatureSelected(elementId, member.get('attributes')));
        if (!grouped) {
            // Coincident assets share one marker. Prefer the selected member over source order.
            const iconStyles = getItemsLayerStyle(selectedFeature ?? visibleFeatures[0], resolution);
            if (visibleFeatures.length === 1 || !map.showOverlapCountPills) {
                return iconStyles;
            }

            const styles = (Array.isArray(iconStyles) ? iconStyles : [iconStyles]).filter(Boolean);
            if (styles.length === 0) {
                return iconStyles;
            }
            const coordinates = visibleFeatures[0].getGeometry().getCoordinates();
            const offset = map.overlapBadgeLayout?.get(`${coordinates[0]}|${coordinates[1]}`);
            return offset ? [...styles, getOverlapCountStyle(visibleFeatures.length, map, offset)] : iconStyles;
        }

        // A group of one is just the item: draw it as itself rather than a bubble reading 1.
        // Clicks and the hover preview already treat a single-member cluster as its item.
        if (visibleFeatures.length === 1) {
            return getItemsLayerStyle(visibleFeatures[0], resolution);
        }

        if (mapProfiling) {
            mapProfile.clusterStyles++;
            mapProfile.clusterMembers += features.length;
        }

        // The count is of what the user can actually get to, not of what the source grouped:
        // a bubble reading 6 that lists 2 is a bubble that has lied about the map.
        const clusterStyle = getClusterStyle(visibleFeatures.length);
        if (selectedFeature) {
            const selectionStyle = getSelectedFeatureStyle();
            selectionStyle.getImage().setDisplacement([18, 18]);
            selectionStyle.getText().setOffsetX(18);
            selectionStyle.getText().setOffsetY(-18);
            return [clusterStyle, selectionStyle];
        }
        return clusterStyle;
    }

    if (features !== undefined)
        feature = features[0];

    return getItemsLayerStyle(feature, resolution);
}

// How big item icons are drawn, from the map settings. The setting adds to the icon's standard
// width (ITEM_ICON_WIDTH) rather than replacing it, so 0 is the icon at that width.
function getIconScale(map) {
    const scale = getMapSettings(map).iconScale;

    return 1 + (scale === undefined || scale === null ? 0 : scale);
}

// The scale that draws this icon at ITEM_ICON_WIDTH: what OpenLayers works out for itself from
// the style's `width` option once the image has loaded. Undefined until then - the image's
// natural size is not known before it arrives.
function getIconBaseScale(image) {
    const size = image.getSize();

    if (!size || !size[0]) {
        return undefined;
    }

    return ITEM_ICON_WIDTH / size[0];
}

// Sizes an icon for this map: the standard width, times whatever the user has asked for.
//
// The icon styles are built with `width: ITEM_ICON_WIDTH`, which OpenLayers keeps as a scale of
// width / natural width. Setting the scale here used to replace that with the user's factor on
// its own, so a 1 (no change asked for) put every icon back to its natural size - fine for a
// 25px glyph, but a sign face or bridge drawing uploaded at full size then covered half the map.
// The user's factor is applied on top of the base scale instead.
//
// Before the image has loaded there is no base to work from, so the style is left alone and
// the width option's own load handler sets it; the next render through here picks it up.
function setIconScale(image, map) {
    if (!image) {
        return;
    }

    const baseScale = getIconBaseScale(image);

    if (baseScale === undefined) {
        return;
    }

    const scale = baseScale * getIconScale(map);

    if (image.getScale() !== scale) {
        image.setScale(scale);
    }
}

// The features of a cluster that are actually drawn - the styling rules hide completed items,
// filtered-out items and shapes already loaded, and none of those should be counted in the
// bubble or offered in its list. Asked through isFeatureDrawn rather than by styling each one:
// this runs for every feature on the map on every render, and building styles to throw away was
// most of what made a busy map heavy to pan.
function getVisibleClusterFeatures(features, elementId) {
    if (!features?.length) {
        return [];
    }

    // Once for the whole cluster, not once per member.
    const context = getFeatureVisibilityContext(elementId);

    return features.filter((feature) => {
        try {
            return isFeatureDrawn(feature, context);
        } catch {
            return false;
        }
    });
}

// The cluster marker: a solid disc in the app's map blue with a translucent ring of the same
// colour around it, and the count in white across the middle.
//
// The ring is the whole of the difference from the flat circle this replaced. It is drawn as a
// wide stroke rather than a second style, so a cluster is still one image to render however many
// of them are on screen - and because a stroke straddles the edge it gives the disc a soft
// outside without a hard second border, which is what made the old one look like a sticker.
//
// Size says how many. A cluster of eight and a cluster of eight hundred used to be the same
// circle with different numbers in it, so the shape of the data was only readable by stopping to
// read. The tiers step rather than scale smoothly: a marker that changes size continuously as
// features load reads as movement on the map.
const clusterMarkerColour = '41, 128, 185'; // #2980b9, the map's own blue - see .custom-map-control
const clusterMarkerTiers = [
    {upTo: 10, radius: 13, font: 'bold 11px sans-serif'},
    {upTo: 50, radius: 16, font: 'bold 12px sans-serif'},
    {upTo: 250, radius: 19, font: 'bold 13px sans-serif'},
    {upTo: Infinity, radius: 23, font: 'bold 14px sans-serif'}
];

// One style per tier, built once and kept: the count is the only thing that changes between
// clusters of the same size, and OpenLayers reads the style as it draws each one.
const clusterStyleCache = {};
const overlapCountStyleCache = new Map();

// A small count beside the normal icon signals that several items occupy the exact same point.
// Use the host's theme colours so the badge stays readable over any basemap in light or dark mode.
function getOverlapBadgeRadius(count) {
    const length = (count > 999 ? '999+' : String(count)).length;
    return length > 3 ? 17 : length > 2 ? 14 : length > 1 ? 12 : 10;
}

function getOverlapCountStyle(count, map, offset) {
    const theme = getComputedStyle(map.getTargetElement());
    const body = getComputedStyle(document.body);
    const background = theme.getPropertyValue('--bg-card').trim() || body.backgroundColor;
    const foreground = theme.getPropertyValue('--text-primary').trim() || body.color;
    const border = theme.getPropertyValue('--border-interactive').trim() || foreground;
    const label = count > 999 ? '999+' : String(count);
    const key = `${label}|${background}|${foreground}|${border}|${offset[0]}|${offset[1]}`;
    let style = overlapCountStyleCache.get(key);

    if (!style) {
        const radius = getOverlapBadgeRadius(count);
        style = new ol.style.Style({
            image: new ol.style.Circle({
                radius,
                displacement: [offset[0], -offset[1]],
                fill: new ol.style.Fill({color: background}),
                stroke: new ol.style.Stroke({color: border, width: 1.5})
            }),
            text: new ol.style.Text({
                text: label,
                font: 'bold 11px sans-serif',
                offsetX: offset[0],
                offsetY: offset[1],
                fill: new ol.style.Fill({color: foreground})
            })
        });
        overlapCountStyleCache.set(key, style);
    }

    return style;
}

function getClusterStyle(count) {
    const tier = clusterMarkerTiers.find((candidate) => count < candidate.upTo) ?? clusterMarkerTiers[clusterMarkerTiers.length - 1];

    let style = clusterStyleCache[tier.radius];

    if (!style) {
        style = new ol.style.Style({
            image: new ol.style.Circle({
                radius: tier.radius,
                fill: new ol.style.Fill({
                    color: `rgba(${clusterMarkerColour}, 1)`
                }),
                stroke: new ol.style.Stroke({
                    color: `rgba(${clusterMarkerColour}, 0.35)`,
                    width: 8
                }),
                declutterMode: ITEM_IMAGE_DECLUTTER_MODE
            }),
            text: new ol.style.Text({
                font: tier.font,
                fill: new ol.style.Fill({
                    color: '#ffffff'
                })
            })
        });

        clusterStyleCache[tier.radius] = style;
    }

    // Four figures and up would spill over the disc, and the exact number stops being the point
    // long before then - it is "a lot here, zoom in" either way.
    style.getText().setText(count > 999 ? '999+' : count.toString());

    return style;
}

async function markWhat3Words(r, wkId, map, elementId) {
    if (r.SearchMethod === "what3words") {
        try {

        } catch (e) {

        }
        var geometry;
        if (wkId != map.mapOptions.mapSource.wkId) {
            geometry = new ol.geom.Point(transformCoordsWkId([r.XCoordinate, r.YCoordinate], wkId, map.mapOptions.mapSource.wkId != 0 ? map.mapOptions.mapSource.wkId : 3857));
        } else if (map.appWkId != map.mapOptions.mapSource.wkId) {
            geometry = new ol.geom.Point(transformCoordsWkId([r.XCoordinate, r.YCoordinate], map.appWkId, map.mapOptions.mapSource.wkId));
        } else {
            geometry = new ol.geom.Point([r.XCoordinate, r.YCoordinate]);
        }
        var icon = "what3words";
        const iconFeature = new ol.Feature({
            geometry: geometry,
            name: "what3words",
            attributes: {
                name: "what3words",
                text: "",
                mapId: elementId,
                icon: icon,
                dontShowOpenLink: true
            }
        });
        iconFeature.setId('what-3-words');
        let linearItemsLayer = getLinearItemsLayerUnderlyingSource(elementId);
        if (linearItemsLayer) {
            if (iconStyleCache[icon] === undefined) {
                await getItemIconImage(elementId, icon).then((iconImage) => {
                    if (!iconImage)
                        return;

                    let assetTextStyleFill = new ol.style.Fill({color: '#FFFFFF'});
                    let assetTextStyleBkgrFill = new ol.style.Fill({color: '#871F78'});
                    let assetTextStyle = new ol.style.Text({
                        fill: assetTextStyleFill,
                        backgroundFill: assetTextStyleBkgrFill,
                        padding: [2, 15, 2, 15],
                        backgroundStroke: new ol.style.Stroke({
                            color: '#000000',
                            width: 1.5
                        }),
                        font: 'bolder 14px sans-serif',
                        textAlign: 'center',
                        textBaseline: 'bottom',
                        offsetY: -8,
                    });

                    const iconStyle = new ol.style.Style({
                        image: new ol.style.Icon({
                            ...itemIconImageOptions(iconImage),
                            anchor: [0.5, 0.5],
                            anchorXUnits: 'fraction',
                            anchorYUnits: 'fraction',
                            width: ITEM_ICON_WIDTH,
                            opacity: 1,
                        }),
                        text: assetTextStyle
                    });

                    iconStyleCache[icon] = iconStyle;
                    let feature = linearItemsLayer.getFeatureById('what-3-words');
                    if (feature) {
                        linearItemsLayer.removeFeature(feature);
                    }
                    map.hideFeaturesUntilZoomedIn = false;
                    linearItemsLayer.addFeature(iconFeature);
                    showWhat3WordsMarker = true;
                });
            } else {
                let feature = linearItemsLayer.getFeatureById('what-3-words');
                if (feature) {
                    linearItemsLayer.removeFeature(feature);
                }
                map.hideFeaturesUntilZoomedIn = false;
                linearItemsLayer.addFeature(iconFeature);
                showWhat3WordsMarker = true;
            }
        }
    }
}

// What GetIconSrc has answered for each icon key, kept as the promise of it so each key crosses to
// .NET once. It used to be asked for every point of a shape that draws an icon, one after another,
// and for every segment of an arrowed line each time the line's style ran - a call into .NET each.
// The server keeps the same answers (IconService), and the map keeps the styles built from them
// for as long (iconStyleCache), so this is no staler than they are and is dropped with them. A call
// that fails is not kept, so the icon is asked for again next time.
let iconSrcCache = new Map();

async function getIconSrc(elementId, icon) {
    let objectReference = dotnetObjectRef[elementId];
    if (!objectReference)
        return null;

    let pending = iconSrcCache.get(icon);
    if (!pending) {
        pending = objectReference.invokeMethodAsync('GetIconSrc', icon);
        iconSrcCache.set(icon, pending);
        pending.catch(() => {
            if (iconSrcCache.get(icon) === pending) {
                iconSrcCache.delete(icon);
            }
        });
    }

    return pending;
}

// Item icons are drawn from a copy the size the map draws them, not from the image as uploaded.
//
// Icon images run from 16px glyphs to full-size sign faces and bridge drawings, and every one is
// drawn ITEM_ICON_WIDTH wide (setIconScale). Handed the upload itself, the browser shrank it on
// every draw of every item: a trace of zooming a record's map spent ~430ms a frame waiting on the
// GPU while a dozen icon images were decoded and sent to it again each frame - too big together
// for its image cache to keep. So each icon is drawn once, onto a canvas as wide as the largest
// it is ever drawn (the Icon size setting doubles it at most, times the screen's pixel density),
// and OpenLayers is given that. An image already that small is left as it was, as is one that
// can't be read back (another site's), and an SVG is always drawn once: the browser redraws an
// SVG from its markup every time it is drawn.
const ITEM_ICON_MAX_SCALE = 2;

// The promise of each icon key's image - the canvas, or the source when it is used as it is.
// Dropped with iconSrcCache. A failure is not kept, so the icon is asked for again next time.
let itemIconImageCache = new Map();

function getItemIconImage(elementId, icon) {
    let pending = itemIconImageCache.get(icon);
    if (!pending) {
        pending = getIconSrc(elementId, icon).then(src => src ? drawItemIconAtMapSize(src) : src);
        itemIconImageCache.set(icon, pending);
        pending.catch(() => {
            if (itemIconImageCache.get(icon) === pending) {
                itemIconImageCache.delete(icon);
            }
        });
    }

    return pending;
}

function getItemIconDrawnWidth() {
    return Math.ceil(ITEM_ICON_WIDTH * ITEM_ICON_MAX_SCALE * Math.max(1, window.devicePixelRatio || 1));
}

async function drawItemIconAtMapSize(src) {
    if (typeof src !== 'string' || !isItemIconReadable(src)) {
        return src;
    }

    const image = new Image();
    image.src = src;

    try {
        await image.decode();
    } catch {
        // OpenLayers loads it, or fails to, as it always did.
        return src;
    }

    const width = getItemIconDrawnWidth();
    const isSvg = /^data:image\/svg/i.test(src) || /\.svg(\?|#|$)/i.test(src);

    if (!image.naturalWidth || !image.naturalHeight || (!isSvg && image.naturalWidth <= width)) {
        return src;
    }

    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = Math.max(1, Math.round(width * image.naturalHeight / image.naturalWidth));

    const context = canvas.getContext('2d');
    context.imageSmoothingQuality = 'high';
    context.drawImage(image, 0, 0, canvas.width, canvas.height);

    return canvas;
}

// A canvas drawn from another site's image could not be read back, and the map's hit detection
// and export read the map's canvas.
function isItemIconReadable(src) {
    if (src.startsWith('data:') || src.startsWith('blob:')) {
        return true;
    }

    try {
        return new URL(src, document.baseURI).origin === window.location.origin;
    } catch {
        return false;
    }
}

// The ol.style.Icon options for what getItemIconImage answered.
function itemIconImageOptions(image) {
    return image instanceof HTMLCanvasElement ? {img: image} : {src: image};
}

async function getToggleOnOffOpen(elementId) {
    let objectReference = dotnetObjectRef[elementId];
    if (!objectReference)
        return Promise.resolve(null);

    try {
        const data = await objectReference.invokeMethodAsync('GetToggleOnOffOpen');
        toggleOnOffOpens = data;
    } catch (error) {
        throw error; // Rethrow the error to propagate it
    }
}

async function setToggleOnOffOpen(elementId) {
    let objectReference = dotnetObjectRef[elementId];
    if (!objectReference)
        return Promise.resolve(null);

    try {
        const data = await objectReference.invokeMethodAsync('SetToggleOnOffOpen', toggleOnOffOpens);
        toggleOnOffOpens = data;
    } catch (error) {
        throw error; // Rethrow the error to propagate it
    }
}

async function toggleMapLabels(elementId) {
    let objectReference = dotnetObjectRef[elementId];
    if (!objectReference)
        return Promise.resolve(null);

    try {
        let map = maps[elementId];

        const data = await objectReference.invokeMethodAsync('SetMapLabelsToggle', map.mapOptions.mapLabelsEnabled);

        setMapLabelsEnabled(elementId, data);
    } catch (error) {
        throw error; // Rethrow the error to propagate it
    }
}

async function removeAllPolygons(elementId) {
    let map = maps[elementId];
    if (!map)
        return;
    interactionLayer.getSource().clear();
    map.mapOptions.hasPolygon = false;
    map.mapOptions.undoEnabled = false;
}

function toggleRoutePlanningView(isActive) {
    let mapControls = document.getElementsByClassName('custom-map-control');

    if (!mapControls)
        return;

    let arrayLength = mapControls.length;
    for (var i = 0; i < arrayLength; i++) {
        let mapControl = mapControls[i];

        if (mapControl.classList.contains('map-route-planner-btn')) {
            if (isActive) {
                mapControl.classList.remove("hidden");
                continue;
            }

            mapControl.classList.add("hidden");
            continue;
        }

        if (isActive) {
            mapControl.classList.add("hidden");
            continue;
        }

        mapControl.classList.remove("hidden");
    }
}

async function togglePolygonSelection(isActive, elementId, isForRoutePlanning = false) {
    let map = maps[elementId];
    if (!map)
        return;

    if (isForRoutePlanning) {
        toggleRoutePlanningView(isActive);
        map.mapOptions.routePlannerEnabled = isActive;

        if (isActive) {
            let routeFeatureLayer = routePlanningLayer[elementId];
            if (routeFeatureLayer) {
                routeFeatureLayer.setSource(null);
                map.removeLayer(routeFeatureLayer);
                routePlanningLayer[elementId] = null;

                zoomToFit(elementId);
            }
        }
    }

    if (!isActive) {
        await removeInteraction(elementId);
    } else {
        // The other way round from toggleMeasuring: two draw interactions on one map means a
        // click that both measures and selects, so whichever is being started ends the other.
        endMeasureSession(elementId);
        getMeasureControl(elementId)?.refresh();

        await addInteraction("Polygon", elementId);
    }

    if (!isActive) {
        await removeAllPolygons(elementId);
    }

    // A session starts with nothing drawn and ends with nothing left behind - no areas, no ticks,
    // no listener. Route planning draws with the same interaction, but has no bar of its own.
    resetPolygonSelection(elementId, isActive && !isForRoutePlanning);

    isActivePolygonInteraction = isActive;
}

async function removeInteraction(elementId) {
    let map = maps[elementId];
    if (!map)
        return;

    if (drawInteraction) {
        drawInteraction.un('drawend', function (e) {
            interactionDrawEnded(e, elementId)
        });
        map.removeInteraction(drawInteraction);
        drawInteraction = null;
    }
}

async function addInteraction(interactionType, elementId) {
    let map = maps[elementId];
    if (!map)
        return;
    drawInteraction = new ol.interaction.Draw({
        source: interactionLayer.getSource(),
        trace: true,
        type: interactionType
    });
    drawInteraction.on('drawend', function (e) {
        interactionDrawEnded(e, elementId)
    });
    // Whether an area is under way decides what undo takes back and what Escape lets go of.
    drawInteraction.on('drawstart', () => setPolygonSketching(elementId, true));
    drawInteraction.on('drawabort', () => setPolygonSketching(elementId, false));
    map.addInteraction(drawInteraction);
}

// ---------------------------------------------------------------------------
// The area select's session - see polygonSelectControl.js for the bar it runs in.
//
// The areas are drawn by the interaction above onto the interaction layer. What they take in is
// worked out again each time one is finished or taken away, by the same function Continue opens
// the records with, so the number on the bar is the number of records that open. Those records
// are drawn as selected (isFeatureSelected) until the session ends.

function resetPolygonSelection(elementId, isSelecting) {
    setPolygonSelectionKeys(elementId, false);
    delete polygonSelectionStates[elementId];
    setPolygonSelectionPreview(elementId, null);

    if (isSelecting) {
        polygonSelectionStates[elementId] = {areas: [], count: 0, isDrawing: false, keyHandler: null};
        setPolygonSelectionKeys(elementId, true);
    }

    updatePolygonSelectControl(elementId);
}

function updatePolygonSelectControl(elementId) {
    const control = mapControls[elementId]?.polygonSelectControl;
    if (!control)
        return;

    const selection = polygonSelectionStates[elementId];

    control.setSelection({
        count: selection?.count ?? 0,
        areaCount: selection?.areas.length ?? 0,
        isDrawing: selection?.isDrawing === true
    });
    control.refresh();
}

function setPolygonSketching(elementId, isDrawing) {
    const selection = polygonSelectionStates[elementId];
    if (!selection || selection.isDrawing === isDrawing)
        return;

    selection.isDrawing = isDrawing;
    updatePolygonSelectControl(elementId);
}

// finishedArea is an area the draw interaction has just finished, which it adds to the layer only
// once its drawend listeners have run.
async function refreshPolygonSelection(elementId, finishedArea) {
    const selection = polygonSelectionStates[elementId];
    if (!selection)
        return;

    await getSelectedFeatureIds(async function (selectedIds) {
        // A session ended while this was being worked out has nothing left to show it on.
        if (polygonSelectionStates[elementId] !== selection)
            return;

        selection.count = selectedIds.length;
        setPolygonSelectionPreview(elementId, selectedIds);
    }, finishedArea, elementId);

    updatePolygonSelectControl(elementId);
}

function setPolygonSelectionPreview(elementId, ids) {
    const hadPreview = !!polygonSelectionPreviewIds[elementId];

    if (ids?.length > 0) {
        polygonSelectionPreviewIds[elementId] = new Set(ids.map(id => String(id).toLowerCase()));
    } else {
        delete polygonSelectionPreviewIds[elementId];
    }

    if (!hadPreview && !polygonSelectionPreviewIds[elementId])
        return;

    // The same as setSelectedFeatureIds: the style functions read the ids, so both item layers
    // are told to draw again.
    getItemsLayerUnderlyingSource(elementId)?.changed();
    getLinearItemsLayerUnderlyingSource(elementId)?.changed();
}

// Takes back whatever was done last: a point of the area being drawn, or the last area finished.
async function undoLastSelectionArea(elementId) {
    const selection = polygonSelectionStates[elementId];
    if (!selection)
        return;

    if (selection.isDrawing) {
        drawInteraction?.removeLastPoint();
        return;
    }

    const area = selection.areas.pop();
    if (!area)
        return;

    const source = interactionLayer?.getSource();
    if (source?.hasFeature(area)) {
        source.removeFeature(area);
    }

    if (maps[elementId]) {
        maps[elementId].mapOptions.hasPolygon = selection.areas.length > 0;
    }

    await refreshPolygonSelection(elementId);
}

async function clearSelectionAreas(elementId) {
    const selection = polygonSelectionStates[elementId];
    if (!selection)
        return;

    if (selection.isDrawing) {
        drawInteraction?.abortDrawing();
    }

    selection.areas = [];
    await removeAllPolygons(elementId);
    await refreshPolygonSelection(elementId);
}

async function stopPolygonSelection(elementId) {
    const map = maps[elementId];
    if (!map || map.mapOptions.polygonSelectionEnabled !== true)
        return;

    map.mapOptions.polygonSelectionEnabled = false;
    await togglePolygonSelection(false, elementId);
}

// Escape lets go of what the user is holding, in the order they would let go of it - the area
// being drawn, then the tool - and Backspace takes back the last point of the area being drawn,
// as they do while measuring.
function setPolygonSelectionKeys(elementId, isListening) {
    const selection = polygonSelectionStates[elementId];

    if (selection?.keyHandler) {
        document.removeEventListener('keydown', selection.keyHandler);
        selection.keyHandler = null;
    }

    if (!isListening || !selection)
        return;

    selection.keyHandler = event => {
        if (event.defaultPrevented || !isPolygonSelectionKeyTarget(elementId, event.target))
            return;

        if (event.key === 'Escape') {
            event.preventDefault();

            if (selection.isDrawing) {
                drawInteraction?.abortDrawing();
                return;
            }

            stopPolygonSelection(elementId);

            return;
        }

        if (event.key === 'Backspace' && selection.isDrawing) {
            event.preventDefault();
            drawInteraction?.removeLastPoint();
        }
    };

    document.addEventListener('keydown', selection.keyHandler);
}

// Keys are the area select's while the user is on the map or on the page around it - not while
// they are typing in a field or working in a dialog, and not while one of the toolbar's menus is
// open, whose Escape closes the menu.
function isPolygonSelectionKeyTarget(elementId, target) {
    const mapElement = maps[elementId]?.getTargetElement?.();
    if (!mapElement || document.querySelector('.modal.show'))
        return false;

    try {
        if (mapElement.querySelector(':popover-open'))
            return false;
    } catch {
    }

    if (!target || target === document.body || target === document.documentElement)
        return true;

    return mapElement.contains(target) && !target.closest('input, textarea, select, [contenteditable="true"]');
}

async function interactionDrawEnded(drawEvent, elementId) {
    let map = maps[elementId];
    const polygonCoordinates = getFeatureCoordinates(drawEvent.feature, elementId);
    map.mapObjects.polygons.push(polygonCoordinates);
    var center = map.getView().getCenter();
    var positionData = {
        center: [center[0], center[1]],
        zoom: map.getView().getZoom()
    };

    map.mapObjects.polygonPosition = positionData;
    map.mapOptions.hasPolygon = true;
    containsCompletePolygon = true;

    // An area select keeps drawing: each area is counted and its records ticked as soon as it is
    // finished - not after the position below is saved - and the records open from the bar's
    // Continue.
    const selection = polygonSelectionStates[elementId];
    if (selection) {
        selection.isDrawing = false;
        selection.areas.push(drawEvent.feature);

        await refreshPolygonSelection(elementId, drawEvent.feature);
    }

    await saveMapPositionData(elementId, "viewselectedpolygons");

    let isRoutePlanner = map.mapOptions.routePlannerEnabled !== undefined && map.mapOptions.routePlannerEnabled === true;
    if (isRoutePlanner) {
        await getSelectedFeatureIds(async function (selectedIds, selectedFeatures) {
            map.selectedItemsIds = selectedIds;
            await planRouteForFeatures(selectedFeatures, elementId);
        }, drawEvent.feature, elementId);
    }
}

async function viewSelectedPolygonItems(elementId) {
    let map = maps[elementId];
    if (!map)
        return;

    await getSelectedFeatureIds(async function (selectedIds, selectedFeatures) {
        map.selectedItemsIds = selectedIds;

        let isRoutePlanner = map.mapOptions.routePlannerEnabled !== undefined && map.mapOptions.routePlannerEnabled === true;
        if (isRoutePlanner) {
            await planRouteForFeatures(selectedFeatures, elementId);
            return;
        }

        isActivePolygonInteraction = false;

        // Tear down the polygon-selection session now that the user has chosen to
        // view the results. Leaving the draw interaction active (with tracing) and
        // the drawn polygons on the map causes continual re-rendering, which makes
        // the map sluggish after the drilldown page is opened and closed.
        map.mapOptions.polygonSelectionEnabled = false;
        await togglePolygonSelection(false, elementId);

        let objectRef = dotnetObjectRef[elementId];
        if (!objectRef)
            return;

        await objectRef.invokeMethodAsync('SelectedIds', selectedIds);
    }, undefined, elementId);
}

// ---------------------------------------------------------------------------
// Measuring - distance along a line, and the area inside a polygon
//
// A session is a draw interaction over a layer of its own, and the labels are drawn by the
// layer's style rather than as overlays: a measurement is a handful of segments, and a style
// redraws with the map for nothing where a DOM overlay per label has to be moved.
//
// Both modes share the layer, so a line measured before the switch to area is still on the map
// after it - the two are measurements of the same place, and clearing them is the user's call.
//
// A measurement that has been taken can be taken again without being drawn again: clicking one
// selects it, and a selected measurement can be reshaped by its corners or moved by the handle
// at its middle. Drawing and editing share the map, so the draw interaction stands down while
// the pointer is over a measurement - otherwise a click meant for one would start another.
//
// The units, the formatting and the geodesic maths live in measureControl.js beside the control
// that offers them. Everything here is about getting the shape onto the map.
// ---------------------------------------------------------------------------

const MEASURE_LINE_COLOUR = '#15803d';
const MEASURE_FILL_COLOUR = 'rgba(21, 128, 61, 0.12)';
const MEASURE_LABEL_FONT = '600 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';

// The shortest a leg can be drawn and still hold its own label without covering the line it
// measures. Below it the leg goes unlabelled - the total says what a short leg was for.
const MEASURE_MIN_LABEL_PIXELS = 48;

// How close the pointer has to be to a measurement to be treated as over it - for picking one up
// and for standing the drawing down. Wider than a pixel-perfect hit because the line is thin and
// a measurement is a thing to grab, not a target to aim at.
const MEASURE_HIT_TOLERANCE = 8;

// What the draw and modify interactions use when they are left to their own devices, kept because
// the measuring tool replaces their conditions rather than adding to them.
function measureNoModifierKeys(event) {
    try {
        return ol.events.condition.noModifierKeys(event);
    } catch {
        return true;
    }
}

function measurePrimaryAction(event) {
    try {
        return ol.events.condition.primaryAction(event);
    } catch {
        return true;
    }
}

// Whether the press landed on the selected measurement's move handle.
function isOverMeasureHandle(elementId, pixel) {
    const map = maps[elementId];
    const session = measureSessions[elementId];

    if (!map || !session?.handleFeature)
        return false;

    return map.forEachFeatureAtPixel(pixel, feature => feature === session.handleFeature, {
        layerFilter: layer => layer === session.handleLayer,
        hitTolerance: MEASURE_HIT_TOLERANCE
    }) === true;
}

function isMeasuring(elementId) {
    return measureSessions[elementId] !== undefined && measureSessions[elementId] !== null;
}

function getMeasureControl(elementId) {
    return mapControls[elementId]?.measureControl ?? null;
}

function getMeasurementUnits(map) {
    return map?.mapOptions?.measurementUnits ?? {distance: 'auto', area: 'auto'};
}

// The ring a measured shape is made of, whichever mode drew it: the line itself, or the polygon's
// outer ring. Everything measured here works from one of these.
//
// A shape still being drawn carries a last point that follows the pointer, and until the pointer
// moves off the point just clicked that is a repeat of it - which counted as a leg of its own and
// had the bar reading "3 legs" over a line with two. Repeats are dropped, which leaves a polygon's
// closing point alone: that repeats the ring's first point, not the one before it.
function getMeasuredRing(geometry) {
    if (!geometry)
        return null;

    const type = geometry.getType();

    let ring = null;

    if (type === 'LineString')
        ring = geometry.getCoordinates();
    else if (type === 'Polygon')
        ring = geometry.getLinearRing(0)?.getCoordinates() ?? null;

    if (!ring)
        return null;

    return ring.filter((point, index) => {
        if (index === 0)
            return true;

        const previous = ring[index - 1];

        return point[0] !== previous[0] || point[1] !== previous[1];
    });
}

// A pill of text at a point. Used for both the per-leg lengths and the total, which differ only
// in their colours and in the total sitting above the shape rather than on it.
function measureLabelStyle(text, coordinate, isTotal) {
    return new ol.style.Style({
        geometry: new ol.geom.Point(coordinate),
        text: new ol.style.Text({
            text: text,
            font: MEASURE_LABEL_FONT,
            fill: new ol.style.Fill({color: isTotal ? '#ffffff' : '#1b1b18'}),
            backgroundFill: new ol.style.Fill({
                color: isTotal ? MEASURE_LINE_COLOUR : 'rgba(255, 255, 255, 0.94)'
            }),
            backgroundStroke: new ol.style.Stroke({
                color: isTotal ? MEASURE_LINE_COLOUR : 'rgba(0, 0, 0, 0.18)',
                width: 1
            }),
            padding: [3, 6, 3, 6],
            offsetY: isTotal ? -16 : 0,
            overflow: true
        })
    });
}

// The shape, its vertices, a length against every leg, and the total - the length at the end of a
// line, the area in the middle of a polygon. The same function styles the finished measurements
// and the shape being drawn, so a measurement looks the same while it is being made as it does
// once it is made.
//
// The one that is selected is drawn solid rather than dashed, with corners big enough to take
// hold of: a measurement being worked on should be the one that stands off the map, and the
// corners are targets now rather than marks.
function measureStyleFunction(elementId) {
    return function (feature) {
        const map = maps[elementId];
        const geometry = feature.getGeometry();
        const ring = getMeasuredRing(geometry);

        if (!map || !ring)
            return [];

        const isArea = geometry.getType() === 'Polygon';
        const isSelected = measureSessions[elementId]?.selected === feature;
        const projection = map.getView().getProjection();
        const units = getMeasurementUnits(map);

        const styles = [
            new ol.style.Style({
                fill: isArea ? new ol.style.Fill({color: MEASURE_FILL_COLOUR}) : undefined,
                stroke: new ol.style.Stroke({color: 'rgba(255, 255, 255, 0.9)', width: isSelected ? 8 : 7})
            }),
            new ol.style.Style({
                stroke: new ol.style.Stroke({
                    color: MEASURE_LINE_COLOUR,
                    width: isSelected ? 4 : 3,
                    lineDash: isSelected ? undefined : [9, 6],
                    lineCap: 'round',
                    lineJoin: 'round'
                })
            })
        ];

        const resolution = map.getView().getResolution();
        let perimeter = 0;

        for (let i = 0; i < ring.length; i++) {
            const point = ring[i];
            const isEnd = !isArea && i === ring.length - 1;

            // A polygon's ring repeats its first point at the end, so drawing a vertex there
            // would double up the one already at the start.
            if (!isArea || i < ring.length - 1) {
                styles.push(new ol.style.Style({
                    geometry: new ol.geom.Point(point),
                    image: new ol.style.Circle({
                        radius: isSelected ? 7 : (isEnd ? 6 : 5),
                        fill: new ol.style.Fill({
                            color: (isEnd && !isSelected) ? MEASURE_LINE_COLOUR : '#ffffff'
                        }),
                        stroke: new ol.style.Stroke({
                            color: (isEnd && !isSelected) ? '#ffffff' : MEASURE_LINE_COLOUR,
                            width: isSelected ? 3 : 2.5
                        })
                    })
                }));
            }

            if (i === 0)
                continue;

            const previous = ring[i - 1];
            const legLength = measureGeodesicSegment(previous, point, projection);

            perimeter += legLength;

            const legPixels = Math.hypot(point[0] - previous[0], point[1] - previous[1]) / resolution;

            if (legPixels < MEASURE_MIN_LABEL_PIXELS)
                continue;

            const midpoint = [(previous[0] + point[0]) / 2, (previous[1] + point[1]) / 2];

            const legText = isArea
                ? formatAreaLength(legLength, units.area).text
                : formatMeasuredDistance(legLength, units.distance).text;

            styles.push(measureLabelStyle(legText, midpoint, false));
        }

        if (isArea) {
            // Three points of a ring is two sides and no area worth labelling - while the user is
            // still placing the second corner there is nothing to say.
            if (ring.length < 4)
                return styles;

            const area = measureGeodesicArea(ring, projection);

            styles.push(measureLabelStyle(formatMeasuredArea(area, units.area).text,
                geometry.getInteriorPoint().getCoordinates(), true));

            return styles;
        }

        // One leg says the same thing twice - the leg's own label is the total.
        if (ring.length > 2) {
            styles.push(measureLabelStyle(formatMeasuredDistance(perimeter, units.distance).text,
                ring[ring.length - 1], true));
        }

        return styles;
    };
}

// The handle that moves a selected measurement whole. A line has no inside to take hold of - and
// its own body belongs to the corners, which is where reshaping is done - so the way to move one
// has to be something drawn for the purpose rather than a part of the shape.
//
// It wears the four-headed arrow every drag handle wears, which is also the cursor the pointer
// takes over it: a plain cross says "add" or "centre here", and the one thing this handle must
// not be mistaken for is a place to put another point.
//
// Drawn as one SVG rather than composed out of RegularShapes. The arrow is a single outline -
// shafts and heads are the same shape, not four triangles laid over a cross - and stacked shapes
// cannot make that: the heads sit off the ends of the arms and the whole thing reads as a cross
// with triangles beside it. The image is twice the size it is drawn at, so it stays sharp on a
// high-density screen.
const MEASURE_HANDLE_SVG =
    '<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 30 30">'
    + '<circle cx="15" cy="15" r="12" fill="' + MEASURE_LINE_COLOUR + '" stroke="#ffffff" stroke-width="2.5"/>'
    + '<path fill="#ffffff" d="M15 6 L18.4 9.6 L16.4 9.6 L16.4 13.6 L20.4 13.6 L20.4 11.6 L24 15'
    + ' L20.4 18.4 L20.4 16.4 L16.4 16.4 L16.4 20.4 L18.4 20.4 L15 24 L11.6 20.4 L13.6 20.4'
    + ' L13.6 16.4 L9.6 16.4 L9.6 18.4 L6 15 L9.6 11.6 L9.6 13.6 L13.6 13.6 L13.6 9.6 L11.6 9.6 Z"/>'
    + '</svg>';

// Built once rather than per selection, so the image is loaded and ready by the time a
// measurement is first picked up rather than arriving a frame after the handle should have
// appeared. Styles carry no state of their own, so one is shared by every map on the page.
const measureHandleStyles = [
    new ol.style.Style({
        image: new ol.style.Icon({
            src: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(MEASURE_HANDLE_SVG),
            scale: 0.5
        })
    })
];

function measureHandleStyle() {
    return measureHandleStyles;
}

// Where that handle sits: the middle of a polygon, and the halfway point along a line. Under the
// total in both cases - the label is drawn above the point it is given, so the two share a spot
// without covering each other.
function measureHandlePosition(geometry) {
    if (!geometry)
        return null;

    if (geometry.getType() === 'Polygon')
        return geometry.getInteriorPoint().getCoordinates().slice(0, 2);

    return geometry.getCoordinateAt(0.5);
}

function updateMeasureHandle(elementId) {
    const session = measureSessions[elementId];
    if (!session)
        return;

    const position = session.selected ? measureHandlePosition(session.selected.getGeometry()) : null;

    if (!position) {
        session.handleSource.clear();
        session.handleFeature = null;

        return;
    }

    if (!session.handleFeature) {
        session.handleFeature = new ol.Feature(new ol.geom.Point(position));
        session.handleFeature.setStyle(measureHandleStyle());
        session.handleSource.addFeature(session.handleFeature);

        return;
    }

    session.handleFeature.getGeometry().setCoordinates(position);
}

// Which measurement the pointer is over, if any. The handle counts as its measurement - it is
// how a line is picked up.
function measurementAtPixel(elementId, pixel) {
    const map = maps[elementId];
    const session = measureSessions[elementId];

    if (!map || !session)
        return null;

    const hit = map.forEachFeatureAtPixel(pixel, feature => feature, {
        layerFilter: layer => layer === session.layer || layer === session.handleLayer,
        hitTolerance: MEASURE_HIT_TOLERANCE
    });

    if (!hit)
        return null;

    return hit === session.handleFeature ? session.selected : hit;
}

// Selecting is what makes a measurement editable: the reshape and move interactions work on the
// selection, so nothing can be dragged by accident while the user is drawing over it.
function selectMeasurement(elementId, feature) {
    const session = measureSessions[elementId];
    if (!session || session.selected === feature)
        return;

    if (session.selectedListener) {
        ol.Observable.unByKey(session.selectedListener);
        session.selectedListener = null;
    }

    session.selected = feature ?? null;

    session.selection.clear();
    session.translated.clear();

    if (session.selected) {
        session.selection.push(session.selected);

        // Reshaping and moving both change the geometry, so the readout and the handle follow it
        // rather than waiting for the drag to end - the number is what the user is dragging
        // towards.
        session.selectedListener = session.selected.getGeometry().on('change', () => {
            updateMeasureHandle(elementId);
            refreshMeasureReadout(elementId);
        });
    }

    updateMeasureHandle(elementId);

    // Translate moves everything it holds, so the handle travels with the shape it belongs to.
    if (session.selected) {
        session.translated.push(session.selected);

        if (session.handleFeature) {
            session.translated.push(session.handleFeature);
        }
    }

    // A polygon read out as a length says nothing, so picking one up puts the bar into the mode
    // that can report it.
    const wantedMode = session.selected?.getGeometry()?.getType() === 'Polygon' ? 'area' : 'distance';

    if (session.selected && session.mode !== wantedMode) {
        setMeasureMode(elementId, wantedMode, true);
    }

    session.layer.changed();
    refreshMeasureReadout(elementId);
}

// What a shape measures, in the terms the bar reads it in.
function measurementFor(map, geometry, isDrawing) {
    const ring = getMeasuredRing(geometry);

    if (!ring || ring.length < 2) {
        return {metres: 0, squareMetres: 0, perimeterMetres: 0, segmentCount: 0, isDrawing: isDrawing === true};
    }

    const projection = map.getView().getProjection();
    const isArea = geometry.getType() === 'Polygon';
    const length = measureGeodesicLength(ring, projection);

    return {
        metres: isArea ? 0 : length,
        squareMetres: isArea ? measureGeodesicArea(ring, projection) : 0,
        perimeterMetres: isArea ? length : 0,
        segmentCount: Math.max(ring.length - 1, 0),
        isDrawing: isDrawing === true
    };
}

// The bar follows whatever the user is working on: the shape being drawn, then the one they have
// picked up, and only failing both the last one drawn in this mode.
function refreshMeasureReadout(elementId) {
    const session = measureSessions[elementId];
    const control = getMeasureControl(elementId);

    if (!session || !control)
        return;

    const map = maps[elementId];
    const features = session.source.getFeatures();
    const sketchGeometry = session.sketch?.getGeometry();

    // Whether anything at all has been measured on this map, which is what the Clear action
    // acts on - as against the one measurement the readout is reporting.
    const common = {
        mode: session.mode,
        hasOtherMeasurements: features.length > 0,
        isSelected: false
    };

    if (sketchGeometry) {
        control.setResult(Object.assign({}, common, measurementFor(map, sketchGeometry, true)));

        return;
    }

    if (session.selected) {
        control.setResult(Object.assign({}, common, {isSelected: true},
            measurementFor(map, session.selected.getGeometry(), false)));

        return;
    }

    // The last shape drawn in the mode the bar is showing - a line finished before the user
    // switched to area is not what an area readout should be reporting.
    const previous = features
        .filter(feature => (feature.getGeometry()?.getType() === 'Polygon') === (session.mode === 'area'))
        .slice(-1)[0];

    control.setResult(Object.assign({}, common, measurementFor(map, previous?.getGeometry(), false)));
}

async function toggleMeasuring(elementId, active) {
    const map = maps[elementId];
    if (!map)
        return;

    const shouldMeasure = active === undefined ? !isMeasuring(elementId) : active === true;

    if (shouldMeasure === isMeasuring(elementId))
        return;

    if (shouldMeasure) {
        // Measuring and selecting an area are both "draw on the map", and two draw interactions
        // at once means a click that does both. Selecting stands down for the measurement.
        if (map.mapOptions.polygonSelectionEnabled === true) {
            map.mapOptions.polygonSelectionEnabled = false;

            await togglePolygonSelection(false, elementId);
            mapControls[elementId]?.polygonSelectControl?.toggleIcon();
        }

        startMeasureSession(elementId);
    } else {
        endMeasureSession(elementId);
    }

    getMeasureControl(elementId)?.refresh();
}

function startMeasureSession(elementId) {
    const map = maps[elementId];
    if (!map || isMeasuring(elementId))
        return;

    const source = new ol.source.Vector();

    const layer = new ol.layer.Vector({
        source: source,
        style: measureStyleFunction(elementId),
        // Above the map's own items: a measurement is drawn over the assets. Hit-testing reaches
        // it, but only through measurementAtPixel, which asks for this layer by name - the map's
        // own click handler stands aside for the whole of a measuring session.
        zIndex: 9000,
        updateWhileAnimating: true,
        updateWhileInteracting: true
    });

    layer.set('name', 'measureLayer');
    map.addLayer(layer);

    // The move handle rides above the measurements so it is never buried under one of them.
    const handleSource = new ol.source.Vector();

    const handleLayer = new ol.layer.Vector({
        source: handleSource,
        zIndex: 9001,
        updateWhileAnimating: true,
        updateWhileInteracting: true
    });

    handleLayer.set('name', 'measureHandleLayer');
    map.addLayer(handleLayer);

    const session = {
        mode: getMeasureControl(elementId)?.mode ?? 'distance',
        layer: layer,
        source: source,
        handleLayer: handleLayer,
        handleSource: handleSource,
        handleFeature: null,
        // What reshaping works on, and what moving works on: the same measurement, but moving
        // carries the handle along with it.
        selection: new ol.Collection(),
        translated: new ol.Collection(),
        selected: null,
        selectedListener: null,
        draw: null,
        modify: null,
        translate: null,
        sketch: null,
        sketchListener: null,
        pointerListener: null,
        clickListener: null,
        keyHandler: null
    };

    measureSessions[elementId] = session;

    installMeasureInteractions(elementId);

    // Only the cursor - what a press does is settled by the draw interaction's own condition, so
    // that a device with no pointer to hover is not left unable to pick a measurement up.
    session.pointerListener = map.on('pointermove', event => {
        if (event.dragging || !isMeasuring(elementId))
            return;

        const over = session.sketch ? null : measurementAtPixel(elementId, event.pixel);

        map.getViewport().classList.toggle('map-measure-over', over !== null && over !== session.selected);
    });

    session.clickListener = map.on('singleclick', event => {
        if (!isMeasuring(elementId) || session.sketch)
            return;

        const hit = measurementAtPixel(elementId, event.pixel);

        // A click on the map with nothing under it goes to the draw interaction, which is active
        // again by then - so only a hit is handled here, and letting a selection go is Escape's
        // job rather than something an empty click does behind the user.
        if (hit) {
            selectMeasurement(elementId, hit);
        }
    });

    // Escape lets go of whatever the user is holding, in the order they would let go of it: the
    // shape being drawn, then the one picked up, then the tool. Backspace takes back the last
    // point of a shape being drawn, and Delete removes the one picked up.
    session.keyHandler = event => {
        if (!isMeasuring(elementId))
            return;

        if (event.key === 'Escape') {
            event.preventDefault();

            if (session.sketch) {
                session.draw.abortDrawing();
                return;
            }

            if (session.selected) {
                selectMeasurement(elementId, null);
                return;
            }

            toggleMeasuring(elementId, false);

            return;
        }

        if (event.key === 'Backspace' && session.sketch) {
            event.preventDefault();
            undoLastMeasurePoint(elementId);

            return;
        }

        if ((event.key === 'Delete' || event.key === 'Backspace') && session.selected) {
            event.preventDefault();
            removeSelectedMeasurement(elementId);
        }
    };

    document.addEventListener('keydown', session.keyHandler);

    map.getViewport().classList.add('map-measuring');

    refreshMeasureReadout(elementId);
}

// Draw, then the two that edit what has been drawn. Order matters: OpenLayers offers an event to
// the last interaction added first, so reshaping gets first refusal on a corner, moving takes the
// handle and the inside of an area, and drawing is left with the empty map.
//
// All three are put on together because the draw interaction is replaced whenever the mode
// changes - a Draw's geometry type is fixed at construction - and adding it back on its own would
// leave it in front of the two that were already there.
function installMeasureInteractions(elementId) {
    const map = maps[elementId];
    const session = measureSessions[elementId];

    if (!map || !session)
        return;

    removeMeasureInteractions(elementId);

    const draw = new ol.interaction.Draw({
        source: session.source,
        type: session.mode === 'area' ? 'Polygon' : 'LineString',
        style: measureStyleFunction(elementId),
        // A press on a measurement already on the map is a press meant to pick that one up, so
        // the drawing declines it and the click falls through to the handler that selects it.
        // Decided here, on the press itself, rather than by watching the pointer move over one
        // beforehand: there is no hover on a touch screen, so a tablet would never have been
        // able to select anything at all.
        //
        // Only for the first point. Once a shape is under way its line has to be free to cross
        // the measurements already there.
        condition: event => {
            if (!measureNoModifierKeys(event))
                return false;

            return session.sketch !== null || !measurementAtPixel(elementId, event.pixel);
        }
    });

    const stopWatchingSketch = () => {
        if (session.sketchListener) {
            ol.Observable.unByKey(session.sketchListener);
            session.sketchListener = null;
        }

        session.sketch = null;
    };

    draw.on('drawstart', event => {
        // Starting a new one lets go of the last - the bar reports one measurement, and it is
        // the one being worked on.
        selectMeasurement(elementId, null);

        session.sketch = event.feature;

        // The total is the point of the tool, so it counts up as the shape is drawn rather than
        // appearing once it is finished.
        session.sketchListener = session.sketch.getGeometry().on('change', () => {
            refreshMeasureReadout(elementId);
        });

        refreshMeasureReadout(elementId);
    });

    draw.on('drawend', () => {
        stopWatchingSketch();

        // After the interaction has finished with the event, or the readout reads the shape as it
        // was before the last point was committed.
        setTimeout(() => refreshMeasureReadout(elementId), 0);
    });

    draw.on('drawabort', () => {
        stopWatchingSketch();

        refreshMeasureReadout(elementId);
    });

    // Moving: the selected measurement and its handle together. Reshaping is added after it, so a
    // corner is reshaped rather than the whole shape dragged off by it.
    const translate = new ol.interaction.Translate({
        features: session.translated,
        hitTolerance: MEASURE_HIT_TOLERANCE
    });

    const modify = new ol.interaction.Modify({
        features: session.selection,
        pixelTolerance: MEASURE_HIT_TOLERANCE + 2,
        // The move handle sits on the shape it belongs to - halfway along a line - so reshaping,
        // which gets first refusal on anything near the geometry, would otherwise take the press
        // meant to move it and put a new corner there instead. Over the handle it stands aside
        // and moving takes it.
        condition: event => measurePrimaryAction(event) && !isOverMeasureHandle(elementId, event.pixel)
    });

    session.draw = draw;
    session.translate = translate;
    session.modify = modify;

    map.addInteraction(draw);
    map.addInteraction(translate);
    map.addInteraction(modify);
}

function removeMeasureInteractions(elementId) {
    const map = maps[elementId];
    const session = measureSessions[elementId];

    if (!session)
        return;

    if (session.draw) {
        try {
            session.draw.abortDrawing();
        } catch {
        }
    }

    if (session.sketchListener) {
        ol.Observable.unByKey(session.sketchListener);
        session.sketchListener = null;
    }

    session.sketch = null;

    [session.modify, session.translate, session.draw].forEach(interaction => {
        if (interaction) {
            map?.removeInteraction(interaction);
        }
    });

    session.draw = null;
    session.translate = null;
    session.modify = null;
}

// Distance or area. What is already on the map stays there - a user who has measured a road and
// now wants the area of the field beside it has not finished with the road.
function setMeasureMode(elementId, mode, keepSelection) {
    const session = measureSessions[elementId];
    if (!session || session.mode === mode)
        return;

    session.mode = mode;

    // Switching by hand lets go of anything held: the mode says what the bar reports, and a line
    // cannot be reported as an area. Switching because a shape was picked up keeps it, which is
    // the whole reason it switched.
    if (keepSelection !== true) {
        selectMeasurement(elementId, null);
    }

    installMeasureInteractions(elementId);

    // The bar's own switch may not be where this came from - picking up an area moves it too.
    getMeasureControl(elementId)?.setMode(mode);

    refreshMeasureReadout(elementId);
}

function endMeasureSession(elementId) {
    const session = measureSessions[elementId];
    if (!session)
        return;

    const map = maps[elementId];

    selectMeasurement(elementId, null);
    removeMeasureInteractions(elementId);

    [session.pointerListener, session.clickListener].forEach(listener => {
        if (listener) {
            ol.Observable.unByKey(listener);
        }
    });

    if (session.keyHandler) {
        document.removeEventListener('keydown', session.keyHandler);
    }

    session.source.clear();
    session.layer.setSource(null);
    session.handleSource.clear();
    session.handleLayer.setSource(null);

    if (map) {
        map.removeLayer(session.layer);
        map.removeLayer(session.handleLayer);
        map.getViewport().classList.remove('map-measuring', 'map-measure-over');
    }

    measureSessions[elementId] = null;
    delete measureSessions[elementId];

    getMeasureControl(elementId)?.setResult({mode: session.mode});
}

// The labels along a shape are drawn from the units, so a change of unit has to redraw them - the
// finished measurements on the session's own layer, and the one still being drawn, which the draw
// interaction renders on an overlay layer of its own that layer.changed() does not reach.
function redrawMeasurements(elementId) {
    const session = measureSessions[elementId];
    if (!session)
        return;

    session.layer?.changed();

    try {
        session.draw?.getOverlay()?.changed();
    } catch {
    }
}

function undoLastMeasurePoint(elementId) {
    const session = measureSessions[elementId];
    if (!session?.sketch)
        return;

    session.draw.removeLastPoint();

    refreshMeasureReadout(elementId);
}

// The one measurement the user has picked up, gone - as against Clear, which empties the map.
function removeSelectedMeasurement(elementId) {
    const session = measureSessions[elementId];
    if (!session?.selected)
        return;

    const feature = session.selected;

    selectMeasurement(elementId, null);
    session.source.removeFeature(feature);

    refreshMeasureReadout(elementId);
}

// Everything measured on this map, gone - the shape being drawn included, since a user clearing
// the measurements means the one in front of them as much as the ones behind it.
function clearMeasurements(elementId) {
    const session = measureSessions[elementId];
    if (!session)
        return;

    try {
        session.draw?.abortDrawing();
    } catch {
    }

    selectMeasurement(elementId, null);
    session.source.clear();

    refreshMeasureReadout(elementId);
}

// The unit is the user's own way of working rather than a property of one map, so it is kept
// against them and every map they open afterwards opens in it.
async function setMeasurementUnit(elementId, mode, unit) {
    const map = maps[elementId];
    if (!map)
        return;

    const units = getMeasurementUnits(map);

    units[mode === 'area' ? 'area' : 'distance'] = unit;
    map.mapOptions.measurementUnits = units;

    redrawMeasurements(elementId);
    refreshMeasureReadout(elementId);

    const objectRef = dotnetObjectRef[elementId];
    if (!objectRef)
        return;

    try {
        await objectRef.invokeMethodAsync('UpdateMeasurementUnitPreference', mode, unit);
    } catch (error) {
        console.error('Error saving the measurement unit:', error);
    }
}


function DisplayRoutePlan(elementId, routePlanData, isReturnToStart = false, resetZoom = true) {
    let map = maps[elementId];
    if (!map)
        return;

    if (routePlanData?.length > 0) {
        let mapView = map.getView();
        let mapProjection = mapView.getProjection();

        let routeFeatureLayer = routePlanningLayer[elementId];
        if (routeFeatureLayer == null) {
            const vectorSource = new ol.source.Vector({
                features: []
            });

            routeFeatureLayer = new ol.layer.Vector({
                source: vectorSource,
                style: new ol.style.Style({
                    stroke: new ol.style.Stroke({
                        color: '#ff0000',
                        width: 3
                    })
                })
            });

            routePlanningLayer[elementId] = routeFeatureLayer;
            map.addLayer(routeFeatureLayer);
        }

        const source = routeFeatureLayer.getSource();
        source.clear();

        const routeFeatures = new ol.format.GeoJSON().readFeatures(routePlanData, {
            featureProjection: mapProjection
        });
        source.addFeatures(routeFeatures);

        let previousCoords = null;
        let index = 1;

        routeFeatures.forEach((routeFeature, featureIndex) => {
            const routeGeometry = routeFeature.getGeometry();
            const coordinates = routeGeometry.getCoordinates();
            const start = coordinates[0];
            const end = coordinates[coordinates.length - 1];

            if (previousCoords) {
                if (previousCoords[0] == start[0])
                    return;
            }

            previousCoords = start;

            const isStart = routeFeature.get('isStart') || false;
            const isEnd = routeFeature.get('isEnd') || false;

            // Add number marker at start of segment
            const marker = new ol.Feature({
                geometry: new ol.geom.Point(start)
            });

            let markerText = isStart === true ? (isReturnToStart ? 'Start/End' : 'Start') : (index);
            if (!isReturnToStart && isEnd === true)
                markerText = 'End';

            marker.setStyle(new ol.style.Style({
                text: new ol.style.Text({
                    text: String(markerText),
                    font: 'bold 15px sans-serif',
                    fill: new ol.style.Fill({color: '#ffffff'}),
                    stroke: new ol.style.Stroke({color: '#000000', width: 2.5}),
                    backgroundFill: new ol.style.Fill({color: '#ffffff'}),
                    padding: [6, 6, 6, 6],
                    textAlign: 'center',
                    textBaseline: 'middle',
                    offsetY: -20
                }),
                zIndex: 10
            }));

            source.addFeature(marker);

            if (!isStart)
                index++;
        });

        if (resetZoom) {
            const routeExtent = source.getExtent();
            if (isFittableExtent(routeExtent))
                mapView.fit(routeExtent, {padding: [50, 50, 50, 50]});
        }

        toggleRoutePlanningView(true);
    }
}

async function planRouteForFeatures(selectedFeatures, elementId) {
    let map = maps[elementId];
    if (!map)
        return;

    let objectRef = dotnetObjectRef[elementId];
    if (!objectRef)
        return;

    map.mapOptions.routePlannerEnabled = false;
    await togglePolygonSelection(false, elementId, true);

    await objectRef.invokeMethodAsync('PlanRouteForSelectedFeatures', selectedFeatures);
}

// Every record the areas drawn take in - anything with a part inside or across one of them - from
// both of the map's item layers: the points, and the lines and areas the linear layer draws, which
// a selection used to pass over. Each record once, however many of its features or areas it is
// in; a map's selectFilterFunction, where it has one, has the last word on each feature.
async function getSelectedFeatureIds(callback, polygon, elementId) {
    let map = maps[elementId];
    if (!map)
        return;

    const selectedItemsIds = [];
    const selectedFeatures = [];
    const seen = new Set();

    const areas = interactionLayer.getSource().getFeatures();
    if (polygon !== undefined && polygon !== null && !areas.includes(polygon))
        areas.push(polygon);

    const sources = [getItemsLayerUnderlyingSource(elementId), getLinearItemsLayerUnderlyingSource(elementId)]
        .filter(source => !!source);

    for (const area of areas) {
        const areaGeometry = area.getGeometry();
        const turfArea = toTurfGeometry(areaGeometry);
        if (!turfArea)
            continue;

        for (const source of sources) {
            for (const feature of source.getFeaturesInExtent(areaGeometry.getExtent())) {
                const attr = feature.get('attributes');
                if (attr === undefined || attr === null || attr.itemId === undefined || attr.itemId === null)
                    continue;

                const key = String(attr.itemId).toLowerCase();
                if (seen.has(key))
                    continue;

                if (map.mapOptions.selectFilterFunction !== undefined && !map.mapOptions.selectFilterFunction(feature))
                    continue;

                const shape = toTurfGeometry(feature.getGeometry());
                if (!shape || !turf.booleanIntersects(turfArea, shape))
                    continue;

                seen.add(key);
                selectedItemsIds.push(attr.itemId);

                // Where the record is, for the route planner: the point itself, or a point on the
                // line or inside the area - the same one its selection tick is drawn at.
                const geometry = feature.getGeometry();
                const at = geometry instanceof ol.geom.Point ? geometry : getSelectionBadgePoint(geometry);
                if (at) {
                    const longLat = transformToLongLat(at.getCoordinates(), map.mapOptions.mapSource.wkId);

                    attr.pointX = longLat[0];
                    attr.pointY = longLat[1];
                }

                selectedFeatures.push(attr);
            }
        }
    }

    await callback(selectedItemsIds, selectedFeatures);
}

// An OpenLayers geometry as the turf shape the selection tests it with, or null for one it cannot
// be. An asset's area need not come closed, and a ring too short to make an area is tested as the
// line it draws.
function toTurfGeometry(geometry) {
    const closeRing = ring => {
        const first = ring[0];
        const last = ring[ring.length - 1];

        return first && last && (first[0] !== last[0] || first[1] !== last[1]) ? ring.concat([first]) : ring;
    };

    try {
        switch (geometry?.getType()) {
            case 'Point':
                return turf.point(geometry.getCoordinates());
            case 'MultiPoint':
                return turf.multiPoint(geometry.getCoordinates());
            case 'LineString':
                return turf.lineString(geometry.getCoordinates());
            case 'MultiLineString':
                return turf.multiLineString(geometry.getCoordinates());
            case 'Polygon': {
                const rings = geometry.getCoordinates().map(closeRing);

                return rings[0]?.length >= 4 ? turf.polygon(rings) : turf.lineString(geometry.getCoordinates()[0]);
            }
            case 'MultiPolygon': {
                const polygons = geometry.getCoordinates()
                    .map(rings => rings.map(closeRing))
                    .filter(rings => rings[0]?.length >= 4);

                return polygons.length > 0 ? turf.multiPolygon(polygons) : null;
            }
            case 'Circle':
                return turf.polygon(ol.geom.Polygon.fromCircle(geometry, 64).getCoordinates());
            default:
                return null;
        }
    } catch {
        return null;
    }
}

function getAllFeatureDetails(elementId) {
    let selectedFeatures = [];

    try {
        let map = maps[elementId];
        if (!map)
            return null;

        let features = getItemsLayerUnderlyingSource(elementId).getFeatures();
        if (features?.length > 0) {
            for (var j = 0; j < features.length; j++) {
                let feature = features[j];

                let attr = feature.get('attributes');
                let coordinates = feature.getGeometry().getCoordinates();

                let longLat = transformToLongLat(coordinates, map.mapOptions.mapSource.wkId);

                attr.pointX = longLat[0];
                attr.pointY = longLat[1];

                selectedFeatures.push(attr);
            }
        }
    } catch {
        return null;
    }

    return selectedFeatures;
}

function getFeatureCoordinates(feature, elementId) {
    let map = maps[elementId];
    if (!map)
        return;

    const coordinates = feature.getGeometry().getCoordinates()[0];
    var featurePoints = [];
    coordinates.forEach(function (point) {
        var coords;
        const longLat = transformToLongLat(point, map.mapOptions.mapSource.wkId);
        if (map.appWkId != map.mapOptions.mapSource.wkId) {
            coords = transformCoordsWkId(point, map.mapOptions.mapSource.wkId, map.appWkId);
        } else {
            coords = point;
        }
        featurePoints.push({x: coords[0], y: coords[1], long: longLat[0], lat: longLat[1]});
    });

    return {
        points: featurePoints
    };
}

async function getUserPosition(elementId, force = false) {
    let objectReference = dotnetObjectRef[elementId];
    if (!objectReference)
        return Promise.resolve(null);

    return await objectReference.invokeMethodAsync('GetUserPosition', force)
        .then(data => {
            return data;
        }).catch(async (error) => {
        });
}

async function GetUserCurrentLocationCheck(elementId) {
    let objectReference = dotnetObjectRef[elementId];
    if (!objectReference)
        return Promise.resolve(null);

    try {
        return await objectReference.invokeMethodAsync('GetUserCurrentLocationCheck');
    } catch (error) {
        throw error; // Rethrow the error to propagate it	
    }
}

async function createAssetFeatures(item, wkId, elementId, hide) {
    const assetFeatures = [];

    await createAssetIcon(assetFeatures, item, wkId, elementId, hide);

    return assetFeatures;
}

async function createAssetIcon(assetFeatures, item, wkId, elementId, hide) {
    const coords = [item.long, item.lat];
    const appWkId = maps[elementId]?.appWkId ?? mapDefaults.appWkId;

    let geometry;

    if (wkId !== appWkId) {
        geometry = new ol.geom.Point(transformCoordsWkId(coords, appWkId, wkId));
    } else {
        geometry = new ol.geom.Point(coords);
    }

    const iconFeature = new ol.Feature({
        geometry: geometry,
        name: item.name,
        attributes: {
            mapId: elementId,
            itemId: getItemLocationId(item),
            name: item.name,
            entityKey: item.entityKey,
            icon: item.icon,
            baseColor: item.c0,
            dotColor1: item.c1,
            dotColor2: item.c2,
            hasShapesToLoad: item.hSTL,
            isAmxFeatureServiceFeature: item.isAmxFeatureServiceFeature || false,
            dontShowOpenLink: item.dontShowOpenLink,
            filter: item.filter,
            isCompleted: item.isCompleted,
            heatmapWeight: item.heatmapWeight
        }
    });

    if (item.icon?.length > 0) {
        if (iconStyleCache[item.icon] === undefined) {
            await getItemIconImage(elementId, item.icon).then((iconImage) => {
                if (!iconImage)
                    return;

                let assetTextStyleFill = new ol.style.Fill({color: '#FFFFFF'});
                let assetTextStyleBkgrFill = new ol.style.Fill({color: '#871F78'});
                let assetTextStyle = new ol.style.Text({
                    fill: assetTextStyleFill,
                    backgroundFill: assetTextStyleBkgrFill,
                    padding: [2, 15, 2, 15],
                    backgroundStroke: new ol.style.Stroke({
                        color: '#000000',
                        width: 1.5
                    }),
                    font: 'bolder 14px sans-serif',
                    textAlign: 'center',
                    textBaseline: 'bottom',
                    offsetY: -8,
                });

                const iconStyle = new ol.style.Style({
                    image: new ol.style.Icon({
                        ...itemIconImageOptions(iconImage),
                        anchor: [0.5, 0.5],
                        anchorXUnits: 'fraction',
                        anchorYUnits: 'fraction',
                        width: ITEM_ICON_WIDTH,
                        opacity: 1,
                        declutterMode: ITEM_IMAGE_DECLUTTER_MODE
                    }),
                    text: assetTextStyle
                });

                iconStyleCache[item.icon] = iconStyle;

                if (hide !== true) {
                    iconFeature.setStyle(iconStyle);
                }

                assetFeatures.push(iconFeature);
            });
        } else {
            if (hide !== true) {
                iconFeature.setStyle(iconStyleCache[item.icon]);
            }

            assetFeatures.push(iconFeature);
        }

        return;
    }

    assetFeatures.push(iconFeature);
}

// The transform between each pair of WKIDs, looked up once. ol.proj.transform finds both projections
// and the transform between them on every call, and a load calls it for every vertex it brings in.
// Every projection is registered as this file loads (ol.proj.proj4.register above), so what a pair
// resolves to never changes. A pair with no transform is not kept: it fails as it always did.
const coordsTransforms = new Map();

function transformCoordsWkId(coords, sourceWkId, destWkId) {
    const key = sourceWkId + '>' + destWkId;
    let transform = coordsTransforms.get(key);

    if (!transform) {
        transform = ol.proj.getTransform('EPSG:' + sourceWkId, 'EPSG:' + destWkId);
        if (!transform) {
            return ol.proj.transform(coords, 'EPSG:' + sourceWkId, 'EPSG:' + destWkId);
        }

        coordsTransforms.set(key, transform);
    }

    return transform(coords, undefined, coords.length);
}

function transformToLongLat(coords, wkId) {
    return ol.proj.transform(coords, wkId != 0 ? ('EPSG:' + wkId) : 'EPSG:3857', 'EPSG:4326');
}

function transformExtent(extent, sourceWkId, destWkId) {
    return ol.proj.transformExtent(extent, 'EPSG:' + sourceWkId, 'EPSG:' + destWkId);
}

function getMapExtent(elementId, callback, tilesDownload = false) {
    let transformedExtent;

    try {
        let map = maps[elementId];
        if (map) {
            transformedExtent = getTransformedMapExtent(map, tilesDownload);
            if (callback) {
                callback(elementId, transformedExtent); //for now it calls getAssetShapesAndPopulateShapes and  getAssetFeaturesFromExtent
            }
        }
    } catch (e) {
    }

    return transformedExtent;
}

async function getAssetFeaturesFromExtent(elementId, extent) {
    let objectRef = dotnetObjectRef[elementId];

    try {
        let map = maps[elementId];
        if (!map || !objectRef)
            return;

        let zoomLevel = map.getView().getZoom();

        // A search asks the whole dataset, not the current view: zeros are the server's "no
        // bounds", and the zoom threshold that keeps low zoom light does not apply to it - the
        // match can be anywhere.
        const searching = isSelectWithMapSearchActive(map);

        if ((searching || zoomLevel >= 14) && extent) {
            await objectRef.invokeMethodAsync('GetAssetFeatures', {
                X1: searching ? 0 : extent[0],
                Y1: searching ? 0 : extent[1],
                X2: searching ? 0 : extent[2],
                Y2: searching ? 0 : extent[3],
                ElementId: elementId
            }).then(async (locationData) => {
                if (locationData) {
                    await populateFeatures(map, elementId, locationData);
                }
            });
        }
    } catch (e) {
    }
}

// When the map asks for the linear features (lines and areas) of its items, which it loads after
// their points: the site's Map Linear Loading setting (GIS Settings, LinearLoadingRule on the
// server), sent with the map's data. "auto" loads them at any zoom once the view holds at most
// itemsInView items, and always from fromZoom - so a dense network keeps them back until the view
// thins out, and a handful of items spread across a county shows them as the map opens. "zoom"
// loads them only from fromZoom; "always" at every zoom. The server's own size limits still apply.
const LINEAR_LOAD_DEFAULTS = {mode: 'auto', itemsInView: 100, fromZoom: 16};

function normaliseLinearLoadRule(rule) {
    const mode = ['auto', 'zoom', 'always'].includes(rule?.mode) ? rule.mode : LINEAR_LOAD_DEFAULTS.mode;
    const itemsInView = Number.isFinite(rule?.itemsInView)
        ? Math.min(5000, Math.max(0, Math.round(rule.itemsInView)))
        : LINEAR_LOAD_DEFAULTS.itemsInView;
    const fromZoom = Number.isFinite(rule?.fromZoom)
        ? Math.min(23, Math.max(0, rule.fromZoom))
        : LINEAR_LOAD_DEFAULTS.fromZoom;
    return {mode, itemsInView, fromZoom};
}

// countItemsInView(limit) need only count as far as limit + 1: past that the answer is the same.
function shouldLoadLinearItems(rule, zoomLevel, countItemsInView) {
    const r = normaliseLinearLoadRule(rule);
    if (r.mode === 'always')
        return true;
    if (zoomLevel >= r.fromZoom)
        return true;
    if (r.mode === 'zoom')
        return false;
    return countItemsInView(r.itemsInView) <= r.itemsInView;
}

// Whether the map would draw linear features at all just now: the map settings can switch them
// off, or hold them to a zoom.
function areLinearFeaturesDrawn(context) {
    if (!context)
        return false;
    if (context.settings?.linearFeaturesVisible === false)
        return false;
    return isWithinSettingZoom(context.zoomLevel, context.settings?.linearFeaturesVisibleZoomLevel, true);
}

// How many of the map's items are in its view, stopping once past limit.
function countItemsInMapView(map, elementId, limit) {
    const source = getItemsLayerUnderlyingSource(elementId);
    if (!source)
        return 0;

    const viewExtent = map.getView().calculateExtent(map.getSize());
    let count = 0;
    source.forEachFeatureInExtent(viewExtent, () => {
        count++;
        return count > limit ? true : undefined;
    });
    return count;
}

async function getAssetShapesAndPopulateShapes(elementId, extent) {
    let objectRef = dotnetObjectRef[elementId];

    try {
        let map = maps[elementId];

        let shouldLoadFeatures = false;
        const searching = isSelectWithMapSearchActive(map);

        if (extent && objectRef && map) {
            shouldLoadFeatures = searching || shouldLoadLinearItems(map.mapOptions?.linearLoadRule,
                map.getView().getZoom(), limit => countItemsInMapView(map, elementId, limit));
        }

        if (shouldLoadFeatures) {
            await objectRef.invokeMethodAsync('GetAssetShapesAndPopulateShapes', {
                X1: searching ? 0 : extent[0],
                Y1: searching ? 0 : extent[1],
                X2: searching ? 0 : extent[2],
                Y2: searching ? 0 : extent[3],
                elementId: elementId
            }).then(async (data) => {
                if (data) {
                    await populateShapes(elementId, data);
                }
            });
        }
    } catch (e) {
    }
}

function resetGlobalVariables() {
    maps = {};
    itemsLayers = {};
    userPositionLayers = {};
    //appWkId = null; donnot reset this
    iconStyleCache = {};
    iconSrcCache = new Map();
    itemIconImageCache = new Map();
    dotnetObjectRef = {};
    popovers = {};
    loadedShapesItemsId = {};
    linearItemExtents = {};
    fillStyleCache = {};
    strokeStyleCache = {};
}

function getTransformedMapExtent(map, tilesDownload) {
    if (map) {
        let mapView = map.getView();
        const mapProjection = getWKIDCodeFromProjection(mapView.getProjection().getCode());

        extent = mapView.calculateExtent(map.getSize());
        transformedExtent = tilesDownload ? extent : transformExtent(extent, mapProjection, map.appWkId ?? mapDefaults.appWkId);

        return transformedExtent;
    }
    return null;
}

function createItemsLayer(features, mapOptions, elementId) {
    const itemsLayerVectorSource = new ol.source.Vector({features: features});
    let layerSourceOptions = {
        itemsLayerIsClustered: false,
        itemsLayerVectorSource: itemsLayerVectorSource
    };

    let itemsLayerClusterSource = new ol.source.Cluster({
        distance: mapOptions.clusteringOptions.clusterDistance !== undefined && mapOptions.clusteringOptions.clusterDistance !== null ? mapOptions.clusteringOptions.clusterDistance : 20,
        minDistance: mapOptions.clusteringOptions.clusterMinDistance !== undefined && mapOptions.clusteringOptions.clusterMinDistance !== null ? mapOptions.clusteringOptions.clusterMinDistance : 20,
        source: itemsLayerVectorSource,
        geometryFunction: function (feature) {
            return feature.getGeometry();
        },
    });
    layerSourceOptions.itemsLayerClusterSource = itemsLayerClusterSource;

    let itemLayerSource = itemLayerSources[elementId];
    if (itemLayerSource === undefined)
        itemLayerSources[elementId] = layerSourceOptions;

    let layer = new ol.layer.Vector({
        source: itemsLayerClusterSource,
        style: function (feature, resolution) {
            return getClusterItemsLayerStyle(feature, resolution, elementId);
        }
    });

    layer.setZIndex(10);

    return layer;
}

function createLinearItemsLayer(features, mapOptions, elementId) {
    let itemsLayerVectorSource = new ol.source.Vector({
        features: getUniqueLinearFeatures(features)
    });

    return new ol.layer.Vector({
        source: itemsLayerVectorSource,
        style: (feature, resolution) => {
            return getItemsLayerStyle(feature, resolution);
        }
    });
}

function getScaledTextPadding(textResolution, resolution, paddingArray, map) {
    let result = [];
    for (let i = 0; i < paddingArray.length; i++) {
        result.push(paddingArray[i] * (textResolution / resolution) + ($(map.getTargetElement()).hasClass('p-1') && (i == 1 || i == 3) ? 35 : 0)); //bodged solution for text going out of bounds in dataview minimap simply adds 35px to the left and right padding. 
    }

    return result;
}

function getMarkupStyle(attributes, feature) {

    switch (attributes.markupType) {
        case 'circle':
            return new ol.style.Style({
                fill: new ol.style.Fill({
                    color: [255, 255, 255, 0]
                }),
                stroke: new ol.style.Stroke({
                    color: attributes.currentColour,
                    lineDash: attributes.currentLineStyle,
                    width: 3
                }),
                //image: mapOptions.annotationMode ? circleStyle : null
            })
            break;
        case 'box':
            return new ol.style.Style({
                fill: new ol.style.Fill({
                    color: [255, 255, 255, 0]
                }),
                stroke: new ol.style.Stroke({
                    color: attributes.currentColour,
                    lineDash: attributes.currentLineStyle,
                    width: 3
                })
            });
            break;
        case 'line':
            return new ol.style.Style({
                fill: new ol.style.Fill({
                    color: attributes.currentColour
                }),
                stroke: new ol.style.Stroke({
                    color: attributes.currentColour,
                    lineDash: attributes.currentLineStyle,
                    width: 5
                })
            });
            break;
        case 'arrow':
            var styles = [new ol.style.Style({
                fill: new ol.style.Fill({
                    color: attributes.currentColour
                }),
                stroke: new ol.style.Stroke({
                    color: attributes.currentColour,
                    lineDash: attributes.currentLineStyle,
                    width: 5
                })
            })];

            var geometry = feature.getGeometry();
            geometry.forEachSegment(function (start, end) {
                var dx = end[0] - start[0];
                var dy = end[1] - start[1];
                var rotation = Math.atan2(dy, dx);
                styles.push(new ol.style.Style({
                    geometry: new ol.geom.Point(end),
                    image: new ol.style.Icon({
                        src: window.appLocalHost + 'content/icon/map_arrow_w.png',
                        anchor: [0.75, 0.5],
                        rotateWithView: true,
                        rotation: -rotation,
                        color: attributes.currentColour,
                    })
                }));

            })

            return styles;
            break;
    }
}

function getMapTextConfigStyle(mapTextConfig, resolution, feature, map) {
    if (resolution === undefined || resolution === null)
        return;

    const textSize = parseInt(mapTextConfig.font.split('px')[0]);
    const font = ((mapTextConfig.resolution * textSize) / resolution) + 'px' + mapTextConfig.font.split('px')[1]
    const backgroundFill = new ol.style.Fill(mapTextConfig.backgroundFill);
    const textStyle = new ol.style.Text({
        fill: new ol.style.Fill(mapTextConfig.fill),
        backgroundFill: backgroundFill,
        padding: getScaledTextPadding(mapTextConfig.resolution, resolution, mapTextConfig.padding === undefined || mapTextConfig.padding === null ? [6, 6, 6, 6] : mapTextConfig.padding, map),
        backgroundStroke: new ol.style.Stroke(mapTextConfig.backgroundStroke),
        font: font,
        textAlign: mapTextConfig.textAlign,
        textBaseline: mapTextConfig.textBaseline,
        offsetY: -12,
        overflow: mapTextConfig.overflow,
        text: mapTextConfig.text,
    });

    const circleStyle = new ol.style.Circle({
        radius: 5,
        stroke: new ol.style.Stroke({
            color: [255, 0, 0, 1]
        }),
        fill: new ol.style.Fill({
            color: [170, 255, 0, 1],
        })
    });
    return new ol.style.Style({
        text: textStyle,
        stroke: map.mapOptions.annotationMode ? new ol.style.Stroke(mapTextConfig.backgroundStroke) : null,
        fill: map.mapOptions.annotationMode ? backgroundFill : null,
        geometry: feature.getGeometry(),
        image: map.mapOptions.annotationMode ? circleStyle : null
    });
}

function getText(attributes, layer, map) {
    var label = attributes.name || attributes.labelText;
    if (label != null) {
        label = stringDivider(label, 32, '\n');
    }

    if (layer === undefined) {
        // mapLabelsEnabled is per-map state, and it lives on the map's options - see createMap
        // and toggleMapLabels, which is what the label control writes. Reading it off the map
        // object itself found nothing, so no feature outside a feature service was ever
        // labelled, whatever the control said. The caller passes the map where it has one;
        // otherwise it is resolved from the elementId createShape stamps into the attributes.
        const resolved = map ?? (attributes?.mapId ? maps[attributes.mapId] : null);
        return resolved?.mapOptions?.mapLabelsEnabled === true ? label : '';
    }

    return label;
}

// Shared fills and strokes, so a label costs no allocation to draw. Keyed by the colour, which is
// all that varies between them.
function getCachedFill(color) {
    if (color === undefined || color === null) {
        return null;
    }

    const key = typeof color === 'string' ? color : JSON.stringify(color);

    if (fillStyleCache[key] === undefined) {
        fillStyleCache[key] = new ol.style.Fill({color: color});
    }

    return fillStyleCache[key];
}

function getCachedStroke(options) {
    if (!options) {
        return null;
    }

    const key = JSON.stringify(options);

    if (strokeStyleCache[key] === undefined) {
        strokeStyleCache[key] = new ol.style.Stroke(options);
    }

    return strokeStyleCache[key];
}

// The look of a feature label: the map's own label colours where it has them, a black outline so
// it reads over any base map, and placed against the feature where the user has asked for it
// (map settings - Label position). High-contrast labels drop the configured colours for black on
// white, which is the point of the setting: the pairing a site has chosen is not always readable.
function createLabelTextStyle(map, text) {
    return updateLabelTextStyle(new ol.style.Text({
        padding: LABEL_TEXT_PADDING,
        font: LABEL_TEXT_FONT,
        overflow: true
    }), map, text);
}

// The box and type of a feature label - named, because hasLayerLabelRoom works out its size.
const LABEL_TEXT_PADDING = [2, 15, 2, 15];
const LABEL_TEXT_FONT = 'bolder 14px sans-serif';

// Brings an existing label up to date with the settings.
//
// Item icons carry their label on a style built once and cached (createAssetIcon), and the only
// thing ever set on it as it was drawn was the text - so the colours and the placement stayed as
// they were built, and turning on high-contrast labels or moving the label did nothing at all to
// a point item. Everything the settings can change is set here, on the style that is about to be
// drawn, whether it was just built or came out of the cache.
function updateLabelTextStyle(textStyle, map, text) {
    if (!textStyle) {
        return textStyle;
    }

    const displaySettings = map?.mapOptions?.displaySettings;
    const settings = getMapSettings(map);
    const placement = getLabelPlacement(settings.dataLabelsPosition);
    const accessible = settings.accessibleDataLabels === true;

    textStyle.setFill(getCachedFill(accessible ? '#000000' : (displaySettings?.labelForeColor || '#FFFFFF')));
    textStyle.setBackgroundFill(getCachedFill(accessible ? '#FFFFFF' : (displaySettings?.labelBackColor || '#871F78')));
    textStyle.setBackgroundStroke(getCachedStroke({color: '#000000', width: accessible ? 2.5 : 1.5}));
    textStyle.setTextAlign(placement.textAlign);
    textStyle.setTextBaseline(placement.textBaseline);
    textStyle.setOffsetX(placement.offsetX);
    textStyle.setOffsetY(placement.offsetY);
    textStyle.setText(text);

    return textStyle;
}

// Where a label sits against its feature, from the "Label position" setting. The offsets clear
// the icon rather than sitting on it - a label over the thing it names hides what it is naming.
function getLabelPlacement(position) {
    switch (position) {
        case 'top-left':
            return {textAlign: 'right', textBaseline: 'bottom', offsetX: -10, offsetY: -8};
        case 'top-right':
            return {textAlign: 'left', textBaseline: 'bottom', offsetX: 10, offsetY: -8};
        case 'center-left':
            return {textAlign: 'right', textBaseline: 'middle', offsetX: -14, offsetY: 0};
        case 'center-right':
            return {textAlign: 'left', textBaseline: 'middle', offsetX: 14, offsetY: 0};
        case 'bottom-left':
            return {textAlign: 'right', textBaseline: 'top', offsetX: -10, offsetY: 8};
        case 'bottom-center':
            return {textAlign: 'center', textBaseline: 'top', offsetX: 0, offsetY: 10};
        case 'bottom-right':
            return {textAlign: 'left', textBaseline: 'top', offsetX: 10, offsetY: 8};
        case 'top-center':
        default:
            return {textAlign: 'center', textBaseline: 'bottom', offsetX: 0, offsetY: -8};
    }
}

// Wrapped labels, kept. The wrapping is recursive and runs a regex, and it is asked for the same
// handful of strings on every feature on every render - the answer never changes for a given
// string, so it is worked out once. Bounded, because a map's labels are not.
const dividedStringCache = new Map();
const maxDividedStrings = 5000;

function stringDivider(str, width, spaceReplacer) {
    if (str === undefined || str === null || str.length <= width) {
        return str;
    }

    const key = `${width}|${spaceReplacer}|${str}`;

    let divided = dividedStringCache.get(key);
    if (divided !== undefined) {
        return divided;
    }

    divided = divideString(str, width, spaceReplacer);

    if (dividedStringCache.size >= maxDividedStrings) {
        dividedStringCache.clear();
    }

    dividedStringCache.set(key, divided);

    return divided;
}

function divideString(str, width, spaceReplacer) {
    if (str.length > width) {
        var p = width;
        while (p > 0 && (str[p] != ' ' && str[p] != '-')) {
            p--;
        }
        if (p > 0) {
            var left;
            if (str.substring(p, p + 1) == '-') {
                left = str.substring(0, p + 1);
            } else {
                left = str.substring(0, p);
            }
            var right = str.substring(p + 1);
            var rtnStr = left + spaceReplacer + divideString(right, width, spaceReplacer);
            var find = ' - ';
            var re = new RegExp(find, 'g');
            rtnStr = rtnStr.replace(re, '\r\n');
            return rtnStr;
        }
    }
    return str;
}

async function populateShapes(elementId, shapesData) {
    try {
        let map = maps[elementId];
        if (map && shapesData) {
            if (shapesData) {
                const mapProjection = getWKIDCodeFromProjection(map.getView().getProjection().getCode());

                const shapesToAdd = [];
                for (var i = 0; i < shapesData.length; i++) {
                    let data = shapesData[i];
                    let itemId = data.key;
                    const loadedItemKey = getLoadedItemKey(itemId);
                    if (loadedItemKey) {
                        loadedShapesItemsId[elementId].add(loadedItemKey);
                    }

                    for (var j = 0; j < data.shapes.length; j++) {
                        data.shapes[j].attributes.isLinearHideableShape = true;
                        data.shapes[j].attributes.linearItemKey = loadedItemKey;

                        // The response is keyed by whatever the linear lookup went by, which can be
                        // an ESU or a location detail rather than the item; the shape's own item id
                        // is the one its point carries, so its icon can find it either way.
                        const shapeItemKey = getLoadedItemKey(data.shapes[j].attributes.itemId);
                        if (shapeItemKey) {
                            loadedShapesItemsId[elementId].add(shapeItemKey);
                        }
                    }

                    await createAssetShapes(shapesToAdd, data.shapes, {
                        mapOptionsWkId: mapProjection,
                        appWkId: maps[elementId]?.appWkId ?? mapDefaults.appWkId
                    }, elementId);
                }

                if (shapesToAdd.length > 0 && linearItemsLayers) {
                    let linearItemsLayer = getLinearItemsLayerUnderlyingSource(elementId);
                    if (linearItemsLayer) {
                        addUniqueLinearFeatures(elementId, shapesToAdd);
                        getItemsLayerUnderlyingSource(elementId)?.changed();
                    }
                }
            }
        }
    } catch (e) {
    }
}

async function populateFeatures(map, elementId, featuresData) {
    try {
        if (map && featuresData) {
            const mapProjection = getWKIDCodeFromProjection(map.getView().getProjection().getCode());

            if (featuresData?.itemLocations?.length > 0) {
                let features = await getFeaturesForData(featuresData.itemLocations, mapProjection, elementId);

                hasFeatures = features?.length > 0;

                if (hasFeatures) {
                    map.hideFeaturesUntilZoomedIn = false;

                    let thisItemLayer = getItemsLayerUnderlyingSource(elementId);
                    if (thisItemLayer) {
                        thisItemLayer.clear();
                        thisItemLayer.addFeatures(features);
                    }

                    let linearFeatures = await getLinearFeaturesForData(featuresData.itemLocations, mapProjection, elementId)
                    if (linearFeatures?.length > 0) {
                        let thisLinearItemLayer = getLinearItemsLayerUnderlyingSource(elementId);
                        if (thisLinearItemLayer) {
                            thisLinearItemLayer.clear();
                            loadedShapesItemsId[elementId]?.clear();
                            addUniqueLinearFeatures(elementId, linearFeatures);
                            registerLoadedLinearItems(elementId, linearFeatures);
                            registerLoadedLocationItems(elementId, featuresData.itemLocations);
                        }
                    }
                    setItemsLayerSource(elementId);
                }
            }
        }
    } catch (e) {
    }
}

// Each feature's key as last worked out, with the geometry and revision it was worked out from. Every
// load checks the lines it brings against every line already on the map, and writing out each of
// their coordinates again each time was the cost of a busy map. A geometry replaced or edited since
// is keyed afresh.
const linearFeatureKeys = new WeakMap();

function getLinearFeatureKey(feature) {
    const geometry = feature.getGeometry();

    if (!geometry) {
        return null;
    }

    const known = linearFeatureKeys.get(feature);
    if (known && known.geometry === geometry && known.revision === geometry.getRevision()) {
        return known.key;
    }

    const key = JSON.stringify([
        geometry.getType(),
        geometry.getCoordinates()
    ]);

    linearFeatureKeys.set(feature, {geometry, revision: geometry.getRevision(), key});

    return key;
}

function getUniqueLinearFeatures(features, existingKeys = new Set()) {
    if (!features?.length) {
        return [];
    }

    const uniqueFeatures = [];
    const keys = existingKeys;
    for (const feature of features) {
        const key = getLinearFeatureKey(feature);
        if (!key || keys.has(key)) {
            continue;
        }

        feature.setId(key);
        keys.add(key);
        uniqueFeatures.push(feature);
    }

    return uniqueFeatures;
}

function registerLoadedLinearItems(elementId, features) {
    const loadedItems = loadedShapesItemsId[elementId];
    if (!loadedItems || !features?.length) {
        return;
    }

    for (const feature of features) {
        const itemKey = getLoadedItemKey(feature.get('attributes')?.itemId);
        if (itemKey) {
            loadedItems.add(itemKey);
        }
    }
}

function registerLoadedLocationItems(elementId, itemLocations) {
    const loadedItems = loadedShapesItemsId[elementId];
    if (!loadedItems || !itemLocations?.length) {
        return;
    }

    for (const item of itemLocations) {
        if (!item.shapes?.length) {
            continue;
        }

        // Keyed by the asset the shapes are, never by a record's own id (getItemLocationId): a
        // record on an asset (a work required on a road) keeps its icon over the asset's shape,
        // as only an icon with the same asset id gives way to it.
        const itemKey = getLoadedItemKey(item.assetId ?? item.id);
        if (itemKey) {
            loadedItems.add(itemKey);
        }
    }
}

// The id an item's point feature carries: an asset by its asset id, anything else by its own.
function getItemLocationId(item) {
    return item.entityKey && item.entityKey === 'assets' && item.assetId !== undefined ? item.assetId : item.id;
}

function addUniqueLinearFeatures(elementId, features) {
    const linearSource = getLinearItemsLayerUnderlyingSource(elementId);
    if (!linearSource || !features?.length) {
        return;
    }

    const existingKeys = new Set();
    for (const feature of linearSource.getFeatures()) {
        const key = getLinearFeatureKey(feature);
        if (key) {
            if (existingKeys.has(key)) {
                linearSource.removeFeature(feature);
            } else {
                existingKeys.add(key);
                // Don't clobber ids owned by other code (e.g. the what-3-words marker)
                if (feature.getId() === undefined) {
                    feature.setId(key);
                }
            }
        }
    }

    const uniqueFeatures = getUniqueLinearFeatures(features, existingKeys);

    if (uniqueFeatures.length > 0) {
        linearSource.addFeatures(uniqueFeatures);
    }
}

async function createAssetShapes(assetFeatures, shapes, attr, elementId) {
    if (!Array.isArray(shapes)) {
        return;
    }
    for (let i = 0; i < shapes.length; i++) {
        await createShape(assetFeatures, shapes[i], attr, elementId);
    }
}

async function createShape(assetFeatures, shape, attr, elementId) {
    var attributes = shape.attributes != null ? shape.attributes : attr;

    if (shape.attributes != null && attr != null)
        attributes = Object.assign(attributes, attr);

    if (!elementId) {
        elementId = attr.mapId;
    }
    attributes['mapId'] = elementId;

    switch (shape.type.name) { //shape.type.name not getting the right value after jsonConvert
        case 'points':
            await createPointsShape(assetFeatures, shape.points, shape.style, attributes);
            break;
        case 'lines':
        case 'arrow':
            createLinesShape(assetFeatures, shape.points, shape.style, attributes);
            break;
        case 'polygons':
        case 'box':
            createPolygonShape(assetFeatures, shape.points, shape.style, attributes, shape.polygonHoles);
            break;
        case 'text': //not implemented in web either
            break;
        case 'circle':
        case 'line':
            createCircleShape(assetFeatures, shape.points, shape.style, attributes);
            break;
        default:
            break;
    }
}

function createCircleShape(assetFeatures, points, styles, attr) {
    if (!Array.isArray(points)) {
        return;
    }

    for (var i = 0; i < points.length; i++) {
        var center;
        var point;

        if (!Array.isArray(points[i])) {
            point = [points[i].long, points[i].lat];
        } else {
            point = points[i]
        }

        if ((attr.appWkId ?? mapDefaults.appWkId) !== attr.mapOptionsWkId) {
            center = transformCoordsWkId(point, attr.appWkId ?? mapDefaults.appWkId, attr.mapOptionsWkId);
        } else {
            center = point;
        }

        var geometry;
        var thickness = 2;
        var lineColour = [255, 0, 0, 1];
        var lineColorKey = '255,0,0,1';
        var fillColor = [255, 0, 0, 1];
        var fillColorKey = '255,0,0,1';
        var linePattern = null;
        if (styles) {
            if (styles.rgba) {
                lineColour = styles.rgba.split(',').map(Number);
                lineColorKey = styles.rgba;
            }
            if (styles.fillRgba) {
                fillColor = styles.fillRgba.split(',').map(Number);
                fillColorKey = styles.fillRgba;
            }
            if (styles.thickness)
                thickness = styles.thickness;
            if (styles.linePattern)
                linePattern = styles.linePattern.split(',').map(Number);
        }
        var strokeKey = thickness + '_' + lineColorKey + '_' + linePattern;
        if (strokeStyleCache[strokeKey] === undefined) {
            strokeStyleCache[strokeKey] = new ol.style.Stroke({
                width: thickness,
                lineDash: linePattern,
                color: lineColour,
            });
        }

        stroke = strokeStyleCache[strokeKey];

        if (fillStyleCache[fillColorKey] === undefined) {
            fillStyleCache[fillColorKey] = new ol.style.Fill({
                color: fillColor
            });
        }

        fill = fillStyleCache[fillColorKey];

        var style;
        geometry = new ol.geom.Circle(center, attr.radius);

        style = new ol.style.Style({
            stroke: stroke,
            fill: fill,
        });

        attr.isLocalAsset = true;
        var pointFeature = new ol.Feature({
            geometry: geometry,
            attributes: attr,
            style: style,
        });
        assetFeatures.push(pointFeature);
    }
}

async function createPointsShape(assetFeatures, points, styles, attr) {
    if (!Array.isArray(points)) {
        return;
    }

    let elementId = attr.mapId;

    for (var i = 0; i < points.length; i++) {
        var center;
        var point;

        if (!Array.isArray(points[i])) {
            point = [points[i].long, points[i].lat];
        } else {
            point = points[i];
        }

        center = transformCoordsWkId(point, attr.appWkId ?? mapDefaults.appWkId, attr.mapOptionsWkId);

        var imageStyle;
        var geometry = new ol.geom.Point(center);
        var thickness = 2;
        var lineColour = [255, 0, 0, 1];
        var lineColorKey = '255,0,0,1';
        var fillColor = [255, 0, 0, 1];
        var fillColorKey = '255,0,0,1';
        var linePattern = null;
        if (styles) {
            if (styles.rgba) {
                lineColour = styles.rgba.split(',').map(Number);
                lineColorKey = styles.rgba;
            }
            if (styles.fillRgba) {
                fillColor = styles.fillRgba.split(',').map(Number);
                fillColorKey = styles.fillRgba;
            }
            if (styles.thickness)
                thickness = styles.thickness;
            if (styles.linePattern)
                linePattern = styles.linePattern.split(',').map(Number);

            if (styles.iconSrc) {
                let iconImage = await getItemIconImage(elementId, styles.iconSrc);
                imageStyle = new ol.style.Icon({
                    anchor: [0.5, 0.5],
                    anchorXUnits: 'fraction',
                    anchorYUnits: 'fraction',
                    opacity: styles.fillOpacity !== undefined ? styles.fillOpacity : 1,
                    ...itemIconImageOptions(iconImage),
                    width: ITEM_ICON_WIDTH,
                    declutterMode: ITEM_IMAGE_DECLUTTER_MODE
                });
            }
        }
        var strokeKey = thickness + '_' + lineColorKey + '_' + linePattern;
        if (strokeStyleCache[strokeKey] === undefined) {
            strokeStyleCache[strokeKey] = new ol.style.Stroke({
                width: thickness,
                lineDash: linePattern,
                color: lineColour,
            });
        }

        stroke = strokeStyleCache[strokeKey];

        if (fillStyleCache[fillColorKey] === undefined) {
            fillStyleCache[fillColorKey] = new ol.style.Fill({
                color: fillColor
            });
        }

        fill = fillStyleCache[fillColorKey];

        var style;
        if (!styles || !styles.iconSrc) {
            imageStyle = new ol.style.Circle({
                // Point markers stay the same size as the map zoom changes.
                radius: 5,
                stroke: stroke,
                fill: fill,
                declutterMode: ITEM_IMAGE_DECLUTTER_MODE
            });
            style = new ol.style.Style({
                image: imageStyle
            });
        } else {
            style = new ol.style.Style({
                image: imageStyle
            });
        }

        attr.isLocalAsset = true;
        var pointFeature = new ol.Feature({
            geometry: geometry,
            attributes: attr,
            style: style,
        });
        assetFeatures.push(pointFeature);
    }
}

function createLinesShape(assetFeatures, points, styles, attr) {
    if (!Array.isArray(points)) {
        return;
    }
    var geometry;
    var transformedPoints = [];

    for (var i = 0; i < points.length; i++) {
        var point;

        if (!Array.isArray(points[i])) {
            point = [points[i].long, points[i].lat];
        } else {
            point = points[i];
        }

        if ((attr.appWkId ?? mapDefaults.appWkId) !== attr.mapOptionsWkId) {
            transformedPoints.push(transformCoordsWkId(point, attr.appWkId ?? mapDefaults.appWkId, attr.mapOptionsWkId));
        } else {
            transformedPoints.push(point);
        }
    }

    var thickness = 2;
    var lineColour = [255, 0, 0, 1];
    var lineColorKey = '255,0,0,1';
    var fillColour = [255, 0, 0, 0];
    var fillColorKey = '255,0,0,1';
    var capLine = false;
    var backgroundColour = [255, 0, 0, 0];
    var backgroundColourKey = '255,0,0,0';
    var outlineColour = [255, 0, 0, 0];
    var outlineColourKey = '255,0,0,0';
    var linePattern = null;

    if (styles) {
        if (styles.rgba) {
            lineColour = styles.rgba.split(',');
            lineColorKey = styles.rgba;
        }

        if (styles.fillRgba) {
            fillColour = styles.fillRgba.split(',');
            fillColorKey = styles.fillRgba;
        }

        if (styles.thickness)
            thickness = styles.thickness;

        if (styles.linePattern) {
            linePattern = styles.linePattern.split(',');
        }

        //WT: Ready for when we handle outline and background
        if (styles.lineBackgroundRgba) {
            backgroundColour = styles.lineBackgroundRgba.split(',');
            backgroundColourKey = styles.lineBackgroundRgba;
        }

        if (styles.lineOutlineRgba) {
            outlineColour = styles.lineOutlineRgba.split(',');
            outlineColourKey = styles.lineOutlineRgba;
        }

        if (styles.capLine)
            capLine = styles.capLine;
    }

    if (fillStyleCache[fillColorKey] === undefined) {
        fillStyleCache[fillColorKey] = new ol.style.Fill({
            color: fillColour,
        });
    }

    var fill = fillStyleCache[fillColorKey];

    var strokeColorKey = thickness + '_' + lineColorKey + '_' + linePattern;

    if (strokeStyleCache[strokeColorKey] === undefined) {
        strokeStyleCache[strokeColorKey] = new ol.style.Stroke(({
            width: thickness,
            lineDash: linePattern,
            color: lineColour,
        }));
    }

    var stroke = strokeStyleCache[strokeColorKey];

    var textStyle;
    if (attr != null && attr.labelText != null) {
        let mapId = attr["mapId"];
        let map = maps[mapId];

        textStyle = new ol.style.Text({
            fill: fill,
            stroke: stroke,
            font: '12px sans-serif',
            textAlign: 'center',
            textBaseline: 'bottom',
            offsetY: -8,
            text: getText(attr)
        });
    }
    var style;

    if (backgroundColour != null || outlineColour != null) {
        style = [];

        if (fillStyleCache[backgroundColourKey] === undefined) {
            fillStyleCache[backgroundColourKey] = new ol.style.Fill({
                color: backgroundColour,
            });
        }

        var backgroundFill = fillStyleCache[backgroundColourKey];

        var backgroundStrokeKey = thickness + '_' + backgroundColourKey + '_';

        if (strokeStyleCache[backgroundStrokeKey] === undefined) {
            strokeStyleCache[backgroundStrokeKey] = new ol.style.Stroke({
                width: thickness,
                color: backgroundColour
            });
        }

        var backgroundStroke = strokeStyleCache[backgroundStrokeKey];

        var backgroundStyle = new ol.style.Style({
            stroke: backgroundStroke,
            fill: backgroundFill
        });

        var outlineStrokeKey = (thickness + 6) + '_' + outlineColourKey + '_';

        if (strokeStyleCache[outlineStrokeKey] === undefined) {
            strokeStyleCache[outlineStrokeKey] = new ol.style.Stroke({
                width: thickness + 6,
                color: outlineColour
            });
        }

        var outlineStroke = strokeStyleCache[outlineStrokeKey];

        if (fillStyleCache[outlineColourKey] === undefined) {
            fillStyleCache[outlineColourKey] = new ol.style.Fill({
                color: outlineColour
            });
        }

        var outlineFill = fillStyleCache[outlineColourKey];

        var outlineStyle = new ol.style.Style({
            stroke: outlineStroke,
            fill: outlineFill
        });

        style.push(outlineStyle);
        style.push(backgroundStyle);
        style.push(new ol.style.Style({
            stroke: stroke,
            fill: fill,
        }));
    } else {
        style = new ol.style.Style({
            stroke: stroke,
            fill: fill,
        });
    }

    geometry = new ol.geom.LineString(transformedPoints);

    let strokeFeature = new ol.Feature({
        geometry: geometry,
        attributes: attr,
        style: style,
        text: textStyle
    });

    if (styles) {
        if (styles.showArrowheads && styles.showArrowheads) {
            strokeFeature.set('hasArrows', true);
        }
    }

    strokeFeature.set('capLine', capLine);

    assetFeatures.push(strokeFeature);
}

function createPolygonShape(polygonFeatures, points, styles, attr, polygonHoles) {
    if (!Array.isArray(points)) {
        return;
    }
    var polygon;
    var shapeArray = [];
    var transformedPoints = [];
    if ((attr.appWkId ?? mapDefaults.appWkId) !== attr.mapOptionsWkId) {

        for (var i = 0; i < points.length; i++) {
            var point;

            if (!Array.isArray(points[i])) {
                point = [points[i].long, points[i].lat];
            } else {
                point = points[i];
            }
            transformedPoints.push(transformCoordsWkId(point, attr.appWkId ?? mapDefaults.appWkId, attr.mapOptionsWkId));
        }
        shapeArray.push(transformedPoints);

        if (polygonHoles !== undefined && polygonHoles !== null) {
            for (i = 0; i < polygonHoles.length; i++) {
                var hole = [];

                for (var j = 0; j < polygonHoles[i].length; j++) {
                    var holePoint;

                    if (!$.isArray(polygonHoles[i][j])) {
                        holePoint = [polygonHoles[i][j].long, polygonHoles[i][j].lat];
                    } else {
                        holePoint = polygonHoles[i][j];
                    }
                    hole.push(transformCoordsWkId(holePoint, attr.appWkId ?? mapDefaults.appWkId, attr.mapOptionsWkId));
                }
                shapeArray.push(hole);
            }
        }

    } else {
        for (var i = 0; i < points.length; i++) {
            var point;

            if (!Array.isArray(points[i])) {
                point = [points[i].long, points[i].lat];
            } else {
                point = points[i];
            }
            transformedPoints.push(point);
        }
        shapeArray.push(transformedPoints);

        if (polygonHoles !== undefined && polygonHoles !== null) {
            for (i = 0; i < polygonHoles.length; i++) {
                var hole = [];

                for (var j = 0; j < polygonHoles[i].length; j++) {
                    var holePoint;

                    if (!Array.isArray(polygonHoles[i][j])) {
                        holePoint = [polygonHoles[i][j].long, polygonHoles[i][j].lat];
                    } else {
                        holePoint = polygonHoles[i][j];
                    }
                    hole.push(holePoint);
                }
                shapeArray.push(hole);
            }
        }

    }
    polygon = new ol.geom.Polygon(shapeArray);

    var thickness = 2;
    var lineColour = [255, 0, 0, 1];
    var lineColourKey = '255,0,0,1';
    var fillColour = [255, 0, 0, 0];
    var fillColorKey = '255,0,0,1';
    var outlineColour = null;
    var linePattern = null;
    var outlineColourKey = '255,0,0,0';
    if (styles) {
        if (styles.rgba) {
            lineColour = styles.rgba.split(',');
            lineColourKey = styles.rgba;
        }
        if (styles.fillRgba) {
            fillColour = styles.fillRgba.split(',');
            fillColorKey = styles.fillRgba;
        }
        if (styles.thickness) {
            thickness = styles.thickness;
        }
        if (styles.linePattern) {
            linePattern = styles.linePattern.split(',');
        }
        if (styles.lineOutlineRgba) {
            outlineColour = styles.lineOutlineRgba.split(',');
            outlineColourKey = styles.lineOutlineRgba;
        }
    }

    var strokeColorKey = thickness + '_' + (outlineColour ? outlineColourKey : lineColourKey) + '_' + linePattern;

    if (strokeStyleCache[strokeColorKey] === undefined) {
        strokeStyleCache[strokeColorKey] = new ol.style.Stroke(({
            width: thickness,
            lineDash: linePattern,
            color: outlineColour ? outlineColour : lineColour
        }));
    }

    var stroke = strokeStyleCache[strokeColorKey];

    if (fillStyleCache[fillColorKey] === undefined) {
        fillStyleCache[fillColorKey] = new ol.style.Fill({
            color: fillColour,
        });
    }

    var fill = fillStyleCache[fillColorKey];

    var textStyle;
    if (attr != null && attr.labelText != null) {
        textStyle = new ol.style.Text({
            fill: fill,
            stroke: stroke,
            font: '12px sans-serif',
            textAlign: 'center',
            textBaseline: 'bottom',
            offsetY: -8,
            text: getText(attr)
        });
    }

    var style = new ol.style.Style({
        stroke: stroke,
        fill: fill,
    });
    attr.isLocalAsset = true;
    var polygonFeature = new ol.Feature({
        geometry: polygon,
        attributes: attr,
        style: style,
        text: textStyle
    });

    polygonFeatures.push(polygonFeature);
}

function createEditPoint(shape, style, attr) {
    result = [];
    for (let i = 0; i < shape.editPoints.length; i++) {
        let geometry;

        if ((attr.appWkId ?? mapDefaults.appWkId) !== attr.mapOptionsWkId) {
            geometry = new ol.geom.Point(transformCoordsWkId([shape.editPoints[i].x, shape.editPoints[i].y], attr.appWkId ?? mapDefaults.appWkId, attr.mapOptionsWkId));
        } else {
            geometry = new ol.geom.Point([shape.editPoints[i].x, shape.editPoints[i].y]);
        }
        const editFeature = new ol.Feature({
            geometry: geometry,
            attributes: {
                point: shape.editPoints[i],
            },
            style: style
        });
        result.push(editFeature);
    }

    return result;
}

function createEditLine(shape, style, attr) {
    const coordinates = [];
    const result = [];

    if ((attr.appWkId ?? mapDefaults.appWkId) !== attr.mapOptionsWkId) {
        for (var i = 0; i < shape.editPoints.length; i++) {
            coordinates.push(transformCoordsWkId([shape.editPoints[i].x, shape.editPoints[i].y], attr.appWkId ?? mapDefaults.appWkId, attr.mapOptionsWkId));
        }
    } else {
        for (var i = 0; i < shape.editPoints.length; i++) {
            coordinates.push([shape.editPoints[i].x, shape.editPoints[i].y]);
        }
    }

    const geometry = new ol.geom.LineString(coordinates);

    const editFeature = new ol.Feature({
        geometry: geometry,
        attributes: {
            shapeObject: shape,
        },
        style: style
    });
    result.push(editFeature);

    for (var i = 0; i < coordinates.length; i++) {
        const lineEditPoint = new ol.Feature({
            geometry: new ol.geom.Point([coordinates[i][0], coordinates[i][1]]),
            attributes: {
                point: shape.editPoints[i],
            },
            style: style
        });

        result.push(lineEditPoint);
    }

    return result;
}

function createEditPolygon(shape, style, attr) {
    const result = [];
    const coordinates = [];

    if ((attr.appWkId ?? mapDefaults.appWkId) !== attr.mapOptionsWkId) {
        for (var i = 0; i < shape.editPoints.length; i++) {
            coordinates.push(transformCoordsWkId([shape.editPoints[i].x, shape.editPoints[i].y], attr.appWkId ?? mapDefaults.appWkId, attr.mapOptionsWkId));
        }
    } else {
        for (var i = 0; i < shape.editPoints.length; i++) {
            coordinates.push([shape.editPoints[i].x, shape.editPoints[i].y]);
        }
    }

    if (coordinates.length > 0 && (coordinates[0][0] != coordinates[coordinates.length - 1][0] || coordinates[0][1] != coordinates[coordinates.length - 1][1])) {
        coordinates.push([coordinates[0][0], coordinates[0][1]]);
    }


    const geometry = new ol.geom.Polygon([coordinates]);
    const editFeature = new ol.Feature({
        geometry: geometry,
        attributes: {
            shapeObject: shape,
        },
        style: style
    });

    result.push(editFeature);

    for (var i = 0; i < coordinates.length - 1; i++) {
        const lineEditPoint = new ol.Feature({
            geometry: new ol.geom.Point([coordinates[i][0], coordinates[i][1]]),
            attributes: {
                point: shape.editPoints[i],
            },
            style: style
        });

        result.push(lineEditPoint);
    }

    return result;
}

function createEditCircle(shape, style, attr) {
    //not implemented
    return [];
}

function updateMapPositionData(elementId) {
    var map = maps[elementId];
    if (!map)
        return;

    map.mapOptions.positionData = getPositionData(elementId);
}

async function setPositionData(elementId, locationData, userPosition, hasFeatures, hasGeoLayers) {
    let map = maps[elementId];
    if (!map)
        return;

    let isHandled = false;
    if (locationData.defaultToUserLocation === true) {
        let zoomLevel = map.getView().getZoom();
        await NotifyZoomChangeEvent(elementId, zoomLevel);

        if (!isNaviagtedForDataView && userPosition) {
            setUsersPosition(elementId, userPosition, true);
            isHandled = true;

            // Start continuous browser geolocation watching if tracking is on
            if (map.mapOptions.trackUsersPosition) {
                enableFollow();
                _startGeoWatch(elementId);
            }
        }
    }

    // A pinned map opens where the user pinned it, on every map that offers the pin - the record
    // maps and location panels included, not just the report Map tab. It is settled ahead of the
    // branches below because pinning is precisely the choice not to be moved by whatever the
    // current data selection happens to be.
    const pinnedPosition = locationData.positionData;
    if (!isHandled && map.mapOptions.showMapPin === true && pinnedPosition?.mapPinned === true && pinnedPosition.zoomLevel > 0) {
        map.mapOptions.positionData = pinnedPosition;
        restorePosition(elementId);
        isHandled = true;
    }

    if (!isHandled && locationData.showEditOptions === true && locationData.positionData && !hasFeatures) {
        map.mapOptions.positionData = locationData.positionData;
        restorePosition(elementId);
        isHandled = true;
    }
        // One branch for every map that was handed a position. This was two identical bodies, one for
        // the general map and one for fast edit, with a map showing a single record's location falling
        // between them and never positioned at all - which is why such a map came back from a feature
        // it opened at its default view rather than where the user left it. Whether a position is
        // offered at all is decided in BuildLocationData; a map only gets one where it should open
    // on it.
    else if (!isHandled) {
        const positionData = locationData.positionData;
        if (positionData && locationData.showEditOptions !== true) {
            map.mapOptions.positionData = positionData;

            if (positionData.zoomLevel && (positionData.mapPinned === true || positionData.isNavigation === true || positionData.viewSelectedPolygons === true)) {
                restorePosition(elementId);
                isHandled = true;
            }
        }
    }

    if (locationData.isSelectWithMap === true && isHandled == false) {
        let item = locationData.itemLocations[0];
        if (item) {
            const coords = [item.long, item.lat];
            const view = map.getView();
            let mapProjection = getWKIDCodeFromProjection(view.getProjection().getCode());

            let geometry;

            if (mapProjection !== map.appWkId) {
                geometry = new ol.geom.Point(transformCoordsWkId(coords, map.appWkId, mapProjection));
            } else {
                geometry = new ol.geom.Point(coords);
            }

            if (coords) {
                if (view) {
                    view.fit(geometry, {size: map.getSize(), padding: [10, 10, 10, 10], maxZoom: 18});
                    isHandled = true;
                }
            }
        }
    }

    await AddMapControls(elementId, hasFeatures, locationData.isMapControlsShown);

    // Save tracking preference before zoomToFit (which fully disables tracking)
    const wasTrackingBeforeZoomToFit = !!map?.mapOptions?.trackUsersPosition;

    if (isHandled === false) {
        await zoomToFit(elementId);
    }

    // If tracking was previously enabled (restored from preferences), the zoomToFit
    // above will have disabled tracking. Restore the full tracking state so the map
    // centres on the user, follows movement and rotates.
    if (wasTrackingBeforeZoomToFit) {
        // Re-enable tracking state that zoomToFit cleared
        map.mapOptions.trackUsersPosition = true;
        delete rotationPaused[elementId];
        enableFollow();
        toggleDirectionStyle(elementId, true);

        // Tell C# to re-enable tracking (zoomToFit called DisableUserTracking)
        const objRef = dotnetObjectRef[elementId];
        if (objRef) {
            try {
                await objRef.invokeMethodAsync('EnableUserTracking');
            } catch {
            }
        }

        // Centre the map on the user's current position
        try {
            const freshPos = await getUserPosition(elementId, true);
            if (freshPos) {
                setUsersPosition(elementId, freshPos, false);
            }
        } catch {
        }

        // Always zoom + centre via goToCurrentPosition
        goToCurrentPosition(elementId);

        // Start continuous browser geolocation watching so the map follows movement
        _startGeoWatch(elementId);
    }
}

function restorePosition(elementId) {
    var map = maps[elementId];
    if (!map)
        return;

    const view = map.getView();
    const pd = map.mapOptions.positionData;
    const mapProjection = getWKIDCodeFromProjection(view.getProjection().getCode());

    if (pd.zoomLevel)
        view.setZoom(pd.zoomLevel);

    var centerCoords;
    if (pd.wkId && pd.wkId != mapProjection) {
        centerCoords = transformCoordsWkId(pd.center, pd.wkId, mapProjection);
    } else {
        centerCoords = pd.center;
    }

    map.mapOptions.isMapPinned = pd.mapPinned;

    view.setCenter(centerCoords);
}

// Puts a map back where it was looking. Used by a rebuild - a change of base map makes the map
// again from scratch, and a user who was looking at one street should not be returned to the
// whole county. Not the same as the position restored at build time, which only happens for a
// pinned map or an arrival from somewhere else; this is the view the map had a moment ago, and it
// is put back whatever projection the new source is in - restorePosition transforms the centre.
function restoreMapPosition(elementId, positionData) {
    const map = maps[elementId];

    if (!map || !positionData || !(positionData.zoomLevel > 0))
        return;

    map.mapOptions.positionData = {
        center: positionData.center,
        zoomLevel: positionData.zoomLevel,
        wkId: positionData.wkId,
        // Whether the map is pinned is not something a rebuild changes, so it is carried from the
        // map rather than from the position - restorePosition writes this back.
        mapPinned: map.mapOptions.isMapPinned
    };

    restorePosition(elementId);
}

function getPositionData(elementId) {
    var map = maps[elementId];
    if (!map)
        return;

    const view = map.getView();

    let mapProjection = getWKIDCodeFromProjection(view.getProjection().getCode());

    return {
        center: view.getCenter(),
        zoomLevel: view.getZoom(),
        wkId: mapProjection,
        showAssetShapeIcons: true,//mapSettingsControl.getItemsIconVisibility().itemsIconVisibility,
        mapPinned: map.mapOptions.isMapPinned,
        entityKey: map.mapOptions.entityKey,
        mapSource: map.mapOptions.mapSource
    };
}

// ---------------------------------------------------------------------------
// Heatmap overlay
//
// A session-only ol.layer.Heatmap that visualises the concentration of the
// existing map point features. The weight comes from a user-selected numeric
// field (surfaced on each feature's attributes as heatmapWeight) or, when no
// field is chosen ("Count"), every feature gets an equal weight so the heatmap
// simply reflects the density/number of records. Toggling the heatmap off
// restores the normal items view without affecting the report configuration.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Data split
//
// The pane itself belongs to Blazor (ReportMapPage), so this is only how the
// control is told about a change that did not come from one of its own buttons -
// the saved preference being applied on load, or the pane being closed from the
// page. Idempotent: it just re-reads the state and repaints the two buttons.
// ---------------------------------------------------------------------------
function setDataSplitState(elementId, isOpen, isSideBySide) {
    const map = maps[elementId];
    if (!map || !map.mapOptions)
        return;

    map.mapOptions.dataSplitOpen = isOpen === true;
    map.mapOptions.dataSplitSideBySide = isSideBySide === true;

    const control = mapControls[elementId]?.dataSplitControl;
    if (control && typeof control.refresh === 'function') {
        control.refresh();
    }
}

function isHeatmapEnabled(elementId) {
    const state = heatmapLayers[elementId];
    return !!(state && state.enabled);
}

function ensureHeatmapState(elementId) {
    let state = heatmapLayers[elementId];
    if (!state) {
        state = {enabled: false, field: '', radius: 8, blur: 15, layer: null};
        heatmapLayers[elementId] = state;
    }
    return state;
}

function buildHeatmapPointFeatures(elementId, field) {
    const source = getItemsLayerUnderlyingSource(elementId);
    const features = source ? source.getFeatures() : [];
    const points = [];

    // First pass - collect the raw weights so we can normalise into 0..1.
    let minWeight = Number.POSITIVE_INFINITY;
    let maxWeight = Number.NEGATIVE_INFINITY;
    const rawValues = [];

    for (const feature of features) {
        const geometry = feature.getGeometry();
        if (!geometry) {
            rawValues.push(null);
            continue;
        }

        let raw = 1;
        if (field) {
            const attributes = feature.get('attributes');
            const value = attributes ? attributes.heatmapWeight : undefined;
            const parsed = typeof value === 'number' ? value : parseFloat(value);
            raw = isNaN(parsed) ? 0 : parsed;
        }

        rawValues.push(raw);
        if (raw < minWeight) minWeight = raw;
        if (raw > maxWeight) maxWeight = raw;
    }

    const range = maxWeight - minWeight;

    for (let i = 0; i < features.length; i++) {
        const geometry = features[i].getGeometry();
        if (!geometry) continue;

        // Heatmap only works with point geometries - use the geometry centre for
        // anything that is not already a point.
        let coordinate;
        if (geometry.getType && geometry.getType() === 'Point') {
            coordinate = geometry.getCoordinates();
        } else if (typeof geometry.getCenter === 'function') {
            coordinate = geometry.getCenter();
        } else {
            const extent = geometry.getExtent();
            coordinate = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
        }

        let normalised;
        if (!field) {
            normalised = 1;
        } else if (range > 0) {
            // Keep a small floor so every record still registers on the heatmap.
            normalised = 0.1 + (((rawValues[i] - minWeight) / range) * 0.9);
        } else {
            normalised = 1;
        }

        const pointFeature = new ol.Feature({
            geometry: new ol.geom.Point(coordinate)
        });
        pointFeature.set('weight', normalised);
        points.push(pointFeature);
    }

    return points;
}

function refreshHeatmapSource(elementId) {
    const state = heatmapLayers[elementId];
    if (!state || !state.layer)
        return;

    const source = state.layer.getSource();
    source.clear();
    source.addFeatures(buildHeatmapPointFeatures(elementId, state.field));
}

// Update the heatmapWeight attribute on the existing item features in-place from
// a { itemId: weight } map, then refresh the heatmap overlay. This avoids a full
// setupMap teardown/rebuild (which caused a blank base map) when the user only
// changes the weight field.
function updateHeatmapWeights(elementId, weightsById, field) {
    const source = getItemsLayerUnderlyingSource(elementId);
    if (source) {
        const lookup = weightsById || {};
        for (const feature of source.getFeatures()) {
            const attributes = feature.get('attributes');
            if (!attributes) continue;
            const id = attributes.itemId;
            if (id === undefined || id === null) continue;
            const key = String(id);
            attributes.heatmapWeight = Object.prototype.hasOwnProperty.call(lookup, key)
                ? lookup[key]
                : null;
        }
    }

    const state = ensureHeatmapState(elementId);
    state.field = field || '';
    refreshHeatmapSource(elementId);
}

function createHeatmapLayer(elementId, state) {
    const source = new ol.source.Vector({
        features: buildHeatmapPointFeatures(elementId, state.field)
    });

    const layer = new ol.layer.Heatmap({
        source: source,
        blur: state.blur,
        radius: state.radius,
        weight: function (feature) {
            const w = feature.get('weight');
            return typeof w === 'number' ? w : 1;
        }
    });
    layer.setZIndex(11);
    return layer;
}

function toggleHeatmap(elementId) {
    const map = maps[elementId];
    if (!map)
        return false;

    const state = ensureHeatmapState(elementId);

    if (state.enabled) {
        // Turn the heatmap off and restore the normal items view.
        if (state.layer) {
            state.layer.setSource(null);
            map.removeLayer(state.layer);
            state.layer = null;
        }
        const itemsLayer = itemsLayers[elementId];
        if (itemsLayer) itemsLayer.setVisible(true);
        const linearLayer = linearItemsLayers[elementId];
        if (linearLayer) linearLayer.setVisible(true);
        state.enabled = false;
    } else {
        state.layer = createHeatmapLayer(elementId, state);
        map.addLayer(state.layer);
        // Hide the standard items so the heatmap reads cleanly; restored on toggle off.
        const itemsLayer = itemsLayers[elementId];
        if (itemsLayer) itemsLayer.setVisible(false);
        const linearLayer = linearItemsLayers[elementId];
        if (linearLayer) linearLayer.setVisible(false);
        state.enabled = true;
    }

    return state.enabled;
}

// Forcibly remove any heatmap overlay and restore the normal items/base view,
// regardless of the tracked enabled flag. Used when closing the panel so a
// prior field-change reload cannot leave a stale heatmap layer / blank map.
function disableHeatmap(elementId) {
    const map = maps[elementId];
    if (!map)
        return;

    const state = heatmapLayers[elementId];
    if (state && state.layer) {
        state.layer.setSource(null);
        map.removeLayer(state.layer);
        state.layer = null;
    }
    if (state) state.enabled = false;

    const itemsLayer = itemsLayers[elementId];
    if (itemsLayer) itemsLayer.setVisible(true);
    const linearLayer = linearItemsLayers[elementId];
    if (linearLayer) linearLayer.setVisible(true);

    const controls = mapControls[elementId];
    if (controls && controls.heatmapControl && typeof controls.heatmapControl.refreshIcon === 'function') {
        controls.heatmapControl.refreshIcon();
    }
}

// Set the weight field ('' or null = Count / equal weight). The caller reloads
// the location data first when the field is not already in the map dataset.
function setHeatmapField(elementId, fieldName) {
    const state = ensureHeatmapState(elementId);
    state.field = fieldName || '';
    refreshHeatmapSource(elementId);
}

function setHeatmapParams(elementId, radius, blur) {
    const state = ensureHeatmapState(elementId);
    if (typeof radius === 'number' && !isNaN(radius)) {
        state.radius = radius;
        if (state.layer) state.layer.setRadius(radius);
    }
    if (typeof blur === 'number' && !isNaN(blur)) {
        state.blur = blur;
        if (state.layer) state.layer.setBlur(blur);
    }
}

// Applies a full heatmap state in one call. Used after the location data is
// reloaded (which rebuilds the map/controls) to restore the heatmap the user
// had enabled, along with the selected field and radius/blur.
function applyHeatmapState(elementId, enabled, field, radius, blur) {
    const map = maps[elementId];
    if (!map)
        return false;

    const state = ensureHeatmapState(elementId);
    state.field = field || '';
    if (typeof radius === 'number' && !isNaN(radius)) state.radius = radius;
    if (typeof blur === 'number' && !isNaN(blur)) state.blur = blur;

    const shouldShow = !!enabled;
    if (shouldShow && !state.enabled) {
        toggleHeatmap(elementId);
    } else if (!shouldShow && state.enabled) {
        toggleHeatmap(elementId);
    } else if (shouldShow) {
        // Already on - just refresh with the new field/params.
        if (state.layer) {
            state.layer.setRadius(state.radius);
            state.layer.setBlur(state.blur);
        }
        refreshHeatmapSource(elementId);
    }

    const controls = mapControls[elementId];
    if (controls && controls.heatmapControl && typeof controls.heatmapControl.refreshIcon === 'function') {
        controls.heatmapControl.refreshIcon();
    }

    return isHeatmapEnabled(elementId);
}

function getItemsLayerUnderlyingSource(elementId) {
    let itemsLayer = itemsLayers[elementId];
    if (itemsLayer) {
        if (itemsLayer !== null && itemsLayer !== undefined) {
            if (itemsLayer.getSource() instanceof ol.source.Cluster) {
                return itemsLayer.getSource().getSource();
            } else {
                return itemsLayer.getSource();
            }
        }
    }
    return null;
}

function getLinearItemsLayerUnderlyingSource(elementId) {
    let itemsLayer = linearItemsLayers[elementId];
    if (itemsLayer) {
        if (itemsLayer !== null && itemsLayer !== undefined) {
            if (itemsLayer.getSource() instanceof ol.source.Cluster) {
                return itemsLayer.getSource().getSource();
            } else {
                return itemsLayer.getSource();
            }
        }
    }
    return null;
}

function getEditShapeLayerUnderlyingSource(elementId) {
    let editShapeLayer = editShapeLayers[elementId];
    if (editShapeLayer) {
        if (editShapeLayer !== null && editShapeLayer !== undefined) {
            return editShapeLayer.getSource();
        }
    }
    return null;
}

function getUserLocationLayerUnderlyingSource(elementId) {
    let userPositionLayer = userPositionLayers[elementId];

    if (userPositionLayer) {
        if (userPositionLayer !== null && userPositionLayer !== undefined) {
            if (userPositionLayer.getSource() instanceof ol.source.Cluster) {
                return userPositionLayer.getSource().getSource();
            } else {
                return userPositionLayer.getSource();
            }
        }
    }
    return null;
}

let lastRotation = {};
let isAnimating = {};
const userPosAnim = {};
const rotationState = {};
let lastFollowTs = 0;
let followUser = true;
const rotationPaused = {}; // per-map flag: true when zoomToFit paused rotation but tracking stays active
const trackingZoomLevel = {}; // per-map: remembers zoom level during tracking so user zoom is preserved
const geoWatchIds = {}; // per-map: browser geolocation watchPosition id

/**
 * Start watching the browser's geolocation and continuously update the
 * user position on the map.  Called when tracking is enabled.
 */
function _startGeoWatch(elementId) {
    _stopGeoWatch(elementId);
    if (!navigator.geolocation) return;

    const watchId = navigator.geolocation.watchPosition(
        (pos) => {
            const map = maps[elementId];
            if (!map || !map.mapOptions.trackUsersPosition) {
                _stopGeoWatch(elementId);
                return;
            }
            const userPos = {pointX: pos.coords.longitude, pointY: pos.coords.latitude};
            setUsersPosition(elementId, userPos, true, 30, pos.coords.accuracy || 0);
        },
        (err) => { /* silently ignore errors */
        },
        {enableHighAccuracy: true, maximumAge: 2000, timeout: 10000}
    );
    geoWatchIds[elementId] = watchId;
}

function _stopGeoWatch(elementId) {
    if (geoWatchIds[elementId] != null) {
        navigator.geolocation.clearWatch(geoWatchIds[elementId]);
        delete geoWatchIds[elementId];
    }
}

function enableFollow() {
    followUser = true;
    lastFollowTs = 0;
}

function disposeUserAnimator(elementId, keepGeoWatch = false) {
    if (!keepGeoWatch)
        _stopGeoWatch(elementId);
    const st = userPosAnim[elementId];
    if (st && st.rafId) cancelAnimationFrame(st.rafId);
    delete userPosAnim[elementId];
    delete lastRotation[elementId];
    delete isAnimating[elementId];
    const rs = rotationState[elementId];
    if (rs && rs.rafId) cancelAnimationFrame(rs.rafId);
    delete rotationState[elementId];
    delete rotationPaused[elementId];
    delete trackingZoomLevel[elementId];
}

function pixelDistance(map, c1, c2) {
    const p1 = map.getPixelFromCoordinate(c1);
    const p2 = map.getPixelFromCoordinate(c2);
    return Math.hypot(p2[0] - p1[0], p2[1] - p1[1]);
}

const _normTwoPi = Math.PI * 2;

function _normAngle(a) {
    a = a % _normTwoPi;
    if (a <= -Math.PI) a += _normTwoPi;
    if (a > Math.PI) a -= _normTwoPi;
    return a;
}

function olSetRotation(elementId, radians, speed = 0) {
    const map = maps[elementId];
    if (!map) return;

    // If rotation was paused (e.g. by zoomToFit), ignore incoming rotation updates
    if (rotationPaused[elementId]) return;

    const target = _normAngle(radians);

    // Initialise per-map rotation state on first call
    if (!rotationState[elementId]) {
        rotationState[elementId] = {
            target: target,
            current: _normAngle(map.getView().getRotation()),
            speed: speed,
            rafId: null,
            lastFrameTime: 0,
            settledFrames: 0
        };
    }

    const state = rotationState[elementId];

    // Ignore tiny target changes — prevents the loop from chasing sensor noise
    // Wider dead zone when stationary to suppress compass jitter
    const deadZone = (speed || 0) < 0.5 ? 0.06 :   // ~3.4° when stationary
        (speed || 0) < 1.5 ? 0.04 :    // ~2.3° when barely moving
            0.02;                           // ~1.1° at walking speed+
    const targetDelta = Math.abs(_normAngle(target - state.target));
    if (targetDelta < deadZone) return;

    state.target = target;
    state.speed = speed;
    state.settledFrames = 0; // reset idle counter on every meaningful new target

    // Start the continuous interpolation loop if not already running
    if (!state.rafId) {
        state.lastFrameTime = performance.now();
        _startRotationLoop(elementId);
    }

    toggleDirectionStyle(elementId, true);
}

function _startRotationLoop(elementId) {
    const map = maps[elementId];
    if (!map) return;
    const state = rotationState[elementId];
    if (!state) return;

    const view = map.getView();
    // Stop the RAF loop after being settled for this many frames (~0.5 s at 60 fps).
    const maxSettledFrames = 30;

    function step(now) {
        const dt = Math.min((now - state.lastFrameTime) / 1000, 0.05); // cap to avoid jumps after tab-switch
        state.lastFrameTime = now;

        let delta = _normAngle(state.target - state.current);
        const absDelta = Math.abs(delta);

        if (absDelta < 0.008) { // ~0.46° — close enough, snap (wider to settle sooner)
            state.current = state.target;
            state.settledFrames++;
        } else {
            // Smooth interpolation toward target.
            // At high device speeds (driving), GPS COG is reliable so be snappier.
            // When stationary, use very slow interpolation so corrections are imperceptible.
            const spd = state.speed || 0;
            const isHighSpeed = spd > 5;
            const isStationary = spd < 0.5;
            const smoothingSpeedMin = isHighSpeed ? 5 : isStationary ? 0.8 : 2;
            const smoothingSpeedMax = isHighSpeed ? 12 : isStationary ? 2 : 4;
            const deltaThreshLow = 0.05;   // ~2.9° — below this, use min speed
            const deltaThreshHigh = 0.6;   // ~34° — above this, use max speed

            const t = Math.min(Math.max((absDelta - deltaThreshLow) / (deltaThreshHigh - deltaThreshLow), 0), 1);
            const smoothingSpeed = smoothingSpeedMin + t * (smoothingSpeedMax - smoothingSpeedMin);
            // Frame-rate-independent exponential decay
            const factor = 1 - Math.exp(-smoothingSpeed * dt);
            state.current = _normAngle(state.current + delta * factor);
            state.settledFrames = 0;
        }

        view.setRotation(state.current);
        lastRotation[elementId] = state.current; // keep legacy bookkeeping in sync

        if (state.settledFrames < maxSettledFrames) {
            state.rafId = requestAnimationFrame(step);
        } else {
            // Rotation has been stable — park the loop until a new target arrives
            state.rafId = null;
        }
    }

    state.rafId = requestAnimationFrame(step);
}

// Cached direction-cone canvas (Google Maps–style sector/wedge)
let _directionConeCanvas = null;

function _getDirectionConeCanvas() {
    if (_directionConeCanvas) return _directionConeCanvas;

    const size = 128;
    const canvas = document.createElement('canvas');
    canvas.width = size;
    canvas.height = size;
    const ctx = canvas.getContext('2d');

    const cx = size / 2;
    const cy = size / 2;
    const coneRadius = size / 2 - 2;       // px from dot centre to outer edge
    const coneAngle = 66 * Math.PI / 180;  // ~66° spread — similar to Google Maps

    // Cone points upward (-π/2 is 12-o'clock)
    const startAngle = -Math.PI / 2 - coneAngle / 2;
    const endAngle = -Math.PI / 2 + coneAngle / 2;

    // Radial gradient: strong near dot, fading to transparent
    const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, coneRadius);
    gradient.addColorStop(0, 'rgba(66, 133, 244, 0.50)');
    gradient.addColorStop(0.35, 'rgba(66, 133, 244, 0.22)');
    gradient.addColorStop(0.70, 'rgba(66, 133, 244, 0.08)');
    gradient.addColorStop(1, 'rgba(66, 133, 244, 0.00)');

    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.arc(cx, cy, coneRadius, startAngle, endAngle);
    ctx.closePath();
    ctx.fillStyle = gradient;
    ctx.fill();

    _directionConeCanvas = canvas;
    return canvas;
}

function _userPositionStyleFn(feature, resolution) {
    const accuracy = feature.get('accuracy') || 0;
    const lat = feature.get('lat') || 0;
    const showDirection = feature.get('showDirection') || false;

    const styles = [];

    // Accuracy bloom — radius derived from real GPS accuracy in meters
    if (accuracy > 0) {
        const latRad = lat * Math.PI / 180;
        const cosLat = Math.max(0.01, Math.cos(latRad));
        const radiusPx = Math.min(accuracy / (resolution * cosLat), 400);

        if (radiusPx > 10) {
            styles.push(new ol.style.Style({
                image: new ol.style.Circle({
                    radius: radiusPx,
                    fill: new ol.style.Fill({color: 'rgba(66, 133, 244, 0.08)'}),
                    stroke: new ol.style.Stroke({color: 'rgba(66, 133, 244, 0.25)', width: 1})
                }),
                zIndex: 997
            }));
        }
    }

    // Direction cone — Google Maps–style semi-transparent wedge fanning out from the dot.
    // Rendered *behind* the main dot (lower zIndex) so the dot sits cleanly on top.
    // rotateWithView: false  →  stays screen-fixed (always "up"), while the view rotates to heading.
    if (showDirection) {
        const cone = _getDirectionConeCanvas();
        styles.push(new ol.style.Style({
            image: new ol.style.Icon({
                img: cone,
                rotation: 0,
                rotateWithView: false,
                opacity: 1,
                anchor: [0.5, 0.5]
            }),
            zIndex: 998
        }));
    }

    // Main dot — crisp blue with white border (on top of everything)
    styles.push(new ol.style.Style({
        image: new ol.style.Circle({
            radius: 7,
            fill: new ol.style.Fill({color: '#4285F4'}),
            stroke: new ol.style.Stroke({color: '#ffffff', width: 2.5})
        }),
        zIndex: 999
    }));

    return styles;
}

function toggleDirectionStyle(elementId, show = false) {
    const map = maps[elementId];
    if (!map) return;
    let userPositionFeature = map.userPositionFeature;
    if (!userPositionFeature) return;
    userPositionFeature.set('showDirection', show);
    userPositionFeature.changed();
}

function setUsersPosition(elementId, userPosition, goToUsersPosition = true, recenterPx = 30, accuracy = 0) {
    try {
        const map = maps[elementId];
        if (!map) return true;

        const view = map.getView();
        const mapProjection = getWKIDCodeFromProjection(view.getProjection().getCode());
        const coords = transformCoordsWkId([userPosition.pointX, userPosition.pointY], 4326, mapProjection);
        if (!coords) return true;

        let feature = map.userPositionFeature;
        if (!feature) {
            feature = new ol.Feature({geometry: new ol.geom.Point(coords), name: 'User Position'});

            feature.set('accuracy', accuracy);
            feature.set('lat', userPosition.pointY || 0);
            feature.set('showDirection', false);
            feature.setStyle(_userPositionStyleFn);

            map.userPositionFeature = feature;

            const layer = new ol.layer.Vector({
                source: new ol.source.Vector({features: [feature]}),
                updateWhileAnimating: true,
                updateWhileInteracting: true
            });
            userPositionLayers[elementId] = layer;
            map.addLayer(layer);

            // Animator state
            userPosAnim[elementId] = {
                rafId: 0,
                running: false,
                last: coords.slice(),
                target: coords.slice(),
                lastTs: performance.now()
            };

            if (goToUsersPosition) {
                lastFollowTs = Date.now();
                goToCurrentPosition(elementId);
            }
            return true;
        }

        const geom = feature.getGeometry();
        if (!geom) {
            feature.setGeometry(new ol.geom.Point(coords));
            return true;
        }

        // Update accuracy properties so the style function recalculates the bloom
        feature.set('accuracy', accuracy, true);
        feature.set('lat', userPosition.pointY || 0, true);

        const state = userPosAnim[elementId] || (userPosAnim[elementId] = {
            rafId: 0,
            running: false,
            last: geom.getCoordinates().slice(),
            target: coords.slice(),
            lastTs: performance.now()
        });

        state.target = coords.slice();
        const current = geom.getCoordinates();

        // Ignore ultra-small jitter
        const ignorePx = 1.5; // tighter than before to catch real walking movement
        if (pixelDistance(map, current, state.target) < ignorePx) {
            // no movement worth animating
        } else if (!state.running) {
            state.running = true;
            state.lastTs = performance.now();

            const step = () => {
                const now = performance.now();
                const dt = Math.min(0.06, (now - state.lastTs) / 1000);
                state.lastTs = now;

                const cur = geom.getCoordinates();
                const tx = state.target[0], ty = state.target[1];

                // Critically-damped style smoothing — slightly softer for less jitter
                const lambda = 10; // balanced responsiveness
                const alpha = 1 - Math.exp(-lambda * dt);

                const nx = cur[0] + (tx - cur[0]) * alpha;
                const ny = cur[1] + (ty - cur[1]) * alpha;

                // Cap per-frame pixel motion to avoid "teleport" on big jumps
                const maxStepPx = 15; // larger cap for smoother catch-up at speed
                const pxBefore = pixelDistance(map, cur, [nx, ny]);
                if (pxBefore > maxStepPx) {
                    const scale = maxStepPx / pxBefore;
                    geom.setCoordinates([cur[0] + (nx - cur[0]) * scale, cur[1] + (ny - cur[1]) * scale]);
                } else {
                    geom.setCoordinates([nx, ny]);
                }

                if (pixelDistance(map, geom.getCoordinates(), state.target) < 0.5) {
                    geom.setCoordinates(state.target);
                    state.running = false;
                    state.rafId = 0;
                    return;
                }
                state.rafId = requestAnimationFrame(step);
            };

            state.rafId = requestAnimationFrame(step);
        }

        // Smarter follow: only re-center if meaningfully off-center and not too often
        if (goToUsersPosition && followUser) {
            const centerPx = map.getPixelFromCoordinate(view.getCenter());
            const targetPx = map.getPixelFromCoordinate(state.target);
            const drift = Math.hypot(centerPx[0] - targetPx[0], centerPx[1] - targetPx[1]);

            const nowMs = Date.now();
            const longEnough = (nowMs - lastFollowTs) > 250; // responsive cadence for smooth following

            // Use a small dead zone (3px) to suppress GPS jitter while stationary,
            // but re-center promptly on any real movement.
            if (drift > 3 && longEnough) {
                // Use smooth animated centering for a Google Maps-like glide
                _smoothFollowCenter(elementId, state.target);
                lastFollowTs = nowMs;
            }
        }
    } catch (e) { /* swallow to avoid breaking app */
    }

    return true;
}

async function getAdditionalShapes(elementId, extent) {
    let map = maps[elementId];
    if (!map)
        return;

    if (map.getShapesTimeout != null) {
        clearTimeout(map.getShapesTimeout);
    }

    if (map.mapOptions.canShowLocalAssets !== undefined && map.mapOptions.canShowLocalAssets === true
        && map.mapOptions.forceShowLocalAssets !== true && !isSelectWithMapSearchActive(map)) {
        let mapView = map.getView();
        let zoomLevel = mapView.getZoom();

        if (!map.mapOptions.shapesVisibility || zoomLevel < LOCAL_ASSETS_MIN_ZOOM) {
            await NotifyZoomChangeEvent(elementId, zoomLevel);
            return;
        }
    }

    map.getShapesTimeout = setTimeout(async function () {
        // Cleared either way: left set, it reads as "a load is still pending" for the rest of
        // the map's life, which is what made a search wait out its whole timeout.
        map.getShapesTimeout = null;

        if (isMoving[elementId]) {
            map.isInCall = false;
            return;
        }

        await getShapesInExtent(extent, elementId);
    }, 500);
}

const MAX_SHAPE_FEATURES = 5000;

function pruneOutOfViewShapes(elementId, viewExtent) {
    let linearSource = getLinearItemsLayerUnderlyingSource(elementId);
    if (!linearSource) return;

    let features = linearSource.getFeatures();
    if (features.length < MAX_SHAPE_FEATURES) return;

    // Expand the extent by 50% as a buffer so shapes just off-screen aren't immediately removed
    let dx = (viewExtent[2] - viewExtent[0]) * 0.5;
    let dy = (viewExtent[3] - viewExtent[1]) * 0.5;
    let bufferedExtent = [
        viewExtent[0] - dx,
        viewExtent[1] - dy,
        viewExtent[2] + dx,
        viewExtent[3] + dy
    ];

    let toRemove = [];
    for (let i = 0; i < features.length; i++) {
        let geom = features[i].getGeometry();
        if (!geom) continue;
        let featureExtent = geom.getExtent();
        if (!ol.extent.intersects(featureExtent, bufferedExtent)) {
            toRemove.push(features[i]);
        }
    }

    if (toRemove.length === 0) return;

    let loadedSet = loadedShapesItemsId[elementId];
    for (let i = 0; i < toRemove.length; i++) {
        linearSource.removeFeature(toRemove[i]);
        // Remove from tracking set so shapes can be re-fetched if user pans back
        let attrs = toRemove[i].get('attributes');
        const itemKey = getLoadedItemKey(attrs?.itemId);
        if (itemKey && loadedSet) {
            loadedSet.delete(itemKey);
        }
    }
}

async function getShapesInExtent(extent, elementId) {
    let map = maps[elementId];
    if (!map)
        return;

    let objectRef = dotnetObjectRef[elementId];
    if (!objectRef)
        return;

    if (map.isInCall === true) {
        // Queue it rather than drop it, so the call that follows an in-flight one still happens.
        map.pendingShapesExtent = extent;
        return;
    }

    // The server answers this one only for a map showing local assets, or for the asset picker
    // (see BaseMapComponent.GetAdditionalShapes, which returns null otherwise). Asking anyway
    // cost a call into .NET on every pan of every ordinary map - and on a Blazor Server circuit
    // that is a network round trip, which is a large part of why panning dragged.
    if (map.mapOptions.shapesVisibility !== true && map.mapOptions.isSelectWithMap !== true) {
        return;
    }

    map.isInCall = true;

    try {
        // Prune out-of-view shapes when feature count gets large to prevent unbounded memory growth
        pruneOutOfViewShapes(elementId, extent);

        // A search looks everywhere, not just at what is currently in view.
        const searching = isSelectWithMapSearchActive(map);

        const shapeRequest = {
            X1: searching ? 0 : extent[0],
            Y1: searching ? 0 : extent[1],
            X2: searching ? 0 : extent[2],
            Y2: searching ? 0 : extent[3],
            IsShowLocalAssets: map.mapOptions.shapesVisibility ?? false
        };

        const data = await objectRef.invokeMethodAsync('GetAdditionalShapes', shapeRequest);

        if (data) {
            // Populate Shapes callback
            await populateShapesCallback(data, map, elementId);
        }
    } catch (e) {
        // Silently ignore — cancellation, disposal, etc.
    } finally {
        map.isInCall = false;
    }

    if (map.clearStoredShapes)
        map.clearStoredShapes = !map.clearStoredShapes;

    // Anything that arrived while this was running runs now, with the latest extent.
    if (map.pendingShapesExtent) {
        const pendingExtent = map.pendingShapesExtent;
        map.pendingShapesExtent = null;
        await getShapesInExtent(pendingExtent, elementId);
    }
}

async function populateShapesCallback(shapesDictionary, map, elementId) {
    let mapProjection = getWKIDCodeFromProjection(map.getView().getProjection().getCode());

    let shapesToAdd = [];
    if (typeof shapesDictionary == 'string' && shapesDictionary !== '') {
        let shapes;
        try {
            shapes = JSON.parse(shapesDictionary);
        } catch (r) {
            shapes = [];
        }

        for (let i = 0; i < shapes.length; i++) {
            for (var j = 0; j < shapes[i].length; j++) {
                if (shapes[i][j].attributes != null) {
                    const itemKey = getLoadedItemKey(shapes[i][j].attributes.itemId);
                    if (itemKey) {
                        loadedShapesItemsId[elementId].add(itemKey);
                    }
                    shapes[i][j].attributes.isHideableShape = true;
                }
            }

            await createAssetShapes(shapesToAdd, shapes[i], {
                mapOptionsWkId: mapProjection,
                appWkId: maps[elementId]?.appWkId ?? map.appWkId
            }, elementId);
        }
    } else {
        for (let prop in shapesDictionary) {
            const itemKey = getLoadedItemKey(prop);
            if (itemKey) {
                loadedShapesItemsId[elementId].add(itemKey);
            }

            for (var j = 0; j < shapesDictionary[prop].length; j++)
                shapesDictionary[prop][j].attributes = {
                    ...shapesDictionary[prop][j].attributes,
                    itemId: shapesDictionary[prop][j].attributes?.itemId ?? prop,
                    isHideableShape: true
                };

            await createAssetShapes(shapesToAdd, shapesDictionary[prop], {
                mapOptionsWkId: mapProjection,
                appWkId: maps[elementId]?.appWkId ?? map.appWkId
            }, elementId);
        }
    }

    //addFeaturesToMultiSelect(shapesToAdd);

    if (shapesToAdd.length > 0) {
        addUniqueLinearFeatures(elementId, shapesToAdd);
    }
}

function getFeatureLabels(feature, map, layer) {
    var text = '';

    // A layer's labels are drawn whatever the labels toggle says, unless the map was built to
    // have them follow it - the video survey page, where a thousand labelled track sections
    // would otherwise sit on top of the survey's own line.
    if (map.mapOptions?.layerLabelsFollowToggle === true && map.mapOptions.mapLabelsEnabled !== true) {
        return text;
    }

    // Switched off for this layer by the user (the layers panel's Show labels), or by its
    // configuration (ShowLabels).
    if (layer.showLabels === false) {
        return text;
    }

    // From the layer's label zoom on, that level included - see getDisplayedZoomLevel.
    if (map.getView().getZoom() != undefined && getDisplayedZoomLevel(map.getView().getZoom()) >= parseInt(layer.labelMinZoomLevel)) {
        const fields = layer.labelField.split(',');
        for (let i = 0; i < fields.length; i++) {
            if (text.length > 0) {
                text += ', ';
            }
            text += feature.get(fields[i].replace(/ /g, ''));
        }
    }

    return stringDivider(text, 32, '\n');
}

// The layers a source brings with it - its base map and its overlays - as against the ones that
// belong to the map itself: the items, the linear features, the drawing and measuring layers, the
// heat map. Only the first kind is replaced when the base map changes.
const MAP_SOURCE_LAYER_FLAG = 'fromMapSource';

function markLayersFromMapSource(mapLayerGroup, geoLayers) {
    mapLayerGroup?.set(MAP_SOURCE_LAYER_FLAG, true);

    (geoLayers ?? []).forEach(layer => layer?.set(MAP_SOURCE_LAYER_FLAG, true));
}


// The layers panel's grouping for one overlay (MapLayersPanel, getMapLayerTreeForModal): the icon beside its
// name, and the groups it is listed under inside its OpenLayers group - an asset layer split by asset type
// (AssetTypeLayerGrouping) is under its type. The map draws one level of group; deeper levels are the panel's
// alone, so every layer stays one step from the map's own list and everything that walks it finds them.
function tagMapPanelGrouping(olLayer, layer) {
    olLayer.set('iconKey', layer.iconKey || null);

    const subGroups = [];
    if (layer.groupName) {
        for (const level of [
            { title: layer.subGroupName, iconKey: layer.subGroupIconKey },
            { title: layer.subSubGroupName, iconKey: layer.subSubGroupIconKey }
        ]) {
            if (!level.title) {
                break;
            }

            subGroups.push({ title: level.title, iconKey: level.iconKey || null });
        }
    }

    olLayer.set('panelSubGroups', subGroups);
}

function createGeoLayers(mapOptions, mapObjects, arcGisLayers, elementId) {
    //layerSources = [];
    var layers = [];
    arcGisLayers = arcGisLayers || [];
    var argGisLayerIndex = 0;
    var layerStyles = {};
    var layerGroups = {};

    let layerDataArray = mapOptions?.mapSource?.layers;

    function getFeatureLayerStyle(feature, resolution, layer) {
        let map = maps[elementId];
        if (!layer)
            return;

        const layerStyle = layerStyles[layer.name];

        var textStyle;

        if (layer.labelField != null && layer.labelField != '') {
            const followsLine = feature.getGeometry() instanceof ol.geom.LineString || feature.getGeometry() instanceof ol.geom.MultiLineString;
            let text = getFeatureLabels(feature, map, layer);

            // Held back where it would come closer than the user's spacing to another layer label.
            if (!hasLayerLabelRoom(map, feature, resolution, text, layerStyle.textFont,
                {line: followsLine, textAlign: 'center', textBaseline: 'bottom', offsetY: -5})) {
                text = '';
            }

            textStyle = new ol.style.Text({
                fill: layerStyle.textFill,
                stroke: layerStyle.textStroke,
                font: layerStyle.textFont,
                placement: followsLine ? 'line' : undefined,
                textAlign: 'center',
                textBaseline: 'bottom',
                offsetY: -5,
                text: text
            });
        }

        switch (feature.getGeometry().getType()) {
            case 'Point': {
                const cicleStyle = new ol.style.Circle({
                    fill: layerStyle.fill,
                    stroke: layerStyle.stroke,
                    radius: 5,
                    // The point stays when its label is held back for room - it is the feature.
                    declutterMode: 'none'
                });

                return new ol.style.Style({
                    fill: layerStyle.fill,
                    stroke: layerStyle.stroke,
                    image: cicleStyle,
                    text: textStyle
                });
            }
            default:
                return new ol.style.Style({
                    fill: layerStyle.fill,
                    stroke: layerStyle.stroke,
                    text: textStyle
                });
        }
    }

    if (layerDataArray) {
        for (var i = 0; i < layerDataArray.length; i++) {
            const layer = layerDataArray[i];

            // One layer at a time, each behind its own guard. A layer whose configuration the
            // map cannot make sense of used to throw out of here and out of createMap with it,
            // which left the page with no map at all - a whole map lost to one bad row. Now it
            // is skipped and named, and the rest of the map draws.
            //
            // The body is left at its own indentation rather than moved in a level: it is seven
            // hundred lines long, and re-indenting it would bury this change in whitespace.
            try {

                if (layer.groupName != null && layer.groupName.length > 0 && layerGroups[layer.groupName] == undefined) {
                    layerGroups[layer.groupName] = new ol.layer.Group({
                        title: layer.groupName,
                        layers: []
                    });

                    layers.push(layerGroups[layer.groupName]);
                }

                if (layer.groupName && layer.groupIconKey && !layerGroups[layer.groupName].get('iconKey')) {
                    layerGroups[layer.groupName].set('iconKey', layer.groupIconKey);
                }

                // Where the layer is about to be put, so the one this row builds can be found afterwards
                // (tagMapPanelGrouping) without each layer type below having to do it.
                const panelGroupLayers = layer.groupName != null && layer.groupName.length > 0 ? layerGroups[layer.groupName].getLayers() : null;
                const panelLayerCount = panelGroupLayers ? panelGroupLayers.getLength() : layers.length;

                let textFill;
                let textStroke;

                switch (layer.layerType) {
                    case 'esriFeatureLayer':
                        if (layer.layerColour[0] != '#') {
                            layer.layerColour = '#' + layer.layerColour;
                        }
                        if (layer.layerOutlineColour !== undefined && layer.layerOutlineColour !== null && layer.layerOutlineColour[0] != '#') {
                            layer.layerOutlineColour = '#' + layer.layerOutlineColour;
                        }

                        var fillStyle = new ol.style.Fill({
                            color: [255, 0, 0, 0.4]
                        });

                        var strokeStyle = new ol.style.Stroke({
                            color: [255, 0, 0, 0.7],
                            width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                        });

                        var rgbColour = hexToRgb(layer.layerColour);
                        var outlineRgbColour = hexToRgb(layer.layerOutlineColour);

                        if (rgbColour != null && outlineRgbColour == null) {
                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });

                            strokeStyle = new ol.style.Stroke({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour == null && outlineRgbColour != null) {
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour != null && outlineRgbColour != null) {
                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });

                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        }

                        var featureIconSrc = (/\/wf\//i.test(window.location) ? '../' : '') + 'content/icon/triangle_red.png';
                        var iconStyle = new ol.style.Icon(({
                            anchor: [0.5, 0.5],
                            anchorXUnits: 'fraction',
                            anchorYUnits: 'fraction',
                            opacity: 0.75,
                            src: featureIconSrc
                        }));

                        if (layer.labelColour !== undefined && layer.labelColour !== null && layer.labelColour !== '') {
                            if (layer.labelColour[0] != '#') {
                                layer.labelColour = '#' + layer.labelColour;
                            }

                            let textColour = hexToRgb(layer.labelColour);

                            textFill = new ol.style.Fill({
                                color: [textColour.r, textColour.g, textColour.b, 1]
                            });

                            textStroke = new ol.style.Stroke({
                                color: [textColour.r, textColour.g, textColour.b, 1],
                                width: 1
                            });
                        } else {
                            textFill = fillStyle;
                            textStroke = strokeStyle;
                        }

                        layerStyles[layer.name] = {
                            fill: fillStyle,
                            stroke: strokeStyle,
                            image: iconStyle,
                            textFill: textFill,
                            textStroke: textStroke,
                            textFont: layer.labelFontSize !== undefined && layer.labelFontSize !== null && layer.labelFontSize !== '' ? layer.labelFontSize + 'px sans-serif' : undefined
                        };

                        var vector = new ol.layer.Vector({
                            title: layer.name,
                            source: getEsriSource(layer, elementId),
                            opacity: layer.layerOpacity,
                            visible: getStateOfLayerFromOptions(false, layer.name, layer.visible, mapObjects, mapOptions),
                            style: function (feature, resolution) {
                                return getFeatureLayerStyle(feature, resolution, layer);
                            }
                        });

                        vector.on("change:visible", updateLayerVisibleState);

                        vector.set('layerName', layer.name);
                        vector.set('layerBaseUrl', layer.url);
                        vector.set('labelField', layer.labelField);
                        vector.set('labelMinZoomLevel', layer.labelMinZoomLevel);
                        vector.set('layerSelectable', layer.layerSelectable);
                        vector.set('layerQueryParameters', layer.queryParameters);
                        vector.set('maxZoomLevel', layer.maxZoomLevel);
                        vector.set('minZoomLevel', layer.minZoomLevel);
                        vector.set('defaultVisible', layer.visible);
                        vector.set('idFieldName', layer.idFieldName);

                        var tokenObject = {
                            token: layer.token,
                            tokenExpiryDate: layer.tokenExpiryDate,
                            tokenNumber: layer.tokenNumber
                        };
                        vector.getSource().set('tokenObject', tokenObject);

                        if (layer.groupName != null && layer.groupName.length > 0) {
                            layerGroups[layer.groupName].getLayers().push(vector);
                        } else {
                            layers.push(vector);
                        }
                        break;
                    case 'arcGisFeatureLayer':
                        if (layer.layerColour[0] != '#') {
                            layer.layerColour = '#' + layer.layerColour;
                        }
                        if (layer.layerOutlineColour !== undefined && layer.layerOutlineColour !== null && layer.layerOutlineColour[0] != '#') {
                            layer.layerOutlineColour = '#' + layer.layerOutlineColour;
                        }

                        var fillStyle = new ol.style.Fill({
                            color: [255, 0, 0, 0.4]
                        });
                        var strokeStyle = new ol.style.Stroke({
                            color: [255, 0, 0, 0.7],
                            width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                        });

                        var rgbColour = hexToRgb(layer.layerColour);
                        var outlineRgbColour = hexToRgb(layer.layerOutlineColour);

                        if (rgbColour != null && outlineRgbColour == null) {

                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });
                            strokeStyle = new ol.style.Stroke({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour == null && outlineRgbColour != null) {
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour != null && outlineRgbColour != null) {

                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        }

                        var vector = new ol.layer.Vector({
                            title: layer.name,
                            source: getArcGISSource(layer, elementId),
                            visible: getStateOfLayerFromOptions(false, layer.name, layer.visible, mapObjects, mapOptions),
                            opacity: layer.layerOpacity,
                            style: function (feature, resolution) {
                                return getFeatureLayerStyle(feature, resolution, layer);
                            }
                        });

                        arcGisLayers.push(vector);
                        vector.on("change:visible", updateLayerVisibleState);

                        if (layer.labelColour !== undefined && layer.labelColour !== null && layer.labelColour !== '') {
                            if (layer.labelColour[0] != '#') {
                                layer.labelColour = '#' + layer.labelColour;
                            }

                            let textColour = hexToRgb(layer.labelColour);

                            textFill = new ol.style.Fill({
                                color: [textColour.r, textColour.g, textColour.b, 1]
                            });
                            textStroke = new ol.style.Stroke({
                                color: [textColour.r, textColour.g, textColour.b, 1],
                                width: 1
                            });
                        } else {
                            textFill = fillStyle;
                            textStroke = strokeStyle;
                        }

                        layerStyles[layer.name] = {
                            fill: fillStyle,
                            stroke: strokeStyle,
                            textFill: textFill,
                            textStroke: textStroke,
                            textFont: layer.labelFontSize !== undefined && layer.labelFontSize !== null && layer.labelFontSize !== '' ? layer.labelFontSize + 'px sans-serif' : undefined
                        };

                        vector.set('layerName', layer.name);
                        vector.set('layerBaseUrl', layer.url);
                        vector.set('labelField', layer.labelField);
                        vector.set('labelMinZoomLevel', layer.labelMinZoomLevel);
                        vector.set('layerSelectable', layer.layerSelectable);
                        vector.set('layerQueryParameters', layer.queryParameters);
                        vector.set('maxZoomLevel', layer.maxZoomLevel);
                        vector.set('minZoomLevel', layer.minZoomLevel);
                        vector.set('defaultVisible', layer.visible);
                        vector.set('idFieldName', layer.idFieldName);
                        vector.set('popupFields', layer.popupFields);
                        vector.set('isSearchable', true);

                        var tokenObject = {
                            token: layer.token,
                            tokenExpiryDate: layer.tokenExpiryDate,
                            tokenNumber: layer.tokenNumber
                        };
                        vector.getSource().set('tokenObject', tokenObject);

                        if (layer.groupName != null && layer.groupName.length > 0) {
                            layerGroups[layer.groupName].getLayers().push(vector);
                        } else {
                            layers.push(vector);
                        }
                        break;
                    case 'wmtsLayer':
                        var tile = new ol.layer.Tile({
                            cacheSize: MAP_TILE_CACHE_SIZE,
                            title: layer.name,
                            source: getWMTSSource(layer, mapOptions)
                        });

                        tile.on("change:visible", updateLayerVisibleState);
                        tile.set('layerName', layer.name);
                        tile.set('labelField', layer.labelField);
                        tile.set('labelMinZoomLevel', layer.labelMinZoomLevel);
                        tile.set('layerSelectable', layer.layerSelectable);
                        tile.set('layerQueryParameters', layer.queryParameters);
                        tile.set('maxZoomLevel', layer.maxZoomLevel);
                        tile.set('minZoomLevel', layer.minZoomLevel);
                        tile.set('defaultVisible', layer.visible);

                        if (layer.token != null)
                            var tokenObject = {
                                token: layer.token,
                                tokenExpiryDate: layer.tokenExpiryDate,
                                tokenNumber: layer.tokenNumber
                            };
                        tile.getSource().set('tokenObject', tokenObject);

                        if (layer.groupName != null && layer.groupName.length > 0) {
                            layerGroups[layer.groupName].getLayers().push(tile);
                        } else {
                            layers.push(tile);
                        }
                        break;
                    case 'wmtsLayerFromCapabilities':
                        var tile = new ol.layer.Tile({
                            cacheSize: MAP_TILE_CACHE_SIZE,
                            title: layer.name,
                            visible: getStateOfLayerFromOptions(false, layer.name, layer.visible, mapObjects, mapOptions)
                        });

                        tile.on("change:visible", updateLayerVisibleState);
                        tile.set('layerName', layer.name);
                        tile.set('labelField', layer.labelField);
                        tile.set('labelMinZoomLevel', layer.labelMinZoomLevel);
                        tile.set('layerSelectable', layer.layerSelectable);
                        tile.set('layerQueryParameters', layer.queryParameters);
                        tile.set('maxZoomLevel', layer.maxZoomLevel);
                        tile.set('minZoomLevel', layer.minZoomLevel);
                        tile.set('defaultVisible', layer.visible);

                        setWmtsSourceFromCap(tile, layer);

                        if (layer.groupName != null && layer.groupName.length > 0) {
                            layerGroups[layer.groupName].getLayers().push(tile);
                        } else {
                            layers.push(tile);
                        }
                        break;
                    case 'wfsFeatureLayer':
                        if (layer.layerColour[0] != '#') {
                            layer.layerColour = '#' + layer.layerColour;
                        }
                        if (layer.layerOutlineColour !== undefined && layer.layerOutlineColour !== null && layer.layerOutlineColour[0] != '#') {
                            layer.layerOutlineColour = '#' + layer.layerOutlineColour;
                        }

                        var fillStyle = new ol.style.Fill({
                            color: [255, 0, 0, 0.4]
                        });
                        var strokeStyle = new ol.style.Stroke({
                            color: [255, 0, 0, 0.7],
                            width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                        });

                        var rgbColour = hexToRgb(layer.layerColour);
                        var outlineRgbColour = hexToRgb(layer.layerOutlineColour);

                        if (rgbColour != null && outlineRgbColour == null) {

                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });
                            strokeStyle = new ol.style.Stroke({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour == null && outlineRgbColour != null) {
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour != null && outlineRgbColour != null) {

                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        }

                        var featureIconSrc = (/\/wf\//i.test(window.location) ? '../' : '') + 'content/icon/triangle_red.png';
                        var iconStyle = new ol.style.Icon(({
                            anchor: [0.5, 0.5],
                            anchorXUnits: 'fraction',
                            anchorYUnits: 'fraction',
                            opacity: 0.75,
                            src: featureIconSrc
                        }));

                        if (layer.labelColour !== undefined && layer.labelColour !== null && layer.labelColour !== '') {
                            if (layer.labelColour[0] != '#') {
                                layer.labelColour = '#' + layer.labelColour;
                            }

                            let textColour = hexToRgb(layer.labelColour);

                            textFill = new ol.style.Fill({
                                color: [textColour.r, textColour.g, textColour.b, 1]
                            });
                            textStroke = new ol.style.Stroke({
                                color: [textColour.r, textColour.g, textColour.b, 1],
                                width: 1
                            });
                        } else {
                            textFill = fillStyle;
                            textStroke = strokeStyle;
                        }

                        layerStyles[layer.name] = {
                            fill: fillStyle,
                            stroke: strokeStyle,
                            textFill: textFill,
                            image: iconStyle,
                            textStroke: textStroke,
                            textFont: layer.labelFontSize !== undefined && layer.labelFontSize !== null && layer.labelFontSize !== '' ? layer.labelFontSize + 'px sans-serif' : undefined
                        };

                        var vector = new ol.layer.Vector({
                            title: layer.name,
                            opacity: layer.layerOpacity,
                            visible: getStateOfLayerFromOptions(false, layer.name, layer.visible, mapObjects, mapOptions),
                            source: getWfsSource(layer, mapOptions),
                            style: function (feature, resolution) {
                                return getFeatureLayerStyle(feature, resolution, layer);
                            }
                        });

                        vector.on("change:visible", updateLayerVisibleState);
                        vector.set('layerName', layer.name);
                        vector.set('labelField', layer.labelField);
                        vector.set('labelMinZoomLevel', layer.labelMinZoomLevel);
                        vector.set('layerSelectable', layer.layerSelectable);
                        vector.set('layerQueryParameters', layer.queryParameters);
                        vector.set('maxZoomLevel', layer.maxZoomLevel);
                        vector.set('minZoomLevel', layer.minZoomLevel);
                        vector.set('defaultVisible', layer.visible);
                        vector.set('reloadFeaturesOnNavigation', layer.reloadFeaturesOnNavigation)

                        if (layer.token != null)
                            var tokenObject = {
                                token: layer.token,
                                tokenExpiryDate: layer.tokenExpiryDate,
                                tokenNumber: layer.tokenNumber
                            };
                        vector.getSource().set('tokenObject', tokenObject);

                        if (layer.groupName != null && layer.groupName.length > 0) {
                            layerGroups[layer.groupName].getLayers().push(vector);
                        } else {
                            layers.push(vector);
                        }
                        break;
                    case 'gmlFeatureLayer':
                        if (layer.layerColour[0] != '#') {
                            layer.layerColour = '#' + layer.layerColour;
                        }
                        if (layer.layerOutlineColour !== undefined && layer.layerOutlineColour !== null && layer.layerOutlineColour[0] != '#') {
                            layer.layerOutlineColour = '#' + layer.layerOutlineColour;
                        }

                        var fillStyle = new ol.style.Fill({
                            color: [255, 0, 0, 0.4]
                        });
                        var strokeStyle = new ol.style.Stroke({
                            color: [255, 0, 0, 0.7],
                            width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                        });

                        var rgbColour = hexToRgb(layer.layerColour);
                        var outlineRgbColour = hexToRgb(layer.layerOutlineColour);

                        if (rgbColour != null && outlineRgbColour == null) {

                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });
                            strokeStyle = new ol.style.Stroke({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour == null && outlineRgbColour != null) {
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour != null && outlineRgbColour != null) {

                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        }

                        if (layer.labelColour !== undefined && layer.labelColour !== null && layer.labelColour !== '') {
                            if (layer.labelColour[0] != '#') {
                                layer.labelColour = '#' + layer.labelColour;
                            }

                            let textColour = hexToRgb(layer.labelColour);

                            textFill = new ol.style.Fill({
                                color: [textColour.r, textColour.g, textColour.b, 1]
                            });
                            textStroke = new ol.style.Stroke({
                                color: [textColour.r, textColour.g, textColour.b, 1],
                                width: 1
                            });
                        } else {
                            textFill = fillStyle;
                            textStroke = strokeStyle;
                        }

                        layerStyles[layer.name] = {
                            fill: fillStyle,
                            stroke: strokeStyle,
                            textFill: textFill,
                            textStroke: textStroke,
                            textFont: layer.labelFontSize !== undefined && layer.labelFontSize !== null && layer.labelFontSize !== '' ? layer.labelFontSize + 'px sans-serif' : undefined
                        };

                        var vector = new ol.layer.Vector({
                            title: layer.name,
                            source: getGMLSource(layer, elementId),
                            opacity: layer.layerOpacity,
                            visible: getStateOfLayerFromOptions(false, layer.name, layer.visible, mapObjects, mapOptions),
                            type: 'GML',
                            style: function (feature, resolution) {
                                return getFeatureLayerStyle(feature, resolution, layer);
                            }
                        });

                        if (layer.groupName != null && layer.groupName.length > 0) {
                            layerGroups[layer.groupName].getLayers().push(vector);
                        } else {
                            layers.push(vector);
                        }

                        vector.on("change:visible", updateLayerVisibleState);
                        vector.set('layerName', layer.name);
                        vector.set('labelField', layer.labelField);
                        vector.set('labelMinZoomLevel', layer.labelMinZoomLevel);
                        vector.set('layerSelectable', layer.layerSelectable);
                        vector.set('layerQueryParameters', layer.queryParameters);
                        vector.set('maxZoomLevel', layer.maxZoomLevel);
                        vector.set('minZoomLevel', layer.minZoomLevel);
                        vector.set('defaultVisible', layer.visible);
                        vector.set('idFieldName', layer.idFieldName);
                        vector.set('isAmxFeaturesLayer', true);

                        var tokenObject = {
                            token: layer.token,
                            tokenExpiryDate: layer.tokenExpiryDate,
                            tokenNumber: layer.tokenNumber
                        };
                        vector.getSource().set('tokenObject', tokenObject);
                        break;
                    case 'kmlLayer':
                        if (layer.layerColour[0] != '#') {
                            layer.layerColour = '#' + layer.layerColour;
                        }
                        if (layer.layerOutlineColour !== undefined && layer.layerOutlineColour !== null && layer.layerOutlineColour[0] != '#') {
                            layer.layerOutlineColour = '#' + layer.layerOutlineColour;
                        }

                        var fillStyle = new ol.style.Fill({
                            color: [255, 0, 0, 0.4]
                        });
                        var strokeStyle = new ol.style.Stroke({
                            color: [255, 0, 0, 0.7],
                            width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                        });

                        var rgbColour = hexToRgb(layer.layerColour);
                        var outlineRgbColour = hexToRgb(layer.layerOutlineColour);

                        if (rgbColour != null && outlineRgbColour == null) {

                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });
                            strokeStyle = new ol.style.Stroke({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour == null && outlineRgbColour != null) {
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour != null && outlineRgbColour != null) {

                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        }

                        if (layer.labelColour !== undefined && layer.labelColour !== null && layer.labelColour !== '') {
                            if (layer.labelColour[0] != '#') {
                                layer.labelColour = '#' + layer.labelColour;
                            }

                            let textColour = hexToRgb(layer.labelColour);

                            textFill = new ol.style.Fill({
                                color: [textColour.r, textColour.g, textColour.b, 1]
                            });
                            textStroke = new ol.style.Stroke({
                                color: [textColour.r, textColour.g, textColour.b, 1],
                                width: 1
                            });
                        } else {
                            textFill = fillStyle;
                            textStroke = strokeStyle;
                        }

                        layerStyles[layer.name] = {
                            fill: fillStyle,
                            stroke: strokeStyle,
                            textFill: textFill,
                            textStroke: textStroke,
                            textFont: layer.labelFontSize !== undefined && layer.labelFontSize !== null && layer.labelFontSize !== '' ? layer.labelFontSize + 'px sans-serif' : undefined
                        };

                        var vector = new ol.layer.Vector({
                            title: layer.name,
                            source: getKmlLayerSource(layer, elementId),
                            opacity: layer.layerOpacity,
                            visible: getStateOfLayerFromOptions(false, layer.name, layer.visible, mapObjects, mapOptions),
                            type: 'KML',
                            style: function (feature, resolution) {
                                return getFeatureLayerStyle(feature, resolution, layer);
                            }
                        });

                        if (layer.groupName != null && layer.groupName.length > 0) {
                            layerGroups[layer.groupName].getLayers().push(vector);
                        } else {
                            layers.push(vector);
                        }

                        vector.on("change:visible", updateLayerVisibleState)

                        vector.set('layerName', layer.name);
                        vector.set('labelField', layer.labelField);
                        vector.set('labelMinZoomLevel', layer.labelMinZoomLevel);
                        vector.set('layerSelectable', layer.layerSelectable);
                        vector.set('layerQueryParameters', layer.queryParameters);
                        vector.set('maxZoomLevel', layer.maxZoomLevel);
                        vector.set('minZoomLevel', layer.minZoomLevel);
                        vector.set('defaultVisible', layer.visible);
                        vector.set('idFieldName', layer.idFieldName);
                        vector.set('isAmxFeaturesLayer', true);

                        var tokenObject = {
                            token: layer.token,
                            tokenExpiryDate: layer.tokenExpiryDate,
                            tokenNumber: layer.tokenNumber
                        };
                        vector.getSource().set('tokenObject', tokenObject);

                        break;
                    case 'amxFeatureLayer':
                        //let map = maps[elementId];

                        var vector = new ol.layer.Vector({
                            title: layer.name,
                            source: getAmxSource(layer, elementId),
                            visible: getStateOfLayerFromOptions(false, layer.name, layer.visible, mapObjects, mapOptions),
                            opacity: layer.layerOpacity,
                            style: getItemsLayerStyle,
                            declutter: false
                        });

                        vector.on("change:visible", updateLayerVisibleState);
                        vector.set('layerName', layer.name);
                        vector.set('labelField', layer.labelField);
                        vector.set('labelMinZoomLevel', layer.labelMinZoomLevel);
                        vector.set('layerSelectable', layer.layerSelectable);
                        vector.set('layerQueryParameters', layer.queryParameters);
                        vector.set('maxZoomLevel', layer.maxZoomLevel);
                        vector.set('minZoomLevel', layer.minZoomLevel);
                        vector.set('defaultVisible', layer.visible);
                        vector.set('idFieldName', layer.idFieldName);
                        vector.set('isAmxFeaturesLayer', true);
                        vector.set('isSearchable', true);

                        var tokenObject = {
                            token: layer.token,
                            tokenExpiryDate: layer.tokenExpiryDate,
                            tokenNumber: layer.tokenNumber
                        };
                        vector.getSource().set('tokenObject', tokenObject);

                        // Layer-level colours for the AMX feature layer.
                        if (layer.layerColour !== undefined && layer.layerColour !== null && layer.layerColour !== '' && layer.layerColour[0] != '#') {
                            layer.layerColour = '#' + layer.layerColour;
                        }
                        if (layer.layerOutlineColour !== undefined && layer.layerOutlineColour !== null && layer.layerOutlineColour !== '' && layer.layerOutlineColour[0] != '#') {
                            layer.layerOutlineColour = '#' + layer.layerOutlineColour;
                        }

                        var fillStyle = new ol.style.Fill({
                            color: [255, 0, 0, 0.4]
                        });
                        var strokeStyle = new ol.style.Stroke({
                            color: [255, 0, 0, 0.7],
                            width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                        });

                        var rgbColour = hexToRgb(layer.layerColour);
                        var outlineRgbColour = hexToRgb(layer.layerOutlineColour);

                        if (rgbColour != null && outlineRgbColour == null) {

                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });
                            strokeStyle = new ol.style.Stroke({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour == null && outlineRgbColour != null) {
                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        } else if (rgbColour != null && outlineRgbColour != null) {
                            fillStyle = new ol.style.Fill({
                                color: [rgbColour.r, rgbColour.g, rgbColour.b, 0.4]
                            });

                            strokeStyle = new ol.style.Stroke({
                                color: [outlineRgbColour.r, outlineRgbColour.g, outlineRgbColour.b, 0.7],
                                width: layer.layerOutlineThickness !== undefined && layer.layerOutlineThickness !== null && layer.layerOutlineThickness !== -1 ? layer.layerOutlineThickness : 1.25
                            });
                        }

                        if (layer.labelColour !== undefined && layer.labelColour !== null && layer.labelColour !== '') {
                            if (layer.labelColour[0] != '#') {
                                layer.labelColour = '#' + layer.labelColour;
                            }

                            let textColour = hexToRgb(layer.labelColour);

                            textFill = new ol.style.Fill({
                                color: [textColour.r, textColour.g, textColour.b, 1]
                            });
                            textStroke = new ol.style.Stroke({
                                color: [textColour.r, textColour.g, textColour.b, 1],
                                width: 1
                            });
                        } else {
                            textFill = fillStyle;
                            textStroke = strokeStyle;
                        }

                        layerStyles[layer.name] = {
                            fill: fillStyle,
                            stroke: strokeStyle,
                            textFill: textFill,
                            textStroke: textStroke,
                            textFont: layer.labelFontSize !== undefined && layer.labelFontSize !== null && layer.labelFontSize !== '' ? layer.labelFontSize + 'px sans-serif' : undefined
                        };

                        // AMX features are styled per feature by createAssetShapes (see getAmxSource) and drawn
                        // through getItemsLayerStyle, which never reads layerStyles. The layer fill / outline is
                        // therefore pushed onto each loaded feature by applyAmxLayerStyle. Only done when a colour
                        // is configured, so layers without one keep their per-shape colours.
                        if (rgbColour != null || outlineRgbColour != null) {
                            layer._amxLayerFill = rgbColour != null ? fillStyle : new ol.style.Fill({color: [255, 0, 0, 0]});
                            layer._amxLayerStroke = strokeStyle;
                            vector.set('amxLayerFill', layer._amxLayerFill);
                            vector.set('amxLayerStroke', layer._amxLayerStroke);
                        }

                        if (layer.groupName != null && layer.groupName.length > 0) {
                            layerGroups[layer.groupName].getLayers().push(vector);
                        } else {
                            layers.push(vector);
                        }
                        break;
                    default:
                        /*var params = {
                            'LAYERS': layer.name,
                        };
    
                        if (layer.queryParameters != null && layer.queryParameters != '') {
                            var parameters = layer.queryParameters.split(';');
                            for (var j = 0; j < parameters.length; j++) {
                                var paramName = parameters[j].split('=')[0];
                                var paramValue = parameters[j].split('=')[1];
                                params[paramName] = paramValue;
                            }
                        }*/

                        var mapLayer = new ol.layer.Image({
                            title: layer.name,
                            visible: getStateOfLayerFromOptions(false, layer.name, layer.visible, mapObjects, mapOptions),
                            opacity: layer.layerOpacity,
                            source: getImageSource(layer, elementId),
                        });

                        mapLayer.on("change:visible", updateLayerVisibleState);

                        mapLayer.set('layerName', layer.name);
                        mapLayer.set('layerBaseUrl', layer.url);
                        mapLayer.set('labelField', layer.labelField);
                        mapLayer.set('labelMinZoomLevel', layer.labelMinZoomLevel);
                        mapLayer.set('layerSelectable', layer.layerSelectable);
                        mapLayer.set('layerQueryParameters', layer.queryParameters);
                        mapLayer.set('layerQueryParameters', layer.queryParameters);
                        mapLayer.set('maxZoomLevel', layer.maxZoomLevel);
                        mapLayer.set('minZoomLevel', layer.minZoomLevel);
                        mapLayer.set('defaultVisible', layer.visible);
                        mapLayer.set('idFieldName', layer.idFieldName);
                        mapLayer.set('mapElementId', elementId);

                        var tokenObject = {
                            token: layer.token,
                            tokenExpiryDate: layer.tokenExpiryDate,
                            tokenNumber: layer.tokenNumber
                        };

                        mapLayer.getSource().set('tokenObject', tokenObject);

                        if (layer.groupName != null && layer.groupName.length > 0) {
                            layerGroups[layer.groupName].getLayers().push(mapLayer);
                        } else {
                            layers.push(mapLayer);
                        }
                }

                const builtLayer = panelGroupLayers
                    ? (panelGroupLayers.getLength() > panelLayerCount ? panelGroupLayers.item(panelGroupLayers.getLength() - 1) : null)
                    : (layers.length > panelLayerCount ? layers[layers.length - 1] : null);
                if (builtLayer) {
                    tagMapPanelGrouping(builtLayer, layer);
                }
            } catch (error) {
                console.error('Map layer skipped - it could not be built:', layer?.name, error);
            }
        }
    }

    // A layer's zoom range is where it draws, not whether its switch can be on: the user can switch it on
    // at any zoom, and OpenLayers leaves it out of the frame (and loads nothing for it) outside the range.
    layers.forEach(applyLayerZoomRange);

    return layers;
}

// The range is in whole zoom levels as the map shows them, both ends included (isLayerWithinZoomRange
// rounds the view's fractional zoom); OpenLayers draws a layer above its minZoom and up to its maxZoom.
// 0 and 30 are what an empty cell is read as (LocationDataService), so they set no limit.
function applyLayerZoomRange(layer) {
    if (layer instanceof ol.layer.Group) {
        layer.getLayers().forEach(applyLayerZoomRange);
        return;
    }

    const min = layer.get('minZoomLevel');
    const max = layer.get('maxZoomLevel');

    if (Number.isFinite(min) && min > 0)
        layer.setMinZoom(min - 0.5);

    if (Number.isFinite(max) && max < 30)
        layer.setMaxZoom(max + 0.5);
}

// The layer an AMX feature service feature came from - its configuration, the map's own copy -
// put on each feature as it loads. The features are drawn and clicked through the items layer's
// code, which is handed the feature and not the layer, and the layer's options are read off it
// live: its label zoom ("Label min zoom level") and the user's Show labels, Selectable and Show
// pop-up from the layers panel, which change while the features are on the map.
const AMX_LAYER_CONFIG_KEY = 'amxLayerConfig';

function setAmxLayerConfig(features, layer) {
    if (!layer) {
        return;
    }

    for (const feature of features) {
        feature.set(AMX_LAYER_CONFIG_KEY, layer, true);
    }
}

// Whether an AMX feature service feature is labelled: its layer's labels are on, and the map is
// zoomed in as far as the layer's label zoom.
function isAmxLayerLabelShown(feature, zoomLevel) {
    const layer = feature.get(AMX_LAYER_CONFIG_KEY);
    if (!layer) {
        return true;
    }

    if (layer.showLabels === false) {
        return false;
    }

    const minZoom = parseInt(layer.labelMinZoomLevel);

    return !Number.isFinite(minZoom) || minZoom <= 0 || (zoomLevel !== undefined && getDisplayedZoomLevel(zoomLevel) >= minZoom);
}

// Whether clicking an AMX feature service feature picks it (the layer's Selectable), and whether
// picking it shows the pop-up rather than opening the record (its Show pop-up). Anything not from
// such a layer - an item - is always picked, and the map's own pop-up switch decides for it.
function isAmxLayerFeatureSelectable(feature) {
    return feature?.get?.(AMX_LAYER_CONFIG_KEY)?.layerSelectable !== false;
}

function isAmxLayerFeaturePopupShown(feature) {
    return feature?.get?.(AMX_LAYER_CONFIG_KEY)?.layerSelectableShowPopup !== false;
}

// Pushes the layer-level fill / outline configured on an amxFeatureLayer onto the
// per-feature styles built by createAssetShapes. Point icons are left alone.
function applyAmxLayerStyle(features, layer) {
    if (!layer || (!layer._amxLayerFill && !layer._amxLayerStroke)) {
        return;
    }

    for (let i = 0; i < features.length; i++) {
        const feature = features[i];
        const geometry = feature.getGeometry();
        if (!geometry || geometry instanceof ol.geom.Point) {
            continue;
        }

        // Street Manager works are coloured by event type, which the key explains.
        if (feature.get('attributes')?.entityKey === STREET_MANAGER_ENTITY_KEY) {
            continue;
        }

        const featureStyle = feature.get('style');
        if (!featureStyle) {
            continue;
        }

        // Lines with an outline/background carry [outline, background, line]; the visible
        // line is the last one, which is what getItemsLayerStyle draws.
        const mainStyle = Array.isArray(featureStyle) ? featureStyle[featureStyle.length - 1] : featureStyle;
        if (!mainStyle || typeof mainStyle.setFill !== 'function') {
            continue;
        }

        if (layer._amxLayerFill) {
            mainStyle.setFill(layer._amxLayerFill);
        }
        if (layer._amxLayerStroke) {
            mainStyle.setStroke(layer._amxLayerStroke);
        }
    }
}

// The Street Manager permit events layer (StreetManagerPermitEvents on the server): live events
// with no AMX record behind them, drawn in their own colours.
const STREET_MANAGER_LAYER_KEY = 'sm_permitsevents';
const STREET_MANAGER_ENTITY_KEY = 'streetmanage';
const STREET_MANAGER_WKID = '27700';

function isStreetManagerLayer(layer) {
    return (layer?.url ?? '').trim().toLowerCase() === STREET_MANAGER_LAYER_KEY;
}

// Redraws a map's Street Manager layer once tiles it was waiting on have arrived on the server. Its
// source asks again for what is in view, which the server answers from the tiles at once. Several
// tiles land close together, so the redraws are run as one.
const streetManagerRefreshTimers = {};

function refreshStreetManagerLayer(elementId) {
    clearTimeout(streetManagerRefreshTimers[elementId]);
    streetManagerRefreshTimers[elementId] = setTimeout(() => {
        delete streetManagerRefreshTimers[elementId];
        const map = maps[elementId];
        if (!map || disposedMaps[elementId]) {
            return;
        }

        const visit = (layers) => layers.forEach((layer) => {
            if (typeof layer.getLayers === 'function') {
                visit(layer.getLayers().getArray());
            } else if (layer.get('isAmxFeaturesLayer') && isStreetManagerLayer({url: layer.getSource()?.get('streetManagerUrl')})) {
                layer.getSource().refresh();
            }
        });
        visit(map.getLayers().getArray());
    }, 400);
}

function getAmxSource(layer, elementId) {
    let loadTimeout;
    // Serialise the loads. Panning fires the loader per extent, and without this two of them
    // run at once: the second works out what it already has - and skips what the first is
    // about to add - before the first has added anything, so both requests come back with the
    // same shapes and both add them. Overlapping half-transparent shapes are what makes the
    // map darken with every pan. Chaining each load behind the last means every one of them
    // sees the batch before it. The timeout collapses the burst of extents a single drag
    // produces into one request.
    let inFlight = Promise.resolve();
    let pendingExtent = null;
    const vectorSource = new ol.source.Vector({
        strategy: ol.loadingstrategy.bbox,
        loader: function (extent, resolution, projection) {
            const source = this;

            // The extent being dropped here was written off as loaded the moment the loader was
            // called for it, so it has to be forgotten as well - otherwise nothing inside it is
            // ever fetched, and panning back to it shows an empty layer.
            if (pendingExtent) {
                source.removeLoadedExtent(pendingExtent);
            }

            pendingExtent = extent;
            clearTimeout(loadTimeout);
            loadTimeout = setTimeout(() => {
                pendingExtent = null;
                inFlight = inFlight
                    .then(async () => {
                        let map = maps[elementId];

                        if (!map || map.mapOptions.amxLayerLoad == null) {
                            return;
                        }

                        const view = map.getView();

                        let loadExtent = extent;
                        let loadWkId = map.mapOptions.mapSource.wkId;
                        if (isStreetManagerLayer(layer)) {
                            // Street Manager covers England and works in British National Grid, so it
                            // is always asked in that, whatever the map is drawn in. Not the app's
                            // projection: the page does not always know it (it came only with a map
                            // source refresh), and an extent sent unconverted was read as metres of
                            // grid, kilometres wide and in the wrong place.
                            if (String(loadWkId) !== STREET_MANAGER_WKID && loadExtent[0] != undefined && loadExtent[0] !== null) {
                                loadExtent = ol.proj.transformExtent(loadExtent, 'EPSG:' + loadWkId, 'EPSG:' + STREET_MANAGER_WKID);
                            }
                            loadWkId = Number(STREET_MANAGER_WKID);
                        } else if (map.mapOptions.mapSource.wkId != layer.wkId && loadExtent[0] != undefined && loadExtent[0] !== null) {
                            loadExtent = ol.proj.transformExtent(loadExtent, 'EPSG:' + map.mapOptions.mapSource.wkId, 'EPSG:' + layer.wkId);
                        }

                        const loadShapes = async function (result, elementId) {
                            if (result.useEntityModel) {
                                var assetShapes = [];
                                for (var i = 0; i < result.shapeArray.length; i++) {
                                    result.shapeArray[i].isAmxFeatureServiceFeature = true;
                                    await createAssetIcon(assetShapes, result.shapeArray[i]);
                                    await createAssetShapes(assetShapes, result.shapeArray[i].shapes);
                                }
                                applyAmxLayerStyle(assetShapes, layer);
                                setAmxLayerConfig(assetShapes, layer);
                                addUniqueCustomLayerFeatures(vectorSource, assetShapes);
                            } else {
                                var assetShapes = [];
                                for (var i = 0; i < result.shapeArray.length; i++) {
                                    result.shapeArray[i].attributes.isAmxFeatureServiceFeature = true;
                                }
                                const mapProjection = getWKIDCodeFromProjection(view.getProjection().getCode());
                                await createAssetShapes(assetShapes, result.shapeArray, {
                                    mapId: elementId,
                                    mapOptionsWkId: mapProjection
                                });
                                applyAmxLayerStyle(assetShapes, layer);
                                setAmxLayerConfig(assetShapes, layer);
                                addUniqueCustomLayerFeatures(vectorSource, assetShapes);
                            }
                        };

                        const featuresInExtent = source.getFeaturesInExtent(view.calculateExtent(map.getSize()));

                        const itemIdsInExtent = [];
                        for (let i = 0; i < featuresInExtent.length; i++) {
                            if (featuresInExtent[i].get('attributes') !== undefined && featuresInExtent[i].get('attributes').itemId != null) {
                                itemIdsInExtent.push(featuresInExtent[i].get('attributes').itemId);
                            }
                        }

                        const entityCustomLayerFilter = {
                            entityKey: layer.url,
                            queryParameters: layer.queryParameters,
                            wkId: loadWkId,
                            x1: loadExtent[0],
                            y1: loadExtent[1],
                            x2: loadExtent[2],
                            y2: loadExtent[3],
                            clearStoredShapes: false,
                            labelField: layer.labelField,
                            layerId: layer.layerId || null,
                            itemIdsInExtent: itemIdsInExtent
                        };

                        const data = await map.mapOptions.amxLayerLoad(elementId, entityCustomLayerFilter);
                        if (data) {
                            await loadShapes(data, elementId);
                        } else {
                            // Nothing came back because the load failed, not because the area is
                            // empty: forget the extent so the next pan or zoom asks for it again.
                            source.removeLoadedExtent(extent);
                        }
                    })
                    .catch(() => {
                    });
            }, 250);
        }
    });
    // What refreshStreetManagerLayer looks for.
    vectorSource.set('streetManagerUrl', layer?.url ?? '');
    return vectorSource;
}

// What makes a custom-layer feature the same feature as one already on the map. The item id
// alone is not enough: an item contributes an icon and any number of shapes, which would then
// all collapse into one. Geometry separates the shapes; the id separates two items that sit
// at the same point.
function getCustomLayerFeatureKey(feature) {
    const geometry = feature.getGeometry();
    if (!geometry) {
        return null;
    }

    const attributes = feature.get('attributes');
    const itemId = attributes?.itemId;
    const featureKind = attributes?.icon ? 'icon' : 'shape';

    return JSON.stringify([
        featureKind,
        featureKind === 'icon' && itemId !== undefined && itemId !== null
            ? String(itemId)
            : '',
        geometry.getType(),
        geometry.getCoordinates()
    ]);
}

// Adds custom-layer features, skipping any the source already holds. The keys are built from
// what is already there without touching the source, so a duplicate is never added and then
// taken away again - which the user sees as a flash.
function addUniqueCustomLayerFeatures(source, features) {
    if (!source || !features?.length) {
        return;
    }

    const existingKeys = new Set();
    for (const feature of source.getFeatures()) {
        const key = getCustomLayerFeatureKey(feature);
        if (key) {
            existingKeys.add(key);
        }
    }

    const uniqueFeatures = [];
    for (const feature of features) {
        const key = getCustomLayerFeatureKey(feature);

        // Nothing to match on - added rather than dropped, so no layer data goes missing.
        if (!key) {
            uniqueFeatures.push(feature);
            continue;
        }

        if (existingKeys.has(key)) {
            continue;
        }

        existingKeys.add(key);
        uniqueFeatures.push(feature);
    }

    if (uniqueFeatures.length > 0) {
        source.addFeatures(uniqueFeatures);
    }
}

// The same idea for the map layer control's geo layers, which carry an id of their own.
function getGeoFeatureKey(feature, idField) {
    if (idField) {
        const idVal = feature.get(idField);
        if (idVal !== undefined && idVal !== null && idVal !== '') {
            return 'id:' + String(idVal);
        }
    }

    const featureId = feature.getId();
    if (featureId !== undefined && featureId !== null && featureId !== '') {
        return 'fid:' + String(featureId);
    }

    const geometry = feature.getGeometry();
    if (!geometry) {
        return null;
    }

    return 'geom:' + geometry.getType() + ':' + JSON.stringify(geometry.getCoordinates());
}

// Adds geo-layer features while skipping any already present (matched on the layer's id field,
// the feature id, or the geometry). These are bbox-strategy sources too, so their loaders fire
// for overlapping extents and would otherwise draw the same features on top of themselves.
function addUniqueFeaturesToSource(source, features, idField) {
    if (!source || !features?.length) {
        return;
    }

    const existingKeys = new Set();
    for (const feature of source.getFeatures()) {
        const key = getGeoFeatureKey(feature, idField);
        if (key) {
            existingKeys.add(key);
        }
    }

    const uniqueFeatures = [];
    for (const feature of features) {
        const key = getGeoFeatureKey(feature, idField);

        if (!key) {
            uniqueFeatures.push(feature);
            continue;
        }

        if (existingKeys.has(key)) {
            continue;
        }

        existingKeys.add(key);
        uniqueFeatures.push(feature);
    }

    if (uniqueFeatures.length > 0) {
        source.addFeatures(uniqueFeatures);
    }
}

function getImageSource(layer, elementId) {
    const objectRef = dotnetObjectRef[elementId];

    const params = {};
    const paramsArray = layer.queryParameters.split(';');
    for (let i = 0; i < paramsArray.length; i++) {
        const paramName = paramsArray[i].split('=')[0];
        const paramValue = paramsArray[i].split('=')[1];
        params[paramName] = paramValue;
    }

    return new ol.source.ImageWMS({
        url: layer.url,
        params: params,
        hidpi: layer.hidpi,
        serverType: layer.server,
        crossOrigin: 'Anonymous',
        projection: layer.wkId !== undefined && layer.wkId !== null ? 'EPSG:' + layer.wkId : null,
        imageLoadFunction: (tile, src) => {
            try {
                // A WMS image, through the same tile endpoint - it carries no referer or token of
                // its own, but the source may still be one the browser cannot reach directly.
                if (src.includes('openstreetmap')) {
                    tile.getImage().src = src;
                    return;
                }

                tile.getImage().src = getTileProxyUrl(src, null, null);
            } catch (error) {
                if (tile.setState !== undefined)
                    tile.setState(3);
            }
        }
    });
}

function getStateOfLayerFromOptions(baseLayer, layerTitle, defaultState, mapObjects, mapOptions) {
    if (baseLayer) {
        const baseLayerPropName = (layerTitle ? layerTitle.replace(/ |\.|\(|\)/g, '_') : "");
        return (mapObjects.layersVisibilityStates != undefined && mapObjects.layersVisibilityStates.baseLayers?.[baseLayerPropName] != undefined) ?
            mapObjects.layersVisibilityStates.baseLayers[baseLayerPropName] :
            (mapOptions.mapSource.userDefaultLayerStates != undefined && mapOptions.mapSource.userDefaultLayerStates.baseLayers?.[baseLayerPropName] != undefined) ?
                mapOptions.mapSource.userDefaultLayerStates.baseLayers[baseLayerPropName] :
                (mapOptions.mapSource.userDefaultLayerStates != undefined && mapOptions.mapSource.userDefaultLayerStates.baseLayers?.[baseLayerPropName] != undefined) ?
                    mapOptions.mapSource.userDefaultLayerStates.baseLayers[baseLayerPropName] : defaultState;
    } else {
        const layerPropName = (layerTitle ? layerTitle.replace(/ |\.|\(|\)/g, '_') : "");
        return (mapObjects.layersVisibilityStates != undefined && mapObjects.layersVisibilityStates.geoLayers?.[layerPropName] != undefined) ?
            mapObjects.layersVisibilityStates.geoLayers[layerPropName] :
            (mapOptions.mapSource.userDefaultLayerStates != undefined && mapOptions.mapSource.userDefaultLayerStates.geoLayers?.[layerPropName] != undefined) ?
                mapOptions.mapSource.userDefaultLayerStates.geoLayers[layerPropName] :
                (mapOptions.mapSource.userDefaultLayerStates != undefined && mapOptions.mapSource.userDefaultLayerStates.geoLayers?.[layerPropName] != undefined) ?
                    mapOptions.mapSource.userDefaultLayerStates.geoLayers[layerPropName] : defaultState;
    }
}

function updateLayerVisibleState(elementId) {
    if (!elementId)
        return;

    let map = maps[elementId];
    if (!map || !map.mapObjects)
        return;

    map.mapObjects.layersVisibilityStates = getLayersVisibilityStates(elementId);

    // The layer control carries the count of what is on, so it moves with the states. Called
    // through a capability check rather than directly: the control classes are separate script
    // files, so a browser holding an older mapLayerControl.js than this file would otherwise
    // throw here and take the layer toggle down with it.
    updateMapLayerActiveCount(elementId);

    //if (searchMapControl) {
    //    searchMapControl.resetActiveMapLayersList();
    //}
}

function getLayersVisibilityStates(elementId) {
    return mapControls[elementId]?.mapLayerControlControl?.getLayersVisibilityStates();
}

// Refreshes the "how many layers are on" count on the layer control, if this browser has a
// mapLayerControl.js new enough to carry one. The count is a nicety; nothing that changes a
// layer should fail because it could not be updated.
function updateMapLayerActiveCount(elementId) {
    try {
        const layerControl = mapControls[elementId]?.mapLayerControlControl;

        if (typeof layerControl?.updateActiveCount === 'function') {
            layerControl.updateActiveCount();
        }
    } catch {
    }
}

async function getAmxLayerData(elementId, entityCustomLayerFilter) {
    let objectRef = dotnetObjectRef[elementId];
    if (objectRef) {
        try {
            return await objectRef.invokeMethodAsync('GetAmxLayerDetails', entityCustomLayerFilter);
        } catch (error) {
            console.warn('Failed to load AMX map layer details.', error);
            return null;
        }
    }
}

function hexToRgb(hex) {
    if (hex === undefined || hex === null)
        return null;

    const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
    hex = hex.replace(shorthandRegex, function (m, r, g, b) {
        return r + r + g + g + b + b;
    });

    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    return result ? {
        r: parseInt(result[1], 16),
        g: parseInt(result[2], 16),
        b: parseInt(result[3], 16)
    } : null;
}

// A vector source loader that does its work asynchronously and adds its own features. Since
// OpenLayers 10.9 a loader that returns a promise has the promise's result added as the features it
// loaded, and a failure reported - and these loaders resolve to nothing, so every load was reported
// as failed and the layer redrawn for it. The promise is dropped instead, as it was before.
function loaderWithoutPromise(load) {
    return function (extent, resolution, projection) {
        load.call(this, extent, resolution, projection);
    };
}

function getArcGISSource(layer, elementId) {
    return new ol.source.Vector({
        strategy: ol.loadingstrategy.bbox,
        loader: loaderWithoutPromise(async function (extent, resolution, projection) {
            let map = maps[elementId];

            let objectRef = dotnetObjectRef[elementId];

            let controls = mapControls[elementId];
            if (layer == undefined) {
                return;
            }

            if (extent[0] == -Infinity && !isNaN(extent[0])) {
                if (map != undefined) {
                    //extent = transformExtentFromLongLat(map.getView().calculateExtent(map.getSize()));
                    extent = map.getView().calculateExtent(map.getSize());
                } else {
                    extent = projection.getExtent();
                }
            }

            if (map.mapOptions.mapSource.wkId != layer.wkId && extent[0] != undefined && extent[0] !== null) {
                extent = ol.proj.transformExtent(extent, 'EPSG:' + map.mapOptions.mapSource.wkId, 'EPSG:' + layer.wkId);
            }

            var localVectorSource = this;
            var layerUrl = layer.url;

            if (layerUrl[layerUrl.length - 1] != '/') {
                layerUrl = layerUrl + '/';
            }

            var where;
            var useLocalHandler = false;
            var currentFeatures = localVectorSource.getFeaturesInExtent(extent);
            if (currentFeatures.length > 0 && !layer.reloadFeaturesOnNavigation) {
                var length = currentFeatures.length > 150 ? 150 : currentFeatures.length;
                var objectIds = [];
                for (var i = 0; i < length; i++) {
                    objectIds.push(currentFeatures[i].get(layer.idFieldName));
                }

                where = layer.idFieldName + "%20not%20in(" + (objectIds.join()) + ')';
            } else {
                where = "1%3D1";
            }

            var hasWhereParameter = false;

            let query = 'query/';
            if (/api.naturalresources.wales/i.test(layerUrl))
                query = ''


            var url = layerUrl + query + '?f=json&returnGeometry=true&outFields=' + encodeURIComponent(layer.idFieldName + (layer.labelField != null && layer.labelField != '' ? (',' + layer.labelField) : ''))
                + '&outSR=' + layer.wkId
                + '&geometry=' + encodeURIComponent('{"xmin":' + extent[0] + ',"ymin":' + extent[1] + ',"xmax":' + extent[2] + ',"ymax":' + extent[3] + ',"spatialReference":{"wkid":' + layer.wkId + '}}');

            if (layer.queryParameters != null && layer.queryParameters != '') {
                var parameters = layer.queryParameters.split(';');
                for (var i = 0; i < parameters.length; i++) {
                    var paramSplit = parameters[i].split('=');
                    var paramName = paramSplit.shift();
                    var paramValue = paramSplit.join('=');


                    if (paramName === 'where') {
                        paramValue = encodeURIComponent('(' + paramValue + ') AND ') + where;
                        url += '&' + paramName + '=' + paramValue;
                        hasWhereParameter = true;
                    } else if (paramName === 'tokenuselocalhandler') {
                        useLocalHandler = true;
                    } else {
                        if (paramName === 'token') {
                            paramValue = localVectorSource.get('tokenObject').token;
                        }
                        url += '&' + paramName + '=' + encodeURIComponent(paramValue);
                    }
                }
            }

            if (!hasWhereParameter) {
                url += '&where=' + where;
            }

            let headers = {};
            if (layer.header1 != undefined && layer.header1 != null && layer.header1 != '') {
                headers[layer.header1.toString()] = layer.header1Value;
            }

            if (layer.header2 != undefined && layer.header2 != null && layer.header2 != '') {
                headers[layer.header2.toString()] = layer.header2Value;
            }

            if (headers[layer.header1] !== undefined || headers[layer.header2] !== undefined) {
                let layerArray = layerUrl.split('/');
                const layerDomain = layerArray[0] + "//" + layerArray[2];
                headers["Access-Control-Allow-Origin"] = layerDomain;
            }

            await objectRef.invokeMethodAsync('GetArcGISSourceData', {
                url: url,
                headers: headers
            }).then(async (data) => {
                if (!data) {
                    return;
                }

                if (data.objectIdFieldName === undefined) {
                    data.objectIdFieldName = layer.idFieldName;
                }

                // dataProjection will be read from document
                const features = esrijsonFormat.readFeatures(data, {
                    featureProjection: projection
                });

                const geometryMap = new Map();

                features.forEach(feature => {
                    let featureGeom = feature.getGeometry();
                    if (!featureGeom)
                        return;

                    const geometryValue = JSON.stringify(featureGeom.getCoordinates());

                    if (!geometryMap.has(geometryValue)) {
                        geometryMap.set(geometryValue, [feature]);
                    } else {
                        geometryMap.get(geometryValue).push(feature);
                    }
                });

                // Gathered first and added in one go, so what the source already holds from an
                // earlier extent can be filtered out - see addUniqueFeaturesToSource.
                const featuresToAdd = [];

                geometryMap.forEach(featuresArray => {
                    if (featuresArray.length > 1) {
                        const firstFeature = featuresArray[0];
                        const propertyNames = Object.keys(firstFeature.getProperties())
                            .filter(key => key !== layer.idFieldName);

                        let targetProperty;
                        propertyNames.forEach(propertyName => {
                            if (!targetProperty) {
                                const value = firstFeature.get(propertyName);
                                if (typeof value !== 'object') {
                                    targetProperty = propertyName;
                                }
                            }
                        });

                        if (targetProperty) {
                            const propertyValues = featuresArray.map(feature => feature.get(targetProperty));
                            const combinedValue = propertyValues.join('\n');
                            firstFeature.set(targetProperty, combinedValue);
                            featuresToAdd.push(firstFeature);
                        } else {
                            featuresArray.forEach(feature => {
                                featuresToAdd.push(feature);
                            });
                        }
                    } else {
                        featuresToAdd.push(featuresArray[0]);
                    }
                });

                addUniqueFeaturesToSource(localVectorSource, featuresToAdd, layer.idFieldName);
            }).catch(async (error) => {

            });

            this.resolution = resolution;
        }),
        crossOrigin: "Anonymous"
    });
}

function getEsriSource(layer, elementId) {
    var vectorSource = new ol.source.Vector({
        strategy: ol.loadingstrategy.all,
        loader: loaderWithoutPromise(async function (extent, resolution, projection) {
            let objectRef = dotnetObjectRef[elementId];

            var url = layer.url;
            const localVectorSource = this;
            if (layer.queryParameters != null && layer.queryParameters != '') {
                if (url[url.length - 1] !== '?')
                    url += '?';

                const parameters = layer.queryParameters.split(';');
                for (let i = 0; i < parameters.length; i++) {
                    const paramName = parameters[i].split('=')[0];
                    let paramValue = parameters[i].split('=')[1];
                    if (paramName == 'token') {
                        paramValue = localVectorSource.get('tokenObject').token;
                    }
                    url += (i !== 0 ? '&' : '') + paramName + '=' + encodeURIComponent(paramValue);
                }
            }

            if (url.indexOf("http") == -1)
                url = "https://" + url;

            let headers = {};
            if (layer.header1 != undefined && layer.header1 != null && layer.header1 != '') {
                headers[layer.header1] = layer.header1Value;
            }
            if (layer.header2 != undefined && layer.header2 != null && layer.header2 != '') {
                headers[layer.header2] = layer.header2Value;
            }

            await objectRef.invokeMethodAsync('GetArcGISSourceData', {
                url: url,
                headers: headers
            }).then(async (data) => {
                if (!data) {
                    return;
                }

                if (layer.reloadFeaturesOnNavigation) {
                    vectorSource.removeLoadedExtent(extent);
                    vectorSource.clear();
                }
                // dataProjection will be read from document
                const features = esrijsonFormat.readFeatures(data, {
                    featureProjection: projection
                });

                if (features.length > 0) {
                    addUniqueFeaturesToSource(vectorSource, features, layer.idFieldName);
                }
            }).catch(async (error) => {

            });
        }),
        crossOrigin: 'anonymous'
    });

    return vectorSource;
}

function getImageWMSSourceEval(layer) {
    var source;
    var sourceString = layer.source;
    if (layer.tokenNumber > 0) {
        sourceString = sourceString.replace('[token' + layer.tokenNumber + ']', layer.token);
    }

    source = eval(sourceString);

    return source;
}

function getImageWMSSource(layer, elementId) {
    // The tokenised link, not layer.source - the token was being worked out and then dropped, so
    // a secured WMS base map was asked for without it. The configured query parameters are the
    // WMS parameters (LAYERS, STYLES, FORMAT...), as they are for the tiled WMS source; a link
    // that already carries them keeps working, since OpenLayers keeps the link's own query.
    return new ol.source.ImageWMS({
        url: substituteLayerToken(layer.source, layer),
        params: getWmsParams(layer.queryParameters),
        // Asked for, as every other source here asks for it: without it the layer taints the
        // map's canvas and the map cannot be exported.
        crossOrigin: 'Anonymous',
        projection: getLayerProjectionCode(layer.wkId)
    });
}

function getImageArcGIS(layer) {
    return new ol.source.ImageArcGISRest({
        ratio: 1,
        params: {},
        url: substituteLayerToken(layer.source, layer),
        crossOrigin: 'Anonymous',
        projection: getLayerProjectionCode(layer.wkId)
    });
}

// 'EPSG:' + the layer's WKID when it names a projection the map knows, otherwise undefined so
// the source takes the view's projection. 'EPSG' + wkId (no colon) used to be passed here, and a
// layer with no WKID was given 'EPSG:' - neither is a projection.
function getLayerProjectionCode(wkId) {
    if (wkId === undefined || wkId === null || String(wkId).trim() === '' || Number(wkId) === 0) {
        return undefined;
    }

    const code = 'EPSG:' + String(wkId).trim();
    if (!ol.proj.get(code)) {
        console.warn('Map layer projection ' + code + ' is not registered with the map; using the map\'s own projection.');
        return undefined;
    }

    return code;
}

// Puts the layer's token into every [tokenN] placeholder in a link. The server sends the token
// with the layer as `token` and its number as `tokenNumber`.
function substituteLayerToken(url, layer) {
    if (!url || !(layer?.tokenNumber > 0) || layer.token === undefined || layer.token === null) {
        return url;
    }

    return url.split('[token' + layer.tokenNumber + ']').join(layer.token);
}

// "name=value;name=value" - the queryParameters column - as an object keyed by lower-case name.
// A value may itself contain '='.
function parseLayerQueryParameters(queryParameters) {
    const parameters = {};

    (queryParameters || '').split(';').forEach(function (pair) {
        const separator = pair.indexOf('=');
        if (separator <= 0) {
            return;
        }

        parameters[pair.slice(0, separator).trim().toLowerCase()] = pair.slice(separator + 1).trim();
    });

    return parameters;
}

// The queryParameters column as WMS request parameters, names kept as configured.
function getWmsParams(queryParameters) {
    const params = {};

    (queryParameters || '').split(';').forEach(function (pair) {
        const separator = pair.indexOf('=');
        if (separator <= 0) {
            return;
        }

        params[pair.slice(0, separator).trim()] = pair.slice(separator + 1).trim();
    });

    return params;
}

// Appends the layer's queryParameters to a link as a query string, putting the layer's token in
// for a parameter named "token" as the ArcGIS sources do.
function appendLayerQueryParameters(url, layer) {
    if (!layer?.queryParameters) {
        return url;
    }

    let result = url;
    layer.queryParameters.split(';').forEach(function (pair) {
        const separator = pair.indexOf('=');
        if (separator <= 0) {
            return;
        }

        const name = pair.slice(0, separator).trim();
        let value = pair.slice(separator + 1).trim();
        if (name.toLowerCase() === 'token' && layer.token) {
            value = layer.token;
        }

        result += (result.indexOf('?') === -1 ? '?' : (/[?&]$/.test(result) ? '' : '&'))
            + encodeURIComponent(name) + '=' + encodeURIComponent(value);
    });

    return result;
}

// The KVP names of the WMTS protocol itself. Anything else on a configured WMTS link - an API key
// such as Ordnance Survey's key=, a token= - belongs to the provider and has to go on every tile
// request too, because the tile addresses in a capabilities document do not carry it.
const WMTS_PROTOCOL_PARAMETERS = ['service', 'request', 'version', 'layer', 'style', 'tilematrixset',
    'tilematrix', 'tilerow', 'tilecol', 'format'];

function getProviderQueryPairs(url) {
    const queryStart = url.indexOf('?');
    if (queryStart === -1) {
        return [];
    }

    return url.slice(queryStart + 1).split('&').filter(function (pair) {
        if (!pair) {
            return false;
        }

        const name = decodeURIComponent(pair.split('=')[0]).toLowerCase();
        return WMTS_PROTOCOL_PARAMETERS.indexOf(name) === -1;
    });
}

// Adds query pairs a URL (or a WMTS {TileMatrix} template) does not already have. Built as a
// string: the URL class would percent-encode a template's braces.
function appendMissingQueryPairs(url, pairs) {
    let result = url;

    pairs.forEach(function (pair) {
        const name = pair.split('=')[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        if (new RegExp('[?&]' + name + '=', 'i').test(result)) {
            return;
        }

        result += (result.indexOf('?') === -1 ? '?' : (/[?&]$/.test(result) ? '' : '&')) + pair;
    });

    return result;
}

// The GetCapabilities address for a configured WMTS link: a link to the capabilities document
// itself (…/WMTSCapabilities.xml) as it is, anything else with SERVICE and REQUEST set.
function getWmtsCapabilitiesUrl(url) {
    const queryStart = url.indexOf('?');
    const path = queryStart === -1 ? url : url.slice(0, queryStart);

    if (/\.xml$/i.test(path)) {
        return url;
    }

    const pairs = queryStart === -1 ? [] : url.slice(queryStart + 1).split('&').filter(function (pair) {
        return pair && !/^(service|request)=/i.test(pair);
    });

    pairs.push('SERVICE=WMTS', 'REQUEST=GetCapabilities');

    return path + '?' + pairs.join('&');
}

// Reads a WMTS service's capabilities and gives `tileLayer` a source for the configured layer.
// Configuration: `layer`, `matrixset` (or `tilematrixset`), `style` and `format` in
// queryParameters; without `layer` the service's first layer is used, and without a matrix set
// the one matching the layer's projection is chosen. The source carries the token and referer like
// every other source here, and `onSourceReady` runs once it is set.
function setWmtsSourceFromCapabilities(tileLayer, config, configuredUrl, onSourceReady) {
    const name = config?.name || 'WMTS layer';

    if (!configuredUrl) {
        console.error('Map layer "' + name + '" is configured as WMTS from capabilities but has no link.');
        return;
    }

    const url = substituteLayerToken(configuredUrl, config);
    const parameters = parseLayerQueryParameters(config.queryParameters);
    const providerPairs = getProviderQueryPairs(url);

    // Logged with the configured link, never the tokenised one.
    fetch(getWmtsCapabilitiesUrl(url))
        .then(function (response) {
            if (!response.ok) {
                throw new Error('the service answered ' + response.status);
            }

            return response.text();
        })
        .then(function (text) {
            const capabilities = new ol.format.WMTSCapabilities().read(text);
            const capabilityLayers = capabilities?.Contents?.Layer || [];
            const layerName = parameters.layer || capabilityLayers[0]?.Identifier;
            const matrixSet = parameters.matrixset || parameters.tilematrixset;

            const options = ol.source.WMTS.optionsFromCapabilities(capabilities, {
                layer: layerName,
                matrixSet: matrixSet,
                style: parameters.style,
                format: parameters.format,
                projection: matrixSet ? undefined : getLayerProjectionCode(config.wkId)
            });

            if (!options) {
                throw new Error('the service has no layer "' + layerName + '"'
                    + (matrixSet ? ' in tile matrix set "' + matrixSet + '"' : '')
                    + '. It offers: ' + capabilityLayers.map(function (l) {
                        return l.Identifier;
                    }).join(', '));
            }

            options.urls = (options.urls || []).map(function (tileUrl) {
                return appendMissingQueryPairs(tileUrl, providerPairs);
            });
            options.crossOrigin = 'Anonymous';

            // OpenLayers 10.11 wraps the world whenever the first tile matrix spans it, so a national
            // basemap on a world-wide matrix set (GoogleMapsCompatible) would repeat sideways. As
            // before, a layer that gives its WGS84 bounds wraps only when they run the whole way round.
            const wgs84Bounds = capabilityLayers.find(function (l) {
                return l.Identifier === layerName;
            })?.WGS84BoundingBox;
            if (wgs84Bounds) {
                options.wrapX = wgs84Bounds[0] <= -180 + 1e-10 && wgs84Bounds[2] >= 180 - 1e-10;
            }

            options.cacheSize = MAP_TILE_CACHE_SIZE;

            const source = new ol.source.WMTS(options);
            source.set('tokenObject', {
                token: config.token,
                tokenExpiryDate: config.tokenExpiryDate,
                tokenNumber: config.tokenNumber
            });
            source.set('referer', config.referer);

            tileLayer.setSource(source);

            if (onSourceReady) {
                onSourceReady(tileLayer);
            }
        })
        .catch(function (error) {
            console.error('Map layer "' + name + '" could not read its WMTS capabilities from ' + configuredUrl + ':', error);
        });
}

// Base map (and additional base map) of type wmtsfromcapabilities.
function setWmtsBaseSourceFromCap(baseLayer, mapSource, onSourceReady) {
    setWmtsSourceFromCapabilities(baseLayer, mapSource, mapSource.source, onSourceReady);
}

// Overlay of type wmtsLayerFromCapabilities.
function setWmtsSourceFromCap(tileLayer, layer) {
    setWmtsSourceFromCapabilities(tileLayer, layer, layer.url);
}

// Overlay of type wmtsLayer: a WMTS source built from its configuration alone, on the standard
// power-of-two grid over the projection's extent - `layer`, `tilematrixset` (or `matrixset`),
// `format`, `style` and `version` in queryParameters. A service on any other grid needs
// wmtsLayerFromCapabilities instead.
function getWMTSSource(layer, mapOptions) {
    const parameters = parseLayerQueryParameters(layer.queryParameters);
    const projection = ol.proj.get(getLayerProjectionCode(layer.wkId) || 'EPSG:' + mapOptions?.mapSource?.wkId);
    const extent = projection ? projection.getExtent() : null;

    if (!extent) {
        throw new Error('WMTS layer "' + layer.name + '" needs a projection with a known extent to build its tile grid;'
            + ' configure it as wmtsLayerFromCapabilities instead.');
    }

    const size = ol.extent.getWidth(extent) / 256;
    const resolutions = [];
    const matrixIds = [];
    for (let z = 0; z < 14; z++) {
        resolutions.push(size / Math.pow(2, z));
        matrixIds.push(String(z));
    }

    return new ol.source.WMTS({
        url: substituteLayerToken(layer.url, layer),
        layer: parameters.layer,
        matrixSet: parameters.tilematrixset || parameters.matrixset,
        format: parameters.format || 'image/png',
        style: parameters.style ? parameters.style.replace(/ /g, '%20') : 'default',
        version: parameters.version || '1.0.0',
        projection: projection,
        tileGrid: new ol.tilegrid.WMTS({
            // A WMTS tile matrix counts from its top-left corner.
            origin: ol.extent.getTopLeft(extent),
            resolutions: resolutions,
            matrixIds: matrixIds
        }),
        crossOrigin: 'Anonymous',
        cacheSize: MAP_TILE_CACHE_SIZE,
        wrapX: true
    });
}

// GML from a file or a WFS GetFeature link, read as GML 3, then GML 2, then GML 3.2 - whichever
// finds features - wrapped in a WFS FeatureCollection or not. The features' projection is the
// layer's WKID when it has one, otherwise whatever srsName the document declares.
function readGmlFeatures(text, wkId, featureProjection) {
    const doc = ol.xml.parse(text);
    const dataProjection = getLayerProjectionCode(wkId);
    const readOptions = {featureProjection: featureProjection};
    if (dataProjection) {
        readOptions.dataProjection = dataProjection;
    }

    const formats = [ol.format.GML3, ol.format.GML2, ol.format.GML32];
    for (let i = 0; i < formats.length; i++) {
        try {
            const gmlFormat = new formats[i](dataProjection ? {srsName: dataProjection} : undefined);
            let features = new ol.format.WFS({gmlFormat: gmlFormat}).readFeatures(doc, readOptions);
            if (!features || features.length === 0) {
                features = gmlFormat.readFeatures(doc, readOptions);
            }

            // A version that is not the document's can still return features - with no
            // geometry, because it did not recognise the positions - so those do not count.
            if (features && features.some(function (feature) { return !!feature.getGeometry(); })) {
                features.forEach(function (feature) {
                    dropMissingZ(feature.getGeometry());
                });

                return features;
            }
        } catch (error) {
            // Not this GML version - try the next.
        }
    }

    return [];
}

// OpenLayers reads a 2D <gml:pos> as XYZ with a Z of 0, which the projection transform then
// turns into NaN. The data had no Z, so the geometry is put back to plain XY. The Z has to be taken
// out of the coordinates themselves: setCoordinates copies every value it is given, whatever the
// layout says.
function dropMissingZ(geometry) {
    if (!geometry || typeof geometry.getLayout !== 'function' || typeof geometry.setCoordinates !== 'function'
        || geometry.getLayout() !== 'XYZ') {
        return;
    }

    const flatCoordinates = geometry.getFlatCoordinates();
    for (let i = 2; i < flatCoordinates.length; i += 3) {
        if (!isNaN(flatCoordinates[i]) && flatCoordinates[i] !== 0) {
            return;
        }
    }

    const toXY = function (coordinates) {
        return typeof coordinates[0] === 'number' ? coordinates.slice(0, 2) : coordinates.map(toXY);
    };

    geometry.setCoordinates(toXY(geometry.getCoordinates()), 'XY');
}

function createGmlVectorSource(config, url) {
    const name = config?.name || 'GML layer';

    const vectorSource = new ol.source.Vector({
        strategy: ol.loadingstrategy.all,
        loader: function (extent, resolution, projection, success, failure) {
            fetch(url)
                .then(function (response) {
                    if (!response.ok) {
                        throw new Error('the service answered ' + response.status);
                    }

                    return response.text();
                })
                .then(function (text) {
                    const features = readGmlFeatures(text, config.wkId, projection);
                    if (features.length === 0) {
                        console.warn('Map layer "' + name + '" loaded but no GML features could be read from it.');
                    }

                    vectorSource.addFeatures(features);
                    if (success) {
                        success(features);
                    }
                })
                .catch(function (error) {
                    console.error('Map layer "' + name + '" could not load its GML:', error);
                    vectorSource.removeLoadedExtent(extent);
                    if (failure) {
                        failure();
                    }
                });
        }
    });

    return vectorSource;
}

// Overlay of type gmlFeatureLayer: layerUrl plus queryParameters.
function getGMLSource(layer, elementId) {
    return createGmlVectorSource(layer, appendLayerQueryParameters(substituteLayerToken(layer.url, layer), layer));
}

// Base map of type gml, or one whose source is an OpenLayers constructor string naming
// ol.format.GML. The constructor string is evaluated as the other legacy source strings are; a
// plain link is loaded and read like a GML overlay.
function getBaseGMLSource(mapSource) {
    const source = substituteLayerToken(mapSource.source, mapSource) || '';

    if (/^\s*new\s/.test(source)) {
        return eval(source);
    }

    return createGmlVectorSource(mapSource, source);
}

// Overlay of type kmlLayer. The KML's own styles are not used: the layer is drawn in the colours
// and labels configured for it, as every other overlay is.
function getKmlLayerSource(layer, elementId) {
    return new ol.source.Vector({
        strategy: ol.loadingstrategy.all,
        url: appendLayerQueryParameters(substituteLayerToken(layer.url, layer), layer),
        format: new ol.format.KML({extractStyles: false})
    });
}

function createMapLayerGroup(mapObjects, mapOptions, elementId) {
    let objectRef = dotnetObjectRef[elementId];

    let mapLayerGroup = new ol.layer.Group({
        title: 'Base maps',
        visible: true
    });

    // The main base map and each additional one are built behind their own guard, as overlays are
    // in createGeoLayers. A base map the map cannot build used to throw out of here and out of
    // createMap with it, leaving the page with no map at all; now it is skipped and named, and
    // the overlays and any other base maps still draw.
    //
    // The body is left at its own indentation rather than moved in a level, so this change is not
    // buried in whitespace.
    try {

    if (((mapOptions.mapSource.source && (mapOptions.mapSource.source.indexOf('ImageWMS') > -1)) || (mapOptions.mapSource.source && (mapOptions.mapSource.source.indexOf('ImageArcGIS') > -1))) &&
        (mapOptions.mapSource.mapType == '' || mapOptions.mapSource.mapType === undefined || mapOptions.mapSource.mapType === null)) {

        let baseLayer = new ol.layer.Image({
            title: mapOptions.mapSource.name,
            visible: getStateOfLayerFromOptions(true, mapOptions.mapSource.name, true, mapObjects, mapOptions),
            type: 'base',
            source: getImageWMSSourceEval(mapOptions.mapSource)
        });

        let tokenObject = {
            token: mapOptions.mapSource.token,
            tokenExpiryDate: mapOptions.mapSource.tokenExpiryDate,
            tokenNumber: mapOptions.mapSource.tokenNumber
        };

        baseLayer.getSource().set('tokenObject', tokenObject);
        baseLayer.getSource().set('referer', mapOptions.mapSource.referer);

        mapLayerGroup.getLayers().push(baseLayer);
    } else if (mapOptions.mapSource.mapType && mapOptions.mapSource.mapType.toLowerCase() === 'wms') {
        var baseLayer = new ol.layer.Image({
            title: mapOptions.mapSource.name,
            visible: getStateOfLayerFromOptions(true, mapOptions.mapSource.name, true, mapObjects, mapOptions),
            type: 'base',
            source: getImageWMSSource(mapOptions.mapSource)
        });

        var tokenObject = {
            token: mapOptions.mapSource.token,
            tokenExpiryDate: mapOptions.mapSource.tokenExpiryDate,
            tokenNumber: mapOptions.mapSource.tokenNumber
        };

        baseLayer.getSource().set('tokenObject', tokenObject);
        baseLayer.getSource().set('referer', mapOptions.mapSource.referer);

        mapLayerGroup.getLayers().push(baseLayer);
    } else if (mapOptions.mapSource.mapType && mapOptions.mapSource.mapType.toLowerCase() === 'imagearcgis') {
        var baseLayer = new ol.layer.Image({
            title: mapOptions.mapSource.name,
            visible: getStateOfLayerFromOptions(true, mapOptions.mapSource.name, true, mapObjects, mapOptions),
            type: 'base',
            source: getImageArcGIS(mapOptions.mapSource)
        });

        var tokenObject = {
            token: mapOptions.mapSource.token,
            tokenExpiryDate: mapOptions.mapSource.tokenExpiryDate,
            tokenNumber: mapOptions.mapSource.tokenNumber
        };

        baseLayer.getSource().set('tokenObject', tokenObject);
        baseLayer.getSource().set('referer', mapOptions.mapSource.referer);

        mapLayerGroup.getLayers().push(baseLayer);
    } else if (mapOptions.mapSource.mapType && mapOptions.mapSource.mapType.toLowerCase() === 'wmtsfromcapabilities') {
        // No source until the capabilities arrive. It used to be given getTileSource's URL
        // template source in the meantime, which asked the capabilities address for tiles.
        var baseLayer = new ol.layer.Tile({
            cacheSize: MAP_TILE_CACHE_SIZE,
            title: mapOptions.mapSource.name,
            visible: getStateOfLayerFromOptions(true, mapOptions.mapSource.name, true, mapObjects, mapOptions),
            type: 'base'
        });

        setWmtsBaseSourceFromCap(baseLayer, mapOptions.mapSource, attachTileLoader);

        mapLayerGroup.getLayers().push(baseLayer);
    } else if ((mapOptions.mapSource.source && (mapOptions.mapSource.source.indexOf('ol.format.GML') > -1) ||
        (mapOptions.mapSource.mapType && mapOptions.mapSource.mapType.toLowerCase() === 'gml'))) {
        var fillStyle = new ol.style.Fill({
            color: [255, 0, 0, 0.4]
        });
        var strokeStyle = new ol.style.Stroke({
            color: [255, 0, 0, 0.7],
            width: 1.25
        });

        var baseLayer = new ol.layer.Vector({
            title: mapOptions.mapSource.name,
            visible: getStateOfLayerFromOptions(true, mapOptions.mapSource.name, true, mapObjects, mapOptions),
            type: 'base',
            source: getBaseGMLSource(mapOptions.mapSource),
            style: new ol.style.Style({
                fill: fillStyle,
                stroke: strokeStyle
            })
        });

        var tokenObject = {
            token: mapOptions.mapSource.token,
            tokenExpiryDate: mapOptions.mapSource.tokenExpiryDate,
            tokenNumber: mapOptions.mapSource.tokenNumber
        };

        baseLayer.getSource().set('tokenObject', tokenObject);
        baseLayer.getSource().set('referer', mapOptions.mapSource.referer);

        mapLayerGroup.getLayers().push(baseLayer);
    } else {
        let isVisible = getStateOfLayerFromOptions(true, mapOptions.mapSource.name, true, mapObjects, mapOptions);

        if (mapOptions.mapSource.mapType == "pbf") {
            var sourceString = mapOptions.mapSource.source;
            if (mapOptions.mapSource.tokenNumber > 0) {
                sourceString = sourceString.replace('[token' + mapOptions.mapSource.tokenNumber + ']', mapOptions.mapSource.token);
            }

            // The style lives beside the tiles on an ArcGIS vector tile service, so it is derived
            // from the tile URL. When the configured URL is not in that shape the replace does
            // nothing and the "style" fetched is the tile template itself - which is what a
            // "glStyle version 8 required" from ol-mapbox-style is actually telling you.
            const vectorTileStyle = sourceString.replace('tile/{z}/{y}/{x}.pbf', 'resources/styles/root.json');

            if (vectorTileStyle === sourceString) {
                console.warn('Map source "' + mapOptions.mapSource.name + '" is configured as pbf but its URL does not'
                    + ' end in tile/{z}/{y}/{x}.pbf, so there is no style to read beside it:', sourceString);
            }

            // Add a lightweight raster fallback visible only at low zoom where PBF is too heavy
            var pbfFallbackLayer = new ol.layer.Tile({
                cacheSize: MAP_TILE_CACHE_SIZE,
                title: mapOptions.mapSource.name + ' (low zoom)',
                visible: isVisible,
                type: 'base',
                source: new ol.source.OSM({crossOrigin: "Anonymous"}),
                maxZoom: pbfMinZoom
            });
            mapLayerGroup.getLayers().push(pbfFallbackLayer);

            // No cacheSize: a vector tile layer keeps OpenLayers' own default, twice the tiles in
            // view. Each tile kept holds its features, its drawing instructions and an image of the
            // whole tile (4MB at a pixel ratio of 2), so a cache sized for raster tiles kept
            // every zoom level the map had been at, and zooming and panning slowed once it had been
            // zoomed around for a while (see drawVectorTilesForLessEachFrame). A level zoomed back to
            // is loaded and built again, a few tiles a frame.
            baseLayer = new ol.layer.VectorTile({
                title: mapOptions.mapSource.name,
                declutter: true,
                visible: getStateOfLayerFromOptions(true, mapOptions.mapSource.name, true, mapObjects, mapOptions),
                type: 'base',
                source: getTileSource(mapOptions.mapSource),
                renderBuffer: 50,
                preload: 0,
                updateWhileAnimating: false,
                updateWhileInteracting: false,
                minZoom: pbfMinZoom
            });

            // A vector basemap whose style cannot be read is not a basemap. It used to be given a
            // flat grey fill, which draws a map-shaped grey sheet over the whole view and reads as
            // "the map is broken" - so the raster fallback beneath it takes over instead, at every
            // zoom rather than only the low ones, and the vector layer stands down. The user gets
            // a working map on a source that is misconfigured, and the console says which one.
            applyBasemapStyle(baseLayer, vectorTileStyle, mapOptions.mapSource.resolutions)
                .catch(function (error) {
                    console.error('Map source "' + mapOptions.mapSource.name + '" has no usable vector style at '
                        + vectorTileStyle + ' - falling back to the raster basemap.', error);

                    baseLayer.setVisible(false);
                    pbfFallbackLayer.setMaxZoom(Infinity);
                });
        } else {
            var baseLayer = new ol.layer.Tile({
                cacheSize: MAP_TILE_CACHE_SIZE,
                title: mapOptions.mapSource.name,
                visible: isVisible,
                type: 'base',
                source: getTileSource(mapOptions.mapSource)
            });
        }

        var tokenObject = {
            token: mapOptions.mapSource.token,
            tokenExpiryDate: mapOptions.mapSource.tokenExpiryDate,
            tokenNumber: mapOptions.mapSource.tokenNumber
        };

        var baseLayerSource = baseLayer.getSource();
        if (baseLayerSource) {
            baseLayerSource.set('tokenObject', tokenObject);
            baseLayerSource.set('referer', mapOptions.mapSource.referer);
        }

        mapLayerGroup.getLayers().push(baseLayer);
    }

    } catch (error) {
        console.error('Base map skipped - it could not be built:', mapOptions.mapSource?.name, error);
    }

    // Every branch here builds from the additional base layer itself. The wms, imagearcgis and
    // wmtsfromcapabilities branches used to test and build from the parent map source instead, so
    // behind a WMS, ArcGIS image or WMTS-capabilities main base map each additional base map came
    // out as another copy of the main one, under the main one's name.
    if (mapOptions.mapSource.mapSources) {
        for (var i = 0; i < mapOptions.mapSource.mapSources.length; i++) {
            var additionalBaseLayer = mapOptions.mapSource.mapSources[i];

            try {
                var additionalMapType = additionalBaseLayer.mapType ? additionalBaseLayer.mapType.toLowerCase() : '';
                var additionalVisible = getStateOfLayerFromOptions(true, additionalBaseLayer.name, false, mapObjects, mapOptions);
                var layer;

                if (additionalBaseLayer.source && (additionalBaseLayer.source.indexOf('ImageWMS') > -1 || additionalBaseLayer.source.indexOf('ImageArcGIS') > -1)) {
                    layer = new ol.layer.Image({
                        title: additionalBaseLayer.name,
                        visible: additionalVisible,
                        type: 'base',
                        source: getImageWMSSourceEval(additionalBaseLayer),
                        opacity: additionalBaseLayer.layerOpacity
                    });
                } else if (additionalMapType === 'wms') {
                    layer = new ol.layer.Image({
                        title: additionalBaseLayer.name,
                        visible: additionalVisible,
                        type: 'base',
                        source: getImageWMSSource(additionalBaseLayer),
                        opacity: additionalBaseLayer.layerOpacity
                    });
                } else if (additionalMapType === 'imagearcgis') {
                    layer = new ol.layer.Image({
                        title: additionalBaseLayer.name,
                        visible: additionalVisible,
                        type: 'base',
                        source: getImageArcGIS(additionalBaseLayer),
                        opacity: additionalBaseLayer.layerOpacity
                    });
                } else if (additionalMapType === 'wmtsfromcapabilities') {
                    // No source until the capabilities arrive - see setWmtsBaseSourceFromCap.
                    layer = new ol.layer.Tile({
                        cacheSize: MAP_TILE_CACHE_SIZE,
                        title: additionalBaseLayer.name,
                        visible: additionalVisible,
                        type: 'base',
                        opacity: additionalBaseLayer.layerOpacity
                    });

                    setWmtsBaseSourceFromCap(layer, additionalBaseLayer, attachTileLoader);
                } else {
                    layer = new ol.layer.Tile({
                        cacheSize: MAP_TILE_CACHE_SIZE,
                        title: additionalBaseLayer.name,
                        visible: additionalVisible,
                        type: 'base',
                        source: getTileSource(additionalBaseLayer),
                        opacity: additionalBaseLayer.layerOpacity
                    });
                }

                var additionalSource = layer.getSource();
                if (additionalSource) {
                    additionalSource.set('tokenObject', {
                        token: additionalBaseLayer.token,
                        tokenExpiryDate: additionalBaseLayer.tokenExpiryDate,
                        tokenNumber: additionalBaseLayer.tokenNumber
                    });
                    additionalSource.set('referer', additionalBaseLayer.referer);
                }

                layer.set('keyPhotoUrl', additionalBaseLayer.layerKeyPhoto);

                mapLayerGroup.getLayers().push(layer);
            } catch (error) {
                console.error('Additional base map skipped - it could not be built:', additionalBaseLayer?.name, error);
            }
        }
    }

    for (let i = 0; i < mapLayerGroup.getLayers().getLength(); i++) {
        attachTileLoader(mapLayerGroup.getLayers().item(i));
    }

    return mapLayerGroup;

    // Routes a base layer's tiles through the AMX tile proxy when the source needs
    // it, and straight to the source otherwise. A function rather than the body of the loop above
    // so a source that arrives later - a WMTS base map built from its capabilities - is given the
    // same loader as one built up front.
    function attachTileLoader(currentLayer) {
        let layerSource = currentLayer.getSource();

        let referer = layerSource ? layerSource.get('referer') : null;
        let tokenObject = layerSource ? layerSource.get('tokenObject') : null;
        if (layerSource && layerSource.setImageLoadFunction) {
            layerSource.setImageLoadFunction(function (tile, src) {
                if (disposedMaps[elementId]) return;

                try {
                    // Nothing to add to the request, so the browser fetches it straight from the
                    // source - no proxy, no round trip.
                    if (src.includes('openstreetmap') || (!referer && !tokenObject?.tokenNumber)) {
                        requestImageCrossOrigin(tile.getImage()).src = src;
                        return;
                    }

                    requestImageCrossOrigin(tile.getImage()).src = getTileProxyUrl(src, referer, tokenObject);
                } catch (error) {
                    if (tile.setState !== undefined)
                        tile.setState(3);
                }
            });
        } else if (layerSource && layerSource.setTileLoadFunction) {
            if (layerSource instanceof ol.source.VectorTile) {
                // Only use custom tile load function if there's a referer requirement
                // Otherwise, let OpenLayers handle the tile loading natively
                if (referer && referer.length > 0) {
                    layerSource.setTileLoadFunction(async function (tile, src) {
                        if (disposedMaps[elementId]) return;

                        try {
                            // Fetched from our own tile endpoint, which adds the referer and
                            // the token - the browser's own fetch, not a trip through the
                            // circuit. See getTileProxyUrl.
                            const response = await fetch(getTileProxyUrl(src, referer, tokenObject));

                            if (disposedMaps[elementId]) return;

                            if (!response.ok) {
                                if (tile.setState !== undefined)
                                    tile.setState(3);

                                return;
                            }

                            const arrayBuffer = await response.arrayBuffer();

                            if (disposedMaps[elementId]) return;

                            const format = tile.getFormat(); // ol.format.MVT
                            const features = format.readFeatures(arrayBuffer, {
                                extent: tile.extent,
                                featureProjection: tile.projection,
                            });

                            tile.setFeatures(features);
                        } catch (error) {
                            if (disposedMaps[elementId]) return;

                            console.error('Map tile fetch error:', error);

                            if (tile.setState !== undefined)
                                tile.setState(3);
                        }
                    });
                }
                // If no referer, skip custom tile load function - let OpenLayers use default
            } else {

                layerSource.setTileLoadFunction(function (tile, src) {
                    if (disposedMaps[elementId]) return;

                    try {
                        // Nothing to add to the request, so the browser fetches it straight from
                        // the source - no proxy, no round trip.
                        if (src.includes('openstreetmap') || (!referer && !tokenObject?.tokenNumber)) {
                            requestImageCrossOrigin(tile.getImage()).src = src;
                            return;
                        }

                        requestImageCrossOrigin(tile.getImage()).src = getTileProxyUrl(src, referer, tokenObject);
                    } catch (error) {
                    }
                });
            }
        }
    }
}

async function getTileForSrc(src, referer) {
    const requestParams = {
        method: "GET",
        headers: {
            "Referer": referer
        }
    };

    return await fetch(src, requestParams).then(async (res) => {
        const blob = await res.blob();
        const newBlob = new Blob([blob]);

        return newBlob;
    });
}

function fetchGoogleSatelliteSessionToken(apiKey) {
    return fetch('https://tile.googleapis.com/v1/createSession?key=' + apiKey, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
            mapType: 'satellite',
            language: 'en-US',
            region: 'US'
        })
    })
        .then(function (response) {
            if (!response.ok) throw new Error('Failed to create Google Maps session: ' + response.status);
            return response.json();
        })
        .then(function (data) {
            return data.session;
        });
}

function setGoogleSatelliteSession(urlTemplate, sessionToken) {
    const session = encodeURIComponent(sessionToken);

    if (/[?&]session=/.test(urlTemplate)) {
        return urlTemplate.replace(/([?&]session=)[^&]*/, '$1' + session);
    }

    return urlTemplate + (urlTemplate.includes('?') ? '&' : '?') + 'session=' + session;
}

function getTileSource(layer) {
    var sourceString = layer.source;
    var source;

    if (layer.tokenNumber > 0) {
        sourceString = sourceString.replace('[token' + layer.tokenNumber + ']', layer.token);
    }

    if (layer.mapType?.length > 0) {
        let tileGird = null;
        if (layer.origin !== null && layer.resolutions !== null) {
            try {
                if (layer.mapType == "pbf" && layer.wkId == "27700" && layer.extent) {
                    tileGird = new ol.tilegrid.TileGrid({
                        extent: layer.extent,
                        resolutions: layer.resolutions,
                        origin: layer.origin,
                        crossOrigin: "Anonymous",
                        tileSize: 512
                    });
                } else if (layer.mapType.toLowerCase() == "wmts") {
                    /* Skipping sizes here as documentation say not necessary really, if in case it breaks need to check with sizes*/
                    const matrixIds = [];
                    for (let z = 0; z < 14; z++) {
                        matrixIds.push(z);
                    }
                    tileGird = new ol.tilegrid.WMTS({
                        origin: layer.origin,
                        matrixIds: matrixIds,
                        resolutions: layer.resolutions,
                        tileSize: [256, 256]
                    });
                } else {
                    tileGird = new ol.tilegrid.TileGrid({
                        resolutions: layer.resolutions, origin: layer.origin, crossOrigin: "Anonymous"
                    });
                }
            } catch (e) {
            }
        }
        if (layer && layer.mapType) {
            const paramsArray = layer.queryParameters?.split(';');
            switch (layer.mapType.toLowerCase()) {
                case 'osm':
                    source = new ol.source.OSM({crossOrigin: "Anonymous", cacheSize: 256});
                    break;
                case 'xyzesrimap':
                    source = new ol.source.XYZ({
                        url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
                        crossOrigin: "Anonymous",
                        projection: 'EPSG:3857',
                        cacheSize: 256
                    });
                    break;
                case 'googlesatellite': {
                    // The configuration holds the whole tile URL bar the scheme, session
                    // placeholder included, so only the session is rewritten once Google
                    // hands one back.
                    const tileUrl = 'https://' + sourceString.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
                    const apiKey = (tileUrl.match(/[?&]key=([^&]*)/) || [])[1];

                    source = new ol.source.XYZ({
                        url: tileUrl,
                        maxZoom: 20,
                        crossOrigin: 'anonymous',
                        projection: 'EPSG:3857',
                        cacheSize: 256
                    });

                    (function (src, urlTemplate, key) {
                        fetchGoogleSatelliteSessionToken(key).then(function (sessionToken) {
                            src.setUrl(setGoogleSatelliteSession(urlTemplate, sessionToken));
                        }).catch(function (err) {
                            console.error('Google satellite session error:', err);
                        });
                    })(source, tileUrl, apiKey);
                    break;
                }
                case 'xyz':
                    source = new ol.source.XYZ({
                        url: sourceString,
                        tileGrid: tileGird,
                        projection: 'EPSG:' + layer.wkId,
                        crossOrigin: "Anonymous",
                        cacheSize: 256
                    });

                    break;
                case 'wmts':
                    var layerName;
                    var matrixSet;
                    var format;
                    var style;

                    for (let i = 0; i < paramsArray.length; i++) {
                        const paramName = paramsArray[i].split('=')[0].toString().toLowerCase();
                        const paramValue = paramsArray[i].split('=')[1];

                        switch (paramName) {
                            case 'matrixset':
                                matrixSet = paramValue;
                                break;
                            case 'layer':
                                layerName = paramValue;
                                break;
                            case 'format':
                                format = paramValue;
                                break;
                            case 'style':
                                style = paramValue;
                                break;
                        }
                    }
                    source = new ol.source.WMTS({
                        url: sourceString,
                        layer: layerName,
                        matrixSet: matrixSet,
                        format: format,
                        style: style,
                        projection: 'EPSG:' + layer.wkId,
                        tileGrid: tileGird,
                        crossOrigin: "Anonymous",
                        cacheSize: 256
                    });
                    break;
                case 'pbf':
                    source = new ol.source.VectorTile({
                        format: new ol.format.MVT(),
                        tileGrid: tileGird,
                        url: sourceString,
                        projection: 'EPSG:' + layer.wkId,
                        crossOrigin: "Anonymous",
                        // Parsed vector tiles are expensive to rebuild - decoding the protobuf and
                        // running the style over every feature in it - so keeping more of them
                        // means panning back over ground already visited costs nothing.
                        cacheSize: 512,
                        transition: 0,
                        minZoom: pbfMinZoom
                    });
                    break;
                case 'imagetile':
                    source = new ol.source.ImageTile({
                        url: sourceString,
                        projection: 'EPSG:' + layer.wkId,
                        crossOrigin: "Anonymous",
                        cacheSize: 256
                    });
                    break;
                case 'tilewms':
                    let paramOptions = {};
                    paramsArray.forEach(param => {
                        const [rawKey, rawValue] = param.split('=');
                        if (rawKey && rawValue) {
                            const key = rawKey.trim();
                            const value = rawValue.trim();

                            const parsedValue =
                                value.toLowerCase() === 'true' ? true :
                                    value.toLowerCase() === 'false' ? false :
                                        !isNaN(value) ? Number(value) :
                                            value;

                            paramOptions[key] = parsedValue;
                        }
                    });
                    const sourceOptions = {
                        url: sourceString,
                        projection: 'EPSG:' + layer.wkId,
                        tileGrid: tileGird,
                        params: paramOptions,
                        crossOrigin: "Anonymous"
                    };
                    source = new ol.source.TileWMS(sourceOptions);
                    break;
                case 'none':
                    source = eval(sourceString);
                    break;
                default:
                    // A URL-template tile source, the same as the 'xyz' case above and taking the
                    // same options. This branch used to name a TileSource class on the ol.source
                    // namespace, which the library has never exposed - its abstract tile base is
                    // not constructable - so any layer whose server type fell through to here
                    // threw instead of drawing. Not something the OpenLayers upgrade caused: that
                    // name is absent from the 6.x build this replaced as well.
                    source = new ol.source.XYZ({
                        url: sourceString,
                        tileGrid: tileGird,
                        projection: 'EPSG:' + layer.wkId,
                        crossOrigin: "Anonymous",
                        cacheSize: 256
                    });
            }
        }
    } else
        source = eval(sourceString);

    return source;
}

function getWfsSource(layer, mapOptions) {
    const paramsArray = layer.queryParameters.split(';');
    var outputFormat;
    var subType;
    var outputFormatVersion;
    var typeName;
    var typeNames;
    var version;
    let format;
    var token;
    var useLocalHandler = false;

    for (let i = 0; i < paramsArray.length; i++) {
        const paramName = paramsArray[i].split('=')[0].toString().toLowerCase();
        const paramValue = paramsArray[i].split('=')[1];

        switch (paramName.toLowerCase()) {
            case 'type':
            case 'typename':
            case 'layer':
                typeName = paramValue;
                break;
            case 'typenames':
                typeNames = paramValue;
                break;
            case 'version':
                version = paramValue;
                break;
            case 'outputformat':
                outputFormat = paramValue
                break;
            case 'subtype':
                subType = paramValue;
                break;
            case 'outputformatversion':
                outputFormatVersion = paramValue;
                break;
            case 'format':
                switch (paramValue.toLowerCase()) {
                    case 'gml2':
                        format = new ol.format.GML2();
                        break;
                    case 'gml3':
                        format = new ol.format.GML3();
                        break;
                    case 'gml32':
                        format = new ol.format.GML32();
                        break;
                    case 'kml':
                        format = new ol.format.KML();
                        break;
                    case 'wfs':
                        format = new ol.format.WFS();
                        break;
                    case 'esrijson':
                        format = new ol.format.EsriJSON();
                        break;
                    case 'topojson':
                        format = new ol.format.TopoJSON();
                        break;
                    case 'geojson':
                        format = new ol.format.GeoJSON();
                        break;
                    default:
                        format = new ol.format.GeoJSON();
                        break;
                }
                break;
            case 'tokenuselocalhandler':
                useLocalHandler = true;
                break;
            //default:
            //    nonStandardParams.push(paramsArray[i]);
            //    break;

        }
    }

    if (!format) {
        format = new ol.format.GeoJSON();
    }

    const urlFunction = function (extent) {
        let formattedUrl = layer.url;

        if (layer.url.endsWith('/') && !layer.url.endsWith('?'))
            formattedUrl += '?';

        if (!layer.url.endsWith('/') && !layer.url.endsWith('?'))
            formattedUrl += '/?';

        if (layer.url.indexOf('service') == -1)
            formattedUrl = formattedUrl + (!formattedUrl.endsWith('?') ? '&' : '') + 'service=WFS';

        if (layer.url.indexOf('request') == -1)
            formattedUrl = formattedUrl + (!formattedUrl.endsWith('?') ? '&' : '') + 'request=GetFeature';

        if (layer.url.indexOf('typenames') == -1 && typeNames)
            formattedUrl = formattedUrl + (!formattedUrl.endsWith('?') ? '&' : '') + 'typenames=' + typeNames;
        else if (layer.url.indexOf('typename') == -1) {
            formattedUrl = formattedUrl + (!formattedUrl.endsWith('?') ? '&' : '') + 'typename=' + typeName;
        }

        if (layer.url.indexOf('version') == -1)
            formattedUrl = formattedUrl + (!formattedUrl.endsWith('?') ? '&' : '') + 'version=' + (version !== null && version !== undefined ? version : '1.1.0');

        if (layer.url.indexOf('outputformat') == -1) {
            formattedUrl = formattedUrl + (!formattedUrl.endsWith('?') ? '&' : '') + 'outputFormat=' + (outputFormat !== null && outputFormat !== undefined ? outputFormat : 'application/json');
            if (layer.url.indexOf('subtype') == -1 && subType !== null && subType !== undefined)
                formattedUrl = formattedUrl + '%3B%20subtype=' + subType;

            if (layer.url.indexOf('20version') == -1 && outputFormatVersion !== null && outputFormatVersion !== undefined)
                formattedUrl = formattedUrl + '%3B%20version=' + outputFormatVersion;
        }

        if (layer.url.indexOf('srsname') == -1)
            formattedUrl = formattedUrl + (!formattedUrl.endsWith('?') ? '&' : '') + 'srsname=EPSG:' + layer.wkId;

        if (token !== undefined && token !== null)
            formattedUrl = formattedUrl + (!formattedUrl.endsWith('?') ? '&' : '') + 'token:' + token;

        formattedUrl += (!formattedUrl.endsWith('?') ? '&bbox=' : '&bbox=') + extent.join(',') + ',EPSG:' + mapOptions.mapSource.wkId;

        return formattedUrl;
    }

    const loader = function (extent, resolution, projection) {
        const url = urlFunction(extent);

        let headers = {};
        if (layer.header1 != undefined && layer.header1 != null && layer.header1 != '') {
            headers[layer.header1] = layer.header1Value;
        }
        if (layer.header2 != undefined && layer.header2 != null && layer.header2 != '') {
            headers[layer.header2] = layer.header2Value;
        }

        $.ajax({
            url: url,
            dataType: 'json',
            headers: headers,
            success: function (response) {
                if (response.error) {
                    layer.wkId
                    vectorSource.removeLoadedExtent(extent);
                } else {
                    if (layer.reloadFeaturesOnNavigation) {
                        vectorSource.removeLoadedExtent(extent);
                        vectorSource.clear();
                    }
                    const features = format.readFeatures(response, {
                        dataProjection: layer.wkId,
                        featureProjection: projection
                    });

                    if (features.length > 0) {
                        addUniqueFeaturesToSource(vectorSource, features, layer.idFieldName);
                    }
                }
            },
            error: function (response) {
                vectorSource.removeLoadedExtent(extent);
            }
        });
    }

    const vectorSource = new ol.source.Vector({
        loader: loader,
        strategy: ol.loadingstrategy.bbox,
        crossOrigin: null
    });

    return vectorSource
}

function setSelectedFeatureIds(elementId, selectedIds) {
    selectedFeatureIdsPerMap[elementId] = new Set((selectedIds || []).map(id => (id || '').toLowerCase()));

    const map = maps[elementId];
    if (!map) {
        return;
    }

    getItemsLayerUnderlyingSource(elementId)?.changed();
    getLinearItemsLayerUnderlyingSource(elementId)?.changed();
}

