I have inherited a legacy webapp which displays a large set of custom maps. It currently uses a bespoke JS implementation to display raster tiles on a canvas with markers, etc. I want to migrate it to use a standard mapping library such as OpenLayers.

Current functionality

Each custom map consists of a large PNG image (thousands of pixels in width/height) which is drawn in the Mercator projection and has known bounds.

A server-side script generates a set of tiles for each of these images at 3 zoom levels:

  • Zoom-level 1
    • 1:1 with original image
    • Google zoom level 16
  • Zoom-level 2
    • 2:1 with original image
    • Google zoom level 15
  • Zoom-level 3
    • 4:1 with original image
    • Google zoom level 14

The tiles are generated in a file/directory structure: Z/Y/X.png e.g. 1/0/0.png is the top-left (NW) tile at zoom-level 1

X and Y are sequential numbers so a 1400x1200px map at zoom-level 1 (1:1) has X=0 to 5 and Y=0 to 4.

Tiles are 256x256px except for those bottom/right tiles whose width/height is the remainder. e.g. the bottom-right (SE) tile of a 1400x1200px map at zoom-level 1 is 1/4/5.png and is 120x176px.

Note: the custom tile sets are also used by a mobile app to which they are downloaded and cached so changing the structure of map tiles (e.g. to TMS) is not an option.


My question is: how do I use OpenLayers to display these custom tiles?

Additionally, the bespoke mapping engine allows overzooming of tiles at zoom-level 1 to an equivalent of Google zoom level 18. Is it possible to also do this with OpenLayers?


This is how far I've got with a proof-of-concept implementation:

// Config of the sample map (www/content/maps/sample/config.js)
const config = {
    map: {
        name: "sample"
        width: 1400,
        height: 1200,
        lat_top: 51.52568699532483,
        lon_left: -0.08205413818359376,
        lat_bottom: 51.509663955004,
        lon_right: -0.052013397216796875,
        lat_centre: 51.51767547516442,
        lon_centre: -0.06703376770019531

const _map = 'map',
    projection: 'EPSG:3857',
    minZoom = 14,
    maxZoom = 16,
    initialZoom = 16,
    tileSize = [256,256],
    content_root = '/www/content/maps/', // location of map tiles on web server
    map_root = content_root + config.map.name+'/', 
    tiles_root = map_root + 'tiles/',
    urlTemplate = tiles_root + '{z}/{y}/{x}.png';

let map, view, tileLayer;

initMap = () => {

    const dragRotateAndZoom = new ol.interaction.DragRotateAndZoom(),
        defaultInteractions = ol.interaction.defaults(),
        interactions = defaultInteractions.extend([dragRotateAndZoom]),
        ne_bound = ol.proj.fromLonLat([config.map.lon_right, config.map.lat_top]),
        se_bound = ol.proj.fromLonLat([config.map.lon_left, config.map.lat_bottom]),
        extent = se_bound.concat(ne_bound);

    view = new ol.View({
        projection: projection,
        center: ol.proj.fromLonLat([config.map.lon_centre, config.map.lat_centre]),
        minZoom: minZoom,
        maxZoom: maxZoom,
        zoom: initialZoom

    tileLayer = new ol.layer.Tile({
        extent: extent,
        source: new ol.source.XYZ({
            minZoom: minZoom,
            maxZoom: maxZoom,
            projection: projection,
            tileSize: tileSize,
            tileUrlFunction: function(tileCoord) {
                let z = tileCoord[0],
                    x = tileCoord[1],
                    y = tileCoord[2];
                // TODO: convert XYZ of tileCoord to custom /Z/Y/X.png tile numbering

                const result = urlTemplate
                    .replace('{z}', z.toString())
                    .replace('{x}', x.toString())
                    .replace('{y}', y.toString());

                return result;
            wrapX: false

    // OpenLayers v6.9.0
    map = new ol.Map({
        interactions: interactions,
        target: _map,
        layers: [tileLayer],
        view: view

document.addEventListener("DOMContentLoaded", initMap);

1 Answer 1


To support fractional tiles at the bottom and right you would need to base your source on Zoomify or IIIF. That has worked for me with a single zoom level, in your case I think you would need

    const source = new ol.source.IIIF({
        size: [config.map.width, config.map.height],
        extent: extent,
        projection: projection,
        tileSize: tileSize,
        resolutions: [1, 2, 4],
    source.setTileUrlFunction(function(tileCoord) {
        let z = tileCoord[0],
            x = tileCoord[1],
            y = tileCoord[2];
        const result = urlTemplate
            .replace('{z}', z.toString())
            .replace('{x}', x.toString())
            .replace('{y}', y.toString());
        return result;

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.