//
//     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:
        '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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