Search

Using Images with Leaflet

Layouts

Create floorplans or projections with images and CRS.

Using Images with Leaflet
View live example →

This demo uses KeyLines with Leaflet to visualise data over ordinary images (in this case SVG and JPG).

The data shows two physical sites connected to a central server via VPN, as well as the wider internet. Each site contains a number of terminals and factory machines which regularly communicate with each other.

An alert is used to highlight traffic from a machine on the internal network heading directly to the outside world, which could indicate some sort of malfunction or malicious operation.

Interaction

Nodes marked with a ** glyph can be double-clicked to see a representation of either a factory floor or the central server's network infrastructure. This can help to better understand the flow of traffic between machines and their physical location.

Double-click on the background while viewing a site to return to the world view.

CRS Mapping

This demo uses a Leaflet ImageLayer to draw the background images, and Leaflet's SimpleCRS to map latitude/longitude positions to x/y points on this image.

A CRS, or Coordinate Reference System, defines how coordinates are projected onto a map. By providing an alternative CRS, you can use different mapping standards - or even draw something that doesn't look like a map at all.

By default, Leaflet uses Spherical Mercator projection, but it does include other CRSs. This demo uses L.CRS.Simple. Read more about CRS in the relevant version of the Leaflet API Reference.

Image Attribution

World map image from Wikimedia Commons.

Key functions used:

import KeyLines from "keylines";

import { data, styleCombo, styleComboLink, drilldownOffsets, siteLocations } from "./data.js";

let chart;

// Metadata to control the CRS image layer
const images = {
  World: {
    url: "/images/imagemaps/wiki-simple-world-map.svg",
    width: 2000,
    height: 1075,
  },
  "New York": {
    url: "/images/imagemaps/factory1.jpg",
    width: 2058,
    height: 1050,
  },
  Bangalore: {
    url: "/images/imagemaps/factory2.jpg",
    width: 2058,
    height: 1050,
  },
  "Central Server": {
    url: "/images/imagemaps/webserver.jpg",
    width: 2058,
    height: 1050,
  },
};

// Track combos we've created by location
const comboMap = {};

let currentBackground;
let backgroundOverlay;

// Update the background CRS image and transition nicely
function updateCRSImage(imageId, animate) {
  const prevOverlay = backgroundOverlay;
  const data = images[imageId];
  const bounds = [
    [0, 0],
    [data.height, data.width],
  ];
  const maxOpacity = 0.6; // Wash out the image colours a little bit

  // Set the background CRS image
  const overlayOpts = { opacity: animate ? 0 : maxOpacity };
  backgroundOverlay = L.imageOverlay(data.url, bounds, overlayOpts);

  if (animate) {
    backgroundOverlay.on("load", () => {
      // Use JQuery to fade the background
      $(backgroundOverlay.getElement()).animate({ opacity: maxOpacity }, 300, "swing", () => {
        prevOverlay.remove();
      });
    });
  }

  backgroundOverlay.addTo(chart.map().leafletMap());
}

function toggleButtons(enable) {
  const buttons = document.querySelectorAll('input[type="button"]');
  for (let i = 0; i < buttons.length; i++) {
    const button = buttons[i];
    const disable = currentBackground === button.value || !enable;
    if (disable) {
      button.setAttribute("disabled", "true");
      button.classList.add("disabled");
    } else {
      button.removeAttribute("disabled");
      button.classList.remove("disabled");
    }
  }
}

// Uncombine the combo representing the current background
function uncombineBackground() {
  const uncombineOpts = { animate: true, time: 600, select: false };
  const activeCombo = comboMap[currentBackground];
  delete comboMap[currentBackground];
  return chart.combo().uncombine(activeCombo, uncombineOpts);
}

function updateLinkStyles() {
  const updates = [];

  chart.each({ type: "link", items: "toplevel" }, (link) => {
    if (chart.combo().isCombo(link.id)) {
      const links = chart.combo().info(link.id).links;
      let isAlert;
      links.forEach((l) => {
        if (l.d.alert) {
          isAlert = true;
        }
      });
      // Style rules are defined in imagemaps-data.js
      updates.push(styleComboLink(link.id, isAlert));
    }
  });
  return chart.setProperties(updates, false);
}

