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:
- chart.combo().combine
- chart.combo().uncombine
- chart.map().leafletMap
- chart.map().options
- chart.map().show
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>