//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//! Enhance your graph with a map view.
import KeyLines from "keylines";
import { geoJSON, data, createPinNodeLabels } from "./layers-data.js";
let chart;
let statesMap;
let selectedState;
let activeHoverId;
// for touch detection - prevents incorrect hovering behavior
let hasTouched = false;
const chartElement = document.getElementById("klchart");
const itemLookup = Object.fromEntries(
data.items.map((item) => [item.id, item])
);
// This is a placeholder for click handlers to be set by Keylines when there is a click event
// we need this because Keylines events are triggered before Leaflet events
// and therefore we have to wait until the leaflet event is triggered to get more information
let handleMapClick;
const stateDefaultStyle = {
fillColor: "#a6dbed",
color: "#86bcce",
weight: 1,
opacity: 0.4,
};
const stateHoverStyle = {
color: "#c15d15",
fillColor: "#f4b58b",
};
const stateSelectedStyle = {
fillOpacity: 0.4,
fillColor: "#fc8a0c",
color: "#c15d15",
};
// When nodes are selected, highlight directly connected airports
function foregroundSelectedNodes() {
const neighbours = new Map();
// This is the criteria for the foreground function
function areNeighboursOf({ id }) {
return neighbours.has(id);
}
const selection = chart.selection();
if (selection.length === 0) {
chart.foreground(() => !selectedState, { type: "all" });
} else {
// show only direct neighbours of nodes
const result = chart.graph().neighbours(selection);
// convert the 2 array in the result in a dictionary to quickly lookup
result.nodes.forEach((nodeId) => {
neighbours.set(nodeId, true);
});
result.links.forEach((linkId) => {
neighbours.set(linkId, true);
});
selection.forEach((id) => {
neighbours.set(id, true);
});
chart.foreground(areNeighboursOf, { type: "all" });
}
}
function updateEnlargedNodes() {
const isSelected = (id) => chart.selection().includes(id);
const updatedStyles = data.items
.filter(({ type, id }) => type === "node" && id !== activeHoverId)
.map(({ id }) => ({
id,
t: isSelected(id)
? createPinNodeLabels(itemLookup[id], 1.25)
: itemLookup[id].t,
}));
const hoveredItem = chart.getItem(activeHoverId);
if (
activeHoverId &&
hoveredItem.type == "node" &&
!isSelected(activeHoverId)
) {
updatedStyles.push({
id: activeHoverId,
t: createPinNodeLabels(itemLookup[activeHoverId], 1.25),
});
}
chart.setProperties(updatedStyles);
}
function updateHoverNodes(props) {
const id = props && props.id;
activeHoverId = id;
updateEnlargedNodes();
}
function clearSelectedState() {
if (selectedState) {
statesMap.resetStyle(selectedState);
selectedState = null;
}
}
function showSelectedAirports() {
const airportTableElement = document.getElementById("airportTable");
const selectedAirportsElement = document.getElementById("selectedAirports");
const noAirportsElement = document.getElementById("noAirports");
const airportsInTable = [];
const buildAirportTable = (airport) => {
if (!airportsInTable.includes(airport.t)) {
// Add the airport to the table
const airportMarkup = `
<tr>
<td>${airport.id}</td>
<td>${airport.d.name}</td>
</tr>
`;
airportTableElement.innerHTML += airportMarkup;
airportsInTable.push(airport.t);
}
};
// Clears the airportTable
let airportTableRow = airportTableElement.lastElementChild;
while (airportTableRow) {
airportTableElement.removeChild(airportTableRow);
airportTableRow = airportTableElement.lastElementChild;
}
const selection = chart.selection();
if (selection.length > 0) {
selectedAirportsElement.style.display = "block";
noAirportsElement.style.display = "none";
selection.forEach((id) => {
const item = chart.getItem(id);
if (item.type === "node") {
const airport = item;
buildAirportTable(airport);
} else {
// is link
const { id1, id2 } = item;
const [sourceAirport, targetAirport] = chart.getItem([id1, id2]);
buildAirportTable(sourceAirport);
buildAirportTable(targetAirport);
}
});
} else {
selectedAirportsElement.style.display = "none";
noAirportsElement.style.display = "block";
}
}
function isPointInStateGeometry(point, { type, coordinates }) {
if (type === "Polygon") return pointInPolygon(point, coordinates);
if (type === "MultiPolygon")
return coordinates.some((polygon) => pointInPolygon(point, polygon));
return false;
}
function selectNodesInStateBorder() {
const nodesToSelect = [];
const stateBounds = selectedState.getBounds();
chart.each({ type: "node" }, (item) => {
// Check whether node is roughly in the right area
if (stateBounds.contains([item.pos.lat, item.pos.lng])) {
if (
isPointInStateGeometry(
[item.pos.lng, item.pos.lat],
selectedState.toGeoJSON().geometry
)
) {
nodesToSelect.push(item.id);
}
}
});
chart.selection(nodesToSelect);
}
// Shows the title of the state on the UI to the right hand side of the chart
function showStateHeader(stateName) {
const selectedStateBoundaryElement = document.getElementById(
"selectedStateBoundary"
);
if (stateName) {
selectedStateBoundaryElement.style.display = "block";
selectedStateBoundaryElement.innerHTML = stateName;
} else {
selectedStateBoundaryElement.style.display = "none";
}
}
function onLeafletLayerClicked(event) {
if (handleMapClick) {
handleMapClick(event.target);
}
}
// Highlight the border on mouseover
function onStateMouseOver(event) {
const layer = event.target;
// ignore if not triggered by mouse
if (!hasTouched) {
if (
!selectedState ||
event.target.feature.properties.STATE !==
selectedState.feature.properties.STATE
) {
layer.setStyle(stateHoverStyle);
}
chartElement.style.cursor = "pointer";
} else {
// reset the flag for future events
hasTouched = false;
}
}
// On mouse out, restore the original style.
function onStateMouseOut(event) {
if (
!selectedState ||
event.target.feature.properties.STATE !==
selectedState.feature.properties.STATE
) {
statesMap.resetStyle(event.target);
}
chartElement.style.cursor = "initial";
}
function setupLayerEvents(feature, layer) {
layer.on({
click: onLeafletLayerClicked,
mouseover: onStateMouseOver,
mouseout: onStateMouseOut,
});
}
function selectionChangeHandler() {
foregroundSelectedNodes();
updateEnlargedNodes();
showSelectedAirports();
}
/**
* debounce - This function delays execution of the passed "fn" until "timeToWait" milliseconds
* have passed since the last time it was called. This ensures that the function
* runs at the end of a particular action to keep performance high.
*/
function debounce(fn, timeToWait = 100) {
let timeoutId;
return function debouncedFn(...args) {
const timeoutFn = () => {
timeoutId = undefined;
fn.apply(this, args);
};
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(timeoutFn, timeToWait);
};
}
// creates an event handler that can later be called with the leaflet target
function generateClickHandler({ id }) {
// inner function is called when leaflet layers are clicked
// if no layer is clicked, leaflet raises a 'click' event after layers, which then calls this inner
return (target) => {
clearSelectedState();
showStateHeader();
// clicks on states or navigation controls
if (target && (id === null || id[0] === "_")) {
selectedState = target;
selectedState.setStyle(stateSelectedStyle);
showStateHeader(selectedState.feature.properties.NAME);
selectNodesInStateBorder();
} else {
// Foregrounds all nodes (and by extension all links)
chart.foreground(() => true);
}
// Update the foreground, enlarged nodes and airports in the sidebar
selectionChangeHandler();
// remove the stale click handler
handleMapClick = undefined;
};
}
// Debounce the handler to increase performance
const debouncedSelectionChangeHandler = debounce(selectionChangeHandler, 100);
function initialiseInteractions() {
chart.on("click", (event) => {
// the KeyLines layer is above the map layer and so receives events first.
// generate a function that leaflet can call once we have context of the clicked 'state'
handleMapClick = generateClickHandler(event);
});
chart.on("hover", updateHoverNodes);
// Show selected airports and update the foreground
chart.on("selection-change", debouncedSelectionChangeHandler);
}
async function loadData() {
statesMap = L.geoJson(geoJSON, {
onEachFeature: setupLayerEvents,
style: stateDefaultStyle,
});
chart.load(data);
const southwest = L.latLng(-30, -210);
const northeast = L.latLng(80, -30);
chart.map().options({
animate: false,
tiles: {
noWrap: false,
url:
"https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png",
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://cartodb.com/attributions">CartoDB</a>',
},
leaflet: {
maxZoom: 10,
minZoom: 3,
// Limit map panning and zoom to be roughly around USA
maxBounds: L.latLngBounds(southwest, northeast),
maxBoundsViscosity: 1,
bounceAtZoomLimits: false,
},
});
await chart.map().show();
const map = chart.map().leafletMap();
map.on("click", () => {
// handleMapClick will be undefined if we have clicked a leaflet layer
if (handleMapClick) {
// pass undefined to ensure all nodes/links are foregrounded
handleMapClick(undefined);
}
});
statesMap.addTo(map);
initialiseInteractions();
}
async function startKeyLines() {
const options = {
logo: { u: "/images/Logo.png" },
hover: 0,
iconFontFamily: "Font Awesome 5 Free",
selectionColour: "#B96229",
};
chart = await KeyLines.create({
container: "klchart",
type: "chart",
options,
});
loadData();
}
function loadFontsAndStart() {
document.fonts.load('24px "Font Awesome 5 Free"').then(startKeyLines);
// Set the flag so we know to ignore mouseover events
document.addEventListener("touchstart", () => {
hasTouched = true;
});
}
window.addEventListener("DOMContentLoaded", loadFontsAndStart);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
<head>
<meta charset="UTF-8">
<title>Leaflet Integration</title>
<link rel="stylesheet" type="text/css" href="/css/keylines.css">
<link rel="stylesheet" type="text/css" href="/css/minimalsdk.css">
<link rel="stylesheet" type="text/css" href="/css/sdk-layout.css">
<link rel="stylesheet" type="text/css" href="/css/demo.css">
<link rel="stylesheet" type="text/css" href="/css/leaflet.css">
<link rel="stylesheet" type="text/css" href="/css/fontawesome5/fontawesome.css">
<link rel="stylesheet" type="text/css" href="/css/fontawesome5/solid.css">
<link rel="stylesheet" type="text/css" href="/layers.css">
<script src="/vendor/jquery.js" defer type="text/javascript"></script>
<script id="keylines" src="/public/keylines.umd.cjs" defer type="text/javascript"></script>
<script src="/vendor/leaflet.js" defer type="text/javascript"></script>
<script src="/vendor/pointInPolygon-hao.min.js" defer type="text/javascript"></script>
<script src="/layers.js" crossorigin="use-credentials" defer type="module"></script>
</head>
<body>
<div class="chart-wrapper demo-cols">
<div class="tab-content-panel flex1" id="lhs" data-tab-group="rhs">
<div class="toggle-content is-visible" id="lhsVisual" style="width:100%; height: 100%;">
<div class="klchart" id="klchart">
</div>
</div>
</div>
<div class="rhs citext closed" id="demorhscontent">
<div class="title-bar">
<svg viewBox="0 0 360 60" style="max-width: 360px; max-height: 60px; font-size: 24px; font-family: Raleway; flex: 1;">
<text x="0" y="38" style="width: 100%;">Leaflet Integration</text>
</svg>
</div>
<div class="tab-content-panel" data-tab-group="rhs">
<div class="toggle-content is-visible tabcontent" id="controlsTab">
<p>The top 50 airports in the US, selectable by state.</p>
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<div class="cicontent" style="position: relative;">
<h4 id="selectedStateBoundary"></h4>
<div id="noAirports">No airports to show.</div>
<table class="table table-condensed" id="selectedAirports" style="display: none;">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
</tr>
</thead>
<tbody id="airportTable"></tbody>
</table>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
.hide {
display:none;
}
Loading source