// Figure out which combo nodes we need for the current background, and create them
async function createCombos(animate) {
  const places = {};
  chart.each({ type: "node", items: "underlying" }, (node) => {
    if (!comboMap[node.d.location]) {
      if (!places[node.d.location]) {
        places[node.d.location] = [];
      }
      places[node.d.location].push(node.id);
    }
  });

  const combos = [];
  Object.keys(places).forEach((p) => {
    if (p !== currentBackground) {
      combos.push({
        ids: places[p],
        label: null,
        glyph: null,
        // Style rules are defined in imagemaps-data.js
        style: styleCombo(p),
      });
    }
  });

  const comboIds = await chart.combo().combine(combos, { animate, select: false });
  // Update the combo map with the new ids
  comboIds.forEach((cid) => {
    const combo = chart.getItem(cid);
    comboMap[combo.d.location] = cid;
  });
}

function updateComboPositions(animate) {
  const updates = [];
  const offsets = drilldownOffsets[currentBackground] || {};

  // Position nodes according to the global or local image map
  const positions = currentBackground === "World" ? siteLocations : offsets;

  chart.each({ items: "toplevel" }, (item) => {
    if (item.type === "node") {
      if (positions[item.d.location]) {
        updates.push({
          id: item.id,
          pos: positions[item.d.location],
        });
      }
    } else if (item.type === "link") {
      // Hide links between combos while drilled-down into a site
      if (chart.combo().isCombo(item.id1) && chart.combo().isCombo(item.id2)) {
        updates.push({
          id: item.id,
          hi: currentBackground !== "World",
        });
      }
    }
  });
  return chart.animateProperties(updates, {
    time: animate ? 400 : 1,
  });
}

async function setBackground(id) {
  if (id !== currentBackground) {
    toggleButtons(false);
    const allowAnimation = !!currentBackground;
    currentBackground = id;

    updateCRSImage(id, allowAnimation);
    await createCombos(allowAnimation);
    await updateComboPositions(allowAnimation);
    await uncombineBackground();
    await updateLinkStyles();
    await chart.zoom("fit", { animate: allowAnimation });

    toggleButtons(true);
  }
}

async function klReady(loadedChart) {
  chart = loadedChart;

  const buttons = document.querySelectorAll('input[type="button"]');
  for (let i = 0; i < buttons.length; i++) {
    buttons[i].onclick = (e) => {
      setBackground(e.target.value);
    };
  }

  chart.on("double-click", (e) => {
    const item = chart.getItem(e.id);
    if (item?.type === "node") {
      if (images[item.d.location]) {
        setBackground(item.d.location);
      }
    } else {
      setBackground("World");
    }
    e.preventDefault();
  });

  await chart.map().options({
    leaflet: {
      crs: L.CRS.Simple,
      minZoom: -1.9,
      maxZoom: 1,
      zoomSnap: 0.5,
    },
    tiles: null,
    animate: false,
    padding: 70,
  });
  await chart.load(data);
  await chart.map().show();
  await chart.map().leafletMap().doubleClickZoom.disable();

  return setBackground("World");
}

async function startKeyLines() {
  const imageAlignment = {
    "fas fa-cloud": { e: 0.9 },
    "fas fa-industry": { e: 0.9 },
    "fas fa-server": { e: 0.9 },
    "fas fa-plus": { e: 1.1 },
    "fas fa-laptop": { e: 0.8 },
  };

  const chartOptions = {
    logo: "/images/Logo.png",
    backColour: "white",
    iconFontFamily: "Font Awesome 5 Free",
    selectedNode: {
      ha0: {
        c: "rgba(197, 150, 247, 0.7)",
        r: 40,
        w: 10,
      },
    },
    selectedLink: {
      c: "rgba(197, 150, 247, 0.7)",
    },
    imageAlignment,
    linkEnds: { avoidLabels: false },
  };

  const loadedChart = await KeyLines.create({
    container: "klchart",
    type: "chart",
    options: chartOptions,
  });
  return klReady(loadedChart);
}

