Layer Swipe Mixing WebGL and Vector layers in OpenLayers


The official examples don’t show you how to create a layer swipe effect in OpenLayers mixing WebGL and Vector layers.

CodeSandbox

Compare

// compare.ts
import type VectorLayer from "ol/layer/Vector";
import type RenderEvent from "ol/render/Event";
import { getRenderPixel } from "ol/render";
import type Map from "ol/Map";
import { Feature } from "ol";
import WebGlTileLayer from "ol/layer/WebGLTile";

export function compare(
  map: Map,
  getPositionPx: () => number,
  left?: VectorLayer<Feature> | WebGlTileLayer,
  right?: VectorLayer<Feature> | WebGlTileLayer
) {
  function onPreRender(opts?: {
    left?: boolean;
    bothWebGl?: boolean;
    isRightWebGL?: boolean;
  }) {
    return function (event: RenderEvent) {
      const ctx = event.context as CanvasRenderingContext2D;
      if (!ctx) return;
      const mapSize = map.getSize();
      if (!mapSize) return;
      //
      const width = getPositionPx();
      // @ts-expect-error -- This only works if you are bundling ol yourself
      const devicePixelRatio = map.pixelRatio_;
      const bbox = opts?.left
        ? {
            tl: [0, 0],
            tr: [width, 0],
            bl: [0, mapSize[1]],
            br: [width, mapSize[1]],
          }
        : {
            tl: [width, 0],
            tr: [mapSize[0], 0],
            bl: [width, mapSize[1]],
            br: [mapSize[0], mapSize[1]],
          };
      const tl = getRenderPixel(event, bbox.tl);
      const tr = getRenderPixel(event, bbox.tr);
      const bl = getRenderPixel(event, bbox.bl);
      const br = getRenderPixel(event, bbox.br);
      if (ctx instanceof WebGLRenderingContext) {
        if (opts?.left) {
          if (!opts.bothWebGl) {
            ctx.clear(ctx.COLOR_BUFFER_BIT);
          }
          ctx.enable(ctx.SCISSOR_TEST);
          ctx.scissor(
            bl[0],
            bl[1],
            width * devicePixelRatio,
            mapSize[1] * devicePixelRatio
          );
        } else {
          ctx.enable(ctx.SCISSOR_TEST);
          ctx.clear(ctx.COLOR_BUFFER_BIT);
          ctx.scissor(
            bl[0],
            bl[1],
            (mapSize[0] - width) * devicePixelRatio,
            mapSize[1] * devicePixelRatio
          );
        }
        return;
      }
      if (opts?.isRightWebGL) {
        ctx.clearRect(0, 0, br[0], br[1]);
      }
      ctx.save();
      ctx.beginPath();
      ctx.moveTo(tl[0], tl[1]);
      ctx.lineTo(bl[0], bl[1]);
      ctx.lineTo(br[0], br[1]);
      ctx.lineTo(tr[0], tr[1]);
      ctx.closePath();
      ctx.clip();
    };
  }

  function onPostRender(event: RenderEvent) {
    const ctx = event.context as CanvasRenderingContext2D;
    if (!ctx) {
      return;
    }
    if (ctx instanceof WebGLRenderingContext) {
      ctx.disable(ctx.SCISSOR_TEST);
      return;
    }
    ctx.restore();
  }

  // store your own discriminator on the OpenLayers Layers when you create them
  // see: https://openlayers.org/en/latest/apidoc/module-ol_layer_Layer-Layer.html#set
  const isLeftWebGL = left?.get("type") === "webgl";
  const isRightWebGL = right?.get("type") === "webgl";
  const bothWebGl = isLeftWebGL && isRightWebGL;

  const leftHandler = onPreRender({ left: true, bothWebGl, isRightWebGL });

  left?.on("prerender", leftHandler);
  left?.on("postrender", onPostRender);

  const rightHandler = onPreRender({ isRightWebGL, bothWebGl });
  right?.on("prerender", rightHandler);
  right?.on("postrender", onPostRender);

  map.render();

  return function off() {
    left?.un("prerender", leftHandler);
    left?.un("postrender", onPostRender);
    right?.un("prerender", rightHandler);
    right?.un("prerender", onPostRender);
    map.render();
  };
}

Usage

import "./styles.css";
import Map from "ol/Map.js";
import OSM from "ol/source/OSM.js";
import WebGLTileLayer from "ol/layer/WebGLTile";
import View from "ol/View.js";
import "ol/ol.css";
import VectorSource from "ol/source/Vector";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import { compare } from "./compare";

const left = new WebGLTileLayer({
  source: new OSM(),
  properties: {
    type: "webgl",
  },
});
const polygon = {
  type: "Feature",
  geometry: {
    type: "Polygon",
    coordinates: [
      [
        [-5e6, -1e6],
        [-3e6, -1e6],
        [-4e6, 1e6],
        [-5e6, -1e6],
      ],
    ],
  },
};
const vectorSource = new VectorSource({
  features: new GeoJSON().readFeatures(polygon),
});

const right = new VectorLayer({
  source: vectorSource,
  properties: {
    type: "vector",
  },
});

const map = new Map({
  target: "map",
  layers: [left, right],
  view: new View({
    center: [0, 0],
    zoom: 2,
  }),
});
const checkbox = document.getElementById("toggle-swipe") as HTMLInputElement;
const slider = document.getElementById("slider") as HTMLInputElement;

const leftOnCheckbox = document.getElementById("left-on") as HTMLInputElement;
const rightOnCheckbox = document.getElementById("right-on") as HTMLInputElement;

let endCompare: ReturnType<typeof compare> | undefined;
let px = slider.valueAsNumber * map.getSize()![0];
let on = true;
let leftOn = true;
let rightOn = true;

function startCompare() {
  const getOff = () =>
    compare(
      map,
      () => px,
      leftOn ? left : undefined,
      rightOn ? right : undefined
    );
  if (endCompare) {
    endCompare();
    endCompare = getOff();
  } else {
    endCompare = getOff();
  }
}

checkbox.onchange = function (ev) {
  on = (ev.target as HTMLInputElement).checked;
  if (on) {
    startCompare();
  } else {
    endCompare?.();
    endCompare = undefined;
  }
  map.render();
};

slider.oninput = function (ev) {
  px = (ev.target as HTMLInputElement).valueAsNumber * map.getSize()![0];
  map.render();
};

leftOnCheckbox.onchange = function (ev) {
  leftOn = (ev.target as HTMLInputElement).checked;
  startCompare();
};

rightOnCheckbox.onchange = function (ev) {
  rightOn = (ev.target as HTMLInputElement).checked;
  startCompare();
};

if (on) {
  startCompare();
}