Skip to content

Vector Styling in OpenLayers using GeoStyler

Warning

This page is currently in a draft form. It may also require features only available in the main MapServer branch, and not yet released in a stable version.

Overview

GeoStyler is an open-source React library that enables developers to build flexible and powerful graphical user interfaces for map-styling in the browser.

It has many different parsers that allow conversion of one style format to another. In this example, we will use the SLD parser to convert SLD created by MapServer styles into GeoStyler JSON format, which can then be used to style vector layers in OpenLayers.

Mapfile Notes

Symbols

Symbols are added to the Mapfile using INCLUDE directives, which allows them to be loaded into the demo web application. The symbol NAME matches the LAYER name.

Symbol Resolution

RESOLUTION and DEFRESOLUTION are used to control the scaling of symbols in MapServer.

When a WMS source has serverType: 'mapserver', OpenLayers requests HiDPI images by addingMAP_RESOLUTION` = 90 × device pixel ratio to the request (when the ratio isn't 1). The 90 value is hard-coded in OpenLayers and based on the OGC standard rendering pixel size of 0.28 mm (25.4 mm per inch ÷ 0.28 mm per pixel = 90.71 pixels per inch).

The device pixel ratio is a property of the browser, and can be different on different devices. To find your browser pixel device ratio, open the developer tools and check the window.devicePixelRatio value.

For example, if this is 1.5, then the MAP_RESOLUTION parameter sent by OpenLayers to MapServer as part of the WMS request will be 90 * 1.5 = 135. The image is requested at 1.5 times the size of the map. When this parameter is sent by a client, it overrides the RESOLUTION value set in the Mapfile for that request.

MapServer scales symbol sizes by RESOLUTION / DEFRESOLUTION. Setting both to 90 keeps symbols at the same visual size on any screen, matching the SLD sizes used for client-side styling. When MAP_RESOLUTION is 135, symbols are scaled by 1.5 in an image that the browser displays 1.5 times smaller.

With MapServer's defaults of 72, symbols would instead be scaled by 135 / 72 = 1.875 in the image, so they appear 1.25 times too large on HiDPI screens.

RESOLUTION 90 # if no MAP_RESOLUTION is provided in a request, MapServer uses this value
DEFRESOLUTION 90 # set to match OpenLayers default. MapServer default is 72

Applying Styles with GeoStyler

Code

geostyler.js
import '../css/geostyler.css';
import { Map, View } from 'ol';
import TileLayer from 'ol/layer/Tile';
import ImageLayer from 'ol/layer/Image';
import VectorLayer from 'ol/layer/Vector';
import OSM from 'ol/source/OSM';
import ImageWMS from 'ol/source/ImageWMS';
import VectorSource from 'ol/source/Vector';
import GeoJSON from 'ol/format/GeoJSON';
import { bbox as bboxStrategy } from 'ol/loadingstrategy';
import SLDParser from 'geostyler-sld-parser';
import OpenLayersParser from 'geostyler-openlayers-parser';
import xmlFormat from 'xml-formatter';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { xml } from '@codemirror/lang-xml';
import { json } from '@codemirror/lang-json';

const mapserverUrl = import.meta.env.VITE_MAPSERVER_BASE_URL;
const mapfilesPath = import.meta.env.VITE_MAPFILES_PATH;

const serverUrl = `${mapserverUrl}${mapfilesPath}geostyler.map&`;
const includesUrl = `${import.meta.env.BASE_URL}includes/`;

/**
 * Layers available in the demo.
 *
 * - id: the MapServer LAYER name.
 * - type: the geometry type, shown in the layer selector
 * - styles (optional): the layer's style names (its CLASS GROUPs), shown in
 *   the style selector. Without it, the layer's default style is used.
 * - title (optional): label in the layer selector
 * - sldUrl (optional, for testing): load the SLD from this URL instead of
 *   a GetStyles request
 * - geostylerUrl (optional, for testing): load a GeoStyler style (JSON) from
 *   this URL and use it instead of an SLD
 */
const demoLayers = [
    { id: 'arrow-wide', type: 'line' },
    { id: 'notched-rect', type: 'point' },
    { id: 'square-spaced', type: 'polygon' },
    // Temporarily use a SLD file instead of MapServer's SLD
    // { id: 'wave', type: 'polygon', sldUrl: `${import.meta.env.BASE_URL}styles/slash.sld.xml` },
    {
        id: 'wave',
        type: 'polygon',
        styles: ['Horizontal', 'Vertical']
    },

    // Temporarily use a GeoStyler style instead of MapServer's SLD
    // { id: 'fills', type: 'polygon', geostylerUrl: `${import.meta.env.BASE_URL}styles/fills.geostyler.json` },
    {
        id: 'fills',
        type: 'polygon'
    },

    {
        id: 'hatch',
        type: 'polygon',
        styles: ['Horizontal', 'Vertical', 'Slash', 'Backslash', 'Cross', 'Diagonal']
    },
];

// Shown for layers without a styles list; sent to MapServer as an empty
// STYLES value, which selects the layer's default style
const DEFAULT_STYLE = 'default';

const getStyles = (layerConfig) =>
    layerConfig.styles?.length ? layerConfig.styles : [DEFAULT_STYLE];

const getStylesParam = (style) => (style === DEFAULT_STYLE ? '' : style);

const sldParser = new SLDParser();
const olParser = new OpenLayersParser();

const baseLayer = () => new TileLayer({ source: new OSM(), opacity: 0.5, className: 'bw' });

const sharedView = new View({
    center: [2975862, 8046369],
    zoom: 14,
});

// TODO add Mapfile syntax
const mapfileLanguage = [];

/**
 * Read-only code viewer with line numbers, folding and search (Ctrl+F)
 */
function createViewer(parentId, language) {
    return new EditorView({
        parent: document.getElementById(parentId),
        state: EditorState.create({
            extensions: [
                basicSetup,
                language,
                EditorView.lineWrapping,
                EditorState.readOnly.of(true),
                EditorView.editable.of(false),
            ],
        }),
    });
}

function setText(view, text) {
    view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
}

const classView = createViewer('class-text', mapfileLanguage);
const symView = createViewer('sym-text', mapfileLanguage);
const sldView = createViewer('sld-text', xml());
const geostylerView = createViewer('geostyler-text', json());


/**
 * For display only: add the decoded SVG as a comment. The SLD that is
 * parsed and applied is not changed.
 */
function addDecodedSvgComments(sldText) {
    const pattern = /^([ \t]*)(<se:OnlineResource\b[^>]*href="data:image\/svg\+xml;base64,([A-Za-z0-9+/=]+)"[^>]*>)/gm;

    return sldText.replace(pattern, (match, indent, tag, base64) => {
        try {
            const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
            let svg = new TextDecoder().decode(bytes).trim();
            try {
                svg = xmlFormat(svg, { indentation: '  ', collapseContent: true });
            } catch {
                // leave the SVG as decoded
            }
            svg = svg
                .replace(/ M /g, '\n      M ') // one subpath (tile copy) per line
                .replace(/--/g, '- -')         // "--" is not allowed inside an XML comment
                .split('\n')
                .map((line) => `${indent}  ${line}`)
                .join('\n');
            return `${indent}<!-- Decoded SVG:\n${svg}\n${indent}-->\n${indent}${tag}`;
        } catch {
            return match; // not valid base64: show as is
        }
    });
}
function showSld(text) {
    let formatted = text;
    try {
        formatted = xmlFormat(text, { indentation: '  ', collapseContent: true });
    } catch {
        // not XML (e.g. an error message): show as is
    }
    setText(sldView, addDecodedSvgComments(formatted));
}

async function fetchText(url) {
    const response = await fetch(url);
    if (!response.ok) {
        throw new Error(`${response.status} ${response.statusText}`);
    }
    return response.text();
}

/**
 * Fetch a file that may not exist. Returns null if it is missing.
 * The Vite dev server answers unknown paths with index.html (status 200),
 * so an HTML response is also treated as missing.
 */
async function fetchOptionalText(url) {
    const response = await fetch(url);
    const contentType = response.headers.get('Content-Type') || '';
    if (!response.ok || contentType.includes('text/html')) {
        return null;
    }
    return response.text();
}

/**
 * Fetch the SLD for a layer and style with a GetStyles request, and return
 * it as a string. An sldUrl in the layer config overrides the GetStyles
 * request (e.g. for testing).
 */
async function fetchSld(layerConfig, style) {
    const stylesParam = encodeURIComponent(getStylesParam(style));
    const sldUrl = layerConfig.sldUrl
        ?? `${serverUrl}REQUEST=GetStyles&SERVICE=WMS&VERSION=1.3.0&LAYERS=${layerConfig.id}&STYLES=${stylesParam}`;

    const response = await fetch(sldUrl);
    if (!response.ok) {
        throw new Error(`Failed to fetch SLD: ${response.status} ${response.statusText}`);
    }
    const contentType = response.headers.get('Content-Type') || '';
    if (!contentType.includes('xml')) {
        throw new Error(`Unexpected Content-Type: ${contentType}. Expected XML.`);
    }

    return response.text();
}

/**
 * Load a GeoStyler style saved as JSON.
 */
async function fetchGeoStylerStyle(url) {
    const response = await fetch(url);
    if (!response.ok) {
        throw new Error(`Failed to fetch GeoStyler style: ${response.status} ${response.statusText}`);
    }
    return response.json();
}

/**
 * Convert a GeoStyler style to an OpenLayers style, and apply it.
 */
async function applyGeoStylerStyle(geostylerStyle, vectorLayer) {
    console.log(`GeoStyler style for layer ${vectorLayer.get('name')}:`, geostylerStyle);

    const ol = await olParser.writeStyle(geostylerStyle);
    if (ol.errors) {
        throw new Error(`Errors writing the OpenLayers style: ${ol.errors}`);
    }
    console.log(`OpenLayers style for layer ${vectorLayer.get('name')}:`, ol.output);

    vectorLayer.setStyle(ol.output);
}

/**
 * Convert an SLD to an OpenLayers style with GeoStyler, and apply it.
 * Returns the GeoStyler style the OpenLayers style was created from.
 */
async function applySld(sldXmlString, vectorLayer) {
    const gs = await sldParser.readStyle(sldXmlString);
    if (gs.errors) {
        throw new Error(`Errors parsing the SLD: ${gs.errors}`);
    }
    await applyGeoStylerStyle(gs.output, vectorLayer);
    return gs.output;
}

function createVectorLayer(layerConfig) {
    const source = new VectorSource({
        format: new GeoJSON({
            dataProjection: 'EPSG:3857',
            featureProjection: 'EPSG:3857',
        }),
        url: (extent, resolution, projection) => {
            const epsg = projection.getCode();
            return `${serverUrl}REQUEST=GetFeature&SERVICE=WFS&VERSION=2.0.0&OUTPUTFORMAT=geojson&TYPENAMES=${layerConfig.id}&srsName=${epsg}&BBOX=${extent.join(',')},${epsg}`;
        },
        strategy: bboxStrategy,
    });
    return new VectorLayer({ source, name: layerConfig.id });
}

function main() {
    const classFileName = document.getElementById('class-file-name');
    const symFileName = document.getElementById('sym-file-name');
    const symPanel = document.getElementById('sym-panel');

    const wmsSource = new ImageWMS({
        url: serverUrl,
        params: {
            VERSION: '1.3.0',
            FORMAT: 'image/png',
            TRANSPARENT: true,
        },
        serverType: 'mapserver',
    });

    new Map({
        target: 'map-left',
        layers: [baseLayer(), new ImageLayer({ source: wmsSource })],
        view: sharedView,
    });

    const mapRight = new Map({
        target: 'map-right',
        layers: [],
        view: sharedView,
    });

    let switchToken = 0;
    async function switchLayer(layerConfig, style) {
        const token = ++switchToken;

        // Left map: MapServer renders the layer itself
        wmsSource.updateParams({ LAYERS: layerConfig.id, STYLES: getStylesParam(style) });

        // Right map: WFS features styled in OpenLayers from the SLD
        const vectorLayer = createVectorLayer(layerConfig);

        const classFile = `${layerConfig.id}.class`;
        const symFile = `${layerConfig.id}.sym`;

        // A geostylerUrl in the layer config replaces the SLD (e.g. for testing)
        const loadStyle = layerConfig.geostylerUrl
            ? fetchGeoStylerStyle(layerConfig.geostylerUrl)
                .then(async (gsStyle) => {
                    await applyGeoStylerStyle(gsStyle, vectorLayer);
                    const note = `<!-- No SLD: GeoStyler style loaded from ${layerConfig.geostylerUrl} -->`;
                    return { sld: note, geostylerStyle: gsStyle };
                })
            : fetchSld(layerConfig, style)
                .then(async (sldXmlString) => {
                    const gsStyle = await applySld(sldXmlString, vectorLayer);
                    return { sld: sldXmlString, geostylerStyle: gsStyle };
                });

        const [classText, symText, { sld, geostylerStyle }] = await Promise.all([
            fetchText(`${includesUrl}${classFile}`)
                .catch((error) => `# Could not load ${classFile}: ${error.message}`),
            fetchOptionalText(`${includesUrl}${symFile}`)
                .catch(() => null),
            loadStyle
                .catch((error) => {
                    console.error(`Failed to style layer ${layerConfig.id}`, error);
                    const message = `Could not load or apply the style: ${error.message}`;
                    return { sld: message, geostylerStyle: null };
                }),
        ]);

        if (token !== switchToken) {
            return; // a newer switch happened, discard this one
        }

        classFileName.textContent = classFile;
        setText(classView, classText);

        // The symbols file is optional: hide its panel when there is none
        symPanel.hidden = symText === null;
        symFileName.textContent = symText === null ? '' : symFile;
        setText(symView, symText ?? '');

        showSld(sld);
        setText(geostylerView, geostylerStyle
            ? JSON.stringify(geostylerStyle, null, 2)
            : '// No GeoStyler style: see the SLD tab for the error');

        mapRight.getLayers().clear();
        mapRight.addLayer(baseLayer());
        mapRight.addLayer(vectorLayer);
    }

    // Tabs in the bottom-right panel: show one viewer at a time
    const tabs = document.querySelectorAll('.tab');
    tabs.forEach((tab) => {
        tab.addEventListener('click', () => {
            tabs.forEach((t) => {
                const active = t === tab;
                t.classList.toggle('active', active);
                document.getElementById(t.dataset.tab).hidden = !active;
            });
        });
    });

    const layerSelect = document.getElementById('layer-select');
    const styleSelect = document.getElementById('style-select');

    /**
     * Fill the style selector with the styles of a layer, select the first
     * one, and return it. The selector is disabled if there's only one style.
     */
    function populateStyles(layerConfig) {
        const styles = getStyles(layerConfig);
        styleSelect.replaceChildren(...styles.map((style) => {
            const option = document.createElement('option');
            option.value = style;
            option.textContent = style;
            return option;
        }));
        styleSelect.disabled = styles.length === 1;
        return styles[0];
    }

    const selectedLayer = () => demoLayers[Number(layerSelect.value)];

    // Populate the layer selector
    demoLayers.forEach((layer, index) => {
        const option = document.createElement('option');
        option.value = String(index);
        option.textContent = layer.title ?? `${layer.id} (${layer.type})`;
        layerSelect.appendChild(option);
    });

    layerSelect.addEventListener('change', () => {
        const layerConfig = selectedLayer();
        switchLayer(layerConfig, populateStyles(layerConfig));
    });

    styleSelect.addEventListener('change', () => {
        switchLayer(selectedLayer(), styleSelect.value);
    });

    switchLayer(demoLayers[0], populateStyles(demoLayers[0]));
}

main();
geostyler.map
MAP
    NAME "GeoStylerDemo"
    SIZE 800 800
    EXTENT 26.668678 58.339241 26.796582 58.409410
    PROJECTION
        "EPSG:4326"
    END
    OUTPUTFORMAT
        NAME "geojson"
        DRIVER "OGR/GEOJSON"
        MIMETYPE "application/json; subtype=geojson; charset=utf-8"
        FORMATOPTION "FORM=SIMPLE"
        FORMATOPTION "STORAGE=memory"
        FORMATOPTION "LCO:NATIVE_MEDIA_TYPE=application/vnd.geo+json"
        FORMATOPTION "USE_FEATUREID=true" # ensure GeoJSON output has an id property
    END

    # we require an IMAGETYPE for the OpenLayers viewer
    # to avoid msDrawMap(): Image handling error. Unable to initialize image. msPrepareImage(): Image handling error. Unable to initialize image.
    IMAGETYPE PNG24

    # ensure symbol sizes are the same for any screen-size (see notes)
    RESOLUTION 90
    DEFRESOLUTION 90

    WEB
        IMAGEPATH "/etc/mapserver/tmp/"
        METADATA
            "ows_enable_request" "*"
            "ows_srs" "EPSG:4326 EPSG:3857"
            "ows_use_default_extent_for_getfeature" "false"
            "wfs_getfeature_formatlist" "geojson"
        END
    END

    # points
    INCLUDE "includes/notched-rect.sym"

    # lines
    INCLUDE "includes/arrow-wide.sym"

    # polygons
    INCLUDE "includes/fills.sym"
    INCLUDE "includes/square-spaced.sym"
    INCLUDE "includes/vwave.sym"
    INCLUDE "includes/wave.sym"
    INCLUDE "includes/hatch.sym"

    LAYER
        NAME "square-spaced"
        TYPE POLYGON
        STATUS OFF
        PROJECTION
            "EPSG:4326"
        END
        TEMPLATE "ttt"
        CONNECTIONTYPE OGR
        CONNECTION "data/osm/landuse_a.fgb"
        METADATA
            "gml_include_items" "all"
            "gml_featureid" "fid"
            "gml_types" "auto"
        END
        FILTER ("[fclass]" = "scrub")
        INCLUDE "includes/square-spaced.class"
    END

    LAYER
        NAME "fills"
        TYPE POLYGON
        STATUS OFF
        PROJECTION
            "EPSG:4326"
        END
        TEMPLATE "ttt"
        CONNECTIONTYPE OGR
        CONNECTION "data/osm/landuse_a.fgb"
        METADATA
            "gml_include_items" "all"
            "gml_featureid" "fid"
            "gml_types" "auto"
        END
        INCLUDE "includes/fills.class"
    END

    LAYER
        NAME "hatch"
        TYPE POLYGON
        STATUS OFF
        PROJECTION
            "EPSG:4326"
        END
        TEMPLATE "ttt"
        CONNECTIONTYPE OGR
        CONNECTION "data/osm/landuse_a.fgb"
        METADATA
            "gml_include_items" "all"
            "gml_featureid" "fid"
            "gml_types" "auto"
        END
        INCLUDE "includes/hatch.class"
    END

    LAYER
        NAME "wave"
        TYPE POLYGON
        STATUS OFF
        PROJECTION
            "EPSG:4326"
        END
        TEMPLATE "ttt"
        CONNECTIONTYPE OGR
        CONNECTION "data/osm/water_a.fgb"
        METADATA
            "gml_include_items" "all"
            "gml_featureid" "fid"
            "gml_types" "auto"
        END
        FILTER ("[fclass]" = "riverbank")
        INCLUDE "includes/wave.class"
    END

    LAYER
        NAME "notched-rect"
        TYPE POINT
        STATUS OFF
        PROJECTION
            "EPSG:4326"
        END
        TEMPLATE "ttt"
        CONNECTIONTYPE OGR
        CONNECTION "data/osm/pois.fgb"
        METADATA
            "gml_include_items" "all"
            "gml_featureid" "fid"
            "gml_types" "auto"
        END
        FILTER ("[fclass]" = "restaurant")
        INCLUDE "includes/notched-rect.class"
    END

    LAYER
        NAME "arrow-wide"
        TYPE LINE
        STATUS OFF
        PROJECTION
            "EPSG:4326"
        END
        TEMPLATE "ttt"
        CONNECTIONTYPE OGR
        CONNECTION "data/osm/roads.fgb"
        METADATA
            "gml_include_items" "all"
            "gml_featureid" "fid"
            "gml_types" "auto"
        END
        FILTER ("[fclass]" = "tertiary")
        INCLUDE "includes/arrow-wide.class"
    END

END

Exercises

  1. Play around with the RESOLUTION and DEFRESOLUTION values in the Mapfile and see how it affects the symbol sizes.