document.addEventListener("DOMContentLoaded", () => {
  document.fonts.load('24px "Font Awesome 5 Free"').then(startKeyLines);
});
import KeyLines from "keylines";
const colours = {
  combo: {
    b: "#4B4C57",
    fi: "#0096FF",
    c: "white",
    fbc: "rgba(0,149,255,0.7)",
  },
  combolink: {
    c: "rgba(0,149,255,0.7)",
  },
  node: {
    b: "#4B4C57",
    fi: "#FF7200",
    fbc: "rgba(255,115,0,0.7)",
    c: "white",
  },
  link: {
    c: "rgba(255,115,0,0.7)",
  },
  alert: "#d81d46",
};

// Combos are described implicitly with various bits of metadata
export const siteLocations = {
  "New York": { lat: 755, lng: 470 },
  Bangalore: { lat: 570, lng: 1430 },
  "Central Server": { lat: 320, lng: 950 },
  "Public Internet": { lat: 980, lng: 950 },
};

// Map combos to particular positions when drilled down to a different view
export const drilldownOffsets = {
  "New York": {
    "Central Server": { lat: 0, lng: 1100 },
    "Public Internet": { lat: 0, lng: 700 },
    Bangalore: { lat: 0, lng: 1500 },
  },
  Bangalore: {
    "Central Server": { lat: 0, lng: 1100 },
    "New York": { lat: 0, lng: 700 },
    "Public Internet": { lat: 0, lng: 1500 },
  },
  "Central Server": {
    "New York": { lat: 0, lng: 460 },
    Bangalore: { lat: 0, lng: 1650 },
    "Public Internet": { lat: 0, lng: 1100 },
  },
};

function getComboIcon(placeName) {
  if (placeName === "Central Server") return "fas fa-server";
  if (placeName === "Public Internet") return "fas fa-cloud";
  return "fa-industry";
}

function getNodeIcon(type) {
  if (type === "machine") return "fas fa-cog";
  if (type === "server") return "fas fa-server";
  if (type === "cloud") return "fas fa-cloud";
  return "fas fa-laptop";
}

function createLink(id1, id2, alert) {
  return {
    id: `${id1}-${id2}`,
    type: "link",
    w: 4,
    id1,
    id2,
    d: alert ? { alert: true } : {},
  };
}

function styleItems(items) {
  items.forEach((item) => {
    if (item.type === "node") {
      item.c = colours.node.c;
      item.b = colours.node.b;
      item.t = {
        position: "s",
        borderRadius: 20,
        margin: "-1 0 0 0",
        padding: "8 8 8 8",
        fs: 17,
        fbc: colours.combo.fbc,
        fc: colours.combo.c,
        fb: true,
        t: item.t,
      };
      item.tc = false;
      item.e = 0.8;
      item.fi = {
        t: getNodeIcon(item.d.type),
        c: colours.node.fi,
      };
    } else if (item.type === "link") {
      item.c = item.d.alert ? colours.alert : colours.link.c;
    }
  });
}

export function styleCombo(location) {
  const glyphs = [];
  // If the combo supports drill-down, add a glyph
  if (drilldownOffsets[location]) {
    glyphs.push({
      fi: {
        t: "fas fa-plus",
        c: colours.combo.c,
      },
      c: colours.combo.b,
      p: "45",
      b: colours.combo.b,
    });
  }

  return {
    pos: siteLocations[location],
    c: colours.combo.c,
    b: colours.combo.b,
    fi: {
      t: getComboIcon(location),
      c: colours.combo.fi,
    },
    t: {
      position: "s",
      borderRadius: 20,
      margin: "1 0 0 0",
      padding: "10 8 6 8",
      fs: 17,
      fbc: colours.combo.fbc,
      fc: colours.combo.c,
      fb: true,
      t: location,
    },
    d: {
      location,
    },
    fbc: "rgba(0,0,0,0)",
    fc: "rgba(0,0,0,0)",
    fs: 20,
    fb: true,
    g: glyphs,
  };
}

export function styleComboLink(id, isAlert) {
  return {
    id,
    c: isAlert ? colours.alert : colours.combolink.c,
    w: 6,
    a1: true,
    a2: true,
    off: 20,
    g: isAlert
      ? [
          {
            fi: {
              t: "fas fa-exclamation",
              c: "white",
            },
            e: 1.5,
            c: colours.alert,
            b: colours.alert,
          },
        ]
      : null,
  };
}

function loadData() {
  const items = [
    {
      id: "f1-t1",
      type: "node",
      t: "192.168.1.56",
      pos: { lat: 180, lng: 400 },
      d: { location: "New York", type: "machine" },
    },
    {
      id: "f1-t2",
      type: "node",
      t: "192.168.1.28",
      pos: { lat: 260, lng: 1280 },
      d: { location: "New York", type: "machine" },
    },
    {
      id: "f1-t4",
      type: "node",
      t: "192.168.1.122",
      pos: { lat: 570, lng: 1600 },
      d: { location: "New York", type: "machine" },
    },
    {
      id: "f1-t5",
      type: "node",
      t: "192.168.1.201",
      pos: { lat: 790, lng: 600 },
      d: { location: "New York", type: "terminal" },
    },
    {
      id: "f1-t7",
      type: "node",
      t: "192.168.1.150",
      pos: { lat: 740, lng: 1180 },
      d: { location: "New York", type: "terminal" },
    },
    {
      id: "f1-t8",
      type: "node",
      t: "192.168.1.91",
      pos: { lat: 260, lng: 1680 },
      d: { location: "New York", type: "machine" },
    },

    {
      id: "f2-t1",
      type: "node",
      t: "192.168.0.101",
      pos: { lat: 720, lng: 1290 },
      d: { location: "Bangalore", type: "machine" },
    },
    {
      id: "f2-t2",
      type: "node",
      t: "192.168.0.69",
      pos: { lat: 720, lng: 980 },
      d: { location: "Bangalore", type: "machine" },
    },
    {
      id: "f2-t3",
      type: "node",
      t: "192.168.0.254",
      pos: { lat: 720, lng: 1590 },
      d: { location: "Bangalore", type: "machine" },
    },
    {
      id: "f2-t4",
      type: "node",
      t: "192.168.0.92",
      pos: { lat: 470, lng: 1660 },
      d: { location: "Bangalore", type: "machine" },
    },
    {
      id: "f2-t5",
      type: "node",
      t: "192.168.0.17",
      pos: { lat: 280, lng: 570 },
      d: { location: "Bangalore", type: "terminal" },
    },
    {
      id: "f2-t7",
      type: "node",
      t: "192.168.0.18",
      pos: { lat: 450, lng: 890 },
      d: { location: "Bangalore", type: "terminal" },
    },
    {
      id: "f2-t6",
      type: "node",
      t: "192.168.0.222",
      pos: { lat: 260, lng: 1420 },
      d: { location: "Bangalore", type: "machine" },
    },
    {
      id: "f2-t8",
      type: "node",
      t: "192.168.0.202",
      pos: { lat: 830, lng: 550 },
      d: { location: "Bangalore", type: "machine" },
    },

    {
      id: "svr1",
      type: "node",
      t: "i-d9a5c5a37c619",
      pos: { lat: 220, lng: 1029 },
      d: { location: "Central Server", type: "server" },
    },
    {
      id: "svr2",
      type: "node",
      t: "i-d9a6c3a39f19a",
      pos: { lat: 525, lng: 1029 },
      d: { location: "Central Server", type: "server" },
    },
    {
      id: "svr3",
      type: "node",
      t: "i-e2a5c7f37c233",
      pos: { lat: 525, lng: 710 },
      d: { location: "Central Server", type: "server" },
    },
    {
      id: "svr4",
      type: "node",
      t: "i-a9a5cf5a37c6a4",
      pos: { lat: 525, lng: 1340 },
      d: { location: "Central Server", type: "server" },
    },
    {
      id: "svr5",
      type: "node",
      t: "i-f15db65d24e2a0",
      pos: { lat: 840, lng: 1029 },
      d: { location: "Central Server", type: "server" },
    },

    // Fake internet node
    {
      id: "public1",
      type: "node",
      t: "203.68.215.110",
      pos: { lat: 520, lng: 1380 },
      d: { location: "Public Internet", type: "cloud" },
    },

    createLink("f1-t1", "f1-t5"),
    createLink("f1-t7", "f1-t4"),
    createLink("f1-t7", "f1-t2"),
    createLink("f1-t7", "f1-t8"),

    createLink("f2-t5", "f2-t8"),
    createLink("f2-t7", "f2-t1"),
    createLink("f2-t7", "f2-t2"),
    createLink("f2-t7", "f2-t3"),
    createLink("f2-t7", "f2-t4"),
    createLink("f2-t7", "f2-t6"),
    createLink("f2-t5", "f2-t7"),

    createLink("f1-t4", "f2-t5"),
    createLink("f1-t8", "f2-t6"),

    createLink("f1-t5", "svr3"),
    createLink("svr4", "f2-t7"),

    createLink("svr1", "svr2"),
    createLink("svr3", "svr2"),
    createLink("svr4", "svr2"),
    createLink("svr5", "svr2"),

    createLink("f1-t5", "public1"),
    createLink("f1-t7", "public1"),
    createLink("public1", "f2-t5"),

    // alert link
    createLink("f1-t1", "public1", true),
  ];

  styleItems(items);

  return { type: "LinkChart", items };
}

export const data = loadData();
<!doctype html>
<html lang="en" style="background-color: #2d383f">
  <head>
    <meta charset="utf-8" />
    <title>Using Images with Leaflet</title>
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/keylines.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/minimalsdk.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/sdk-layout.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/demo.css" />
    <link rel="stylesheet" type="text/css" href="leaflet/dist/leaflet.css" />
    <link
      rel="stylesheet"
      type="text/css"
      href="@fortawesome/[email protected]/css/fontawesome.css"
    />
    <link
      rel="stylesheet"
      type="text/css"
      href="@fortawesome/[email protected]/css/solid.css"
    />
    <style>
      .fas.fa-plus {
        color: white;
        background-color: black;
        border-radius: 8px;
        font-size: 12px;
        padding: 2px 3px;
        margin: 0px 2px;
      }
    </style>
    <script src="jquery" defer type="text/javascript"></script>
  </head>
  <body>
    <div id="klchart" class="klchart"></div>
    <script src="leaflet" defer type="text/javascript"></script>
    <script type="module" src="./code.js"></script>
  </body>
</html>

Terms of use

These terms do not alter or supersede any existing agreements between you (or your employer) and us.

By accessing or using any Content you agree to be bound by these Terms of Use. Please review these terms carefully before using the website.

The contents of this website, including but not limited to any text, code samples, API references, schemas, interactive tools, and other materials (collectively, the 'Content'), are made available for informational and internal evaluation purposes only. All intellectual property rights in the Content are reserved. No licence is granted to use the Content for any commercial purpose, or to copy, distribute, modify, reverse-engineer, or incorporate any part of the Content into any product or service, without our prior written consent.

This Content is provided “as is” and “as available,” without any representations, warranties, or guarantees of any kind, whether express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, non-infringement, or accuracy. To the fullest extent permitted by applicable law, we expressly exclude and disclaim all implied warranties, conditions, and other terms that might otherwise be implied.

We disclaim all liability for any loss or damage, whether direct, indirect, incidental, consequential, or otherwise, arising from any reliance placed on the Content or from your use of it, to the fullest extent permitted by applicable law. By continuing to access or use the Content, you acknowledge and agree to these terms.