Search

Annotating Charts

Annotations

Create and edit annotations in your chart.

Annotating Charts
View live example →

While graph visualisations reveal the story hidden behind the data, annotations provide the means to share this narrative with the outside world.

Users can interact with the chart, analyse it, and then use KeyLines annotations to highlight the most important insights.

Annotations can be seamlessly integrated with any application design and customised to offer any user interaction, so that users can annotate charts directly inside the application.

KeyLines annotations are always readable and can be positioned relatively to their subjects or using world coordinates. You can style them in the Annotations API and create text fields, buttons and other user interface controls by adding custom labels and glyphs.

To provide context for charts shared in documents or presentations, you can export annotated charts into multiple formats with chart.export().

See our Annotations documentation for more details.

Key functions used:

import KeyLines from "keylines";
import { createAnnotation, data, colours, defaultAnnotationsCount } from "./data.js";

let chart;
let annotationIdCount = defaultAnnotationsCount;
let snapshotIdCount = 0;
let nodeIdWithAnnotateButton = null;
let activeAnnotation = null;
let hoveredAnnotationId = null;
let selectedNodes = [];
let annotationToolbarShown = false;
let isNewAnnotation = null;
let handMode = true;
let isMarqueeDragging = false;
let queuedAnnotationContainer = "none";
const editLabelIndex = 2;
const snapshotStore = new Map();
const chartContainer = document.getElementById("klchart");
const annotationTextArea = document.getElementById("annotation-text");
const annotationPreview = document.getElementById("annotation-preview");
const snapshotButton = document.getElementById("snapshot-button");
const annotateButton = document.getElementById("annotate-button");
const textMeasurementSpan = document.getElementById("text-measure");

async function annotate() {
  // Reset the pointer cursor override from the edit label
  setCursor(null);
  await updateNodeColours(true, selectedNodes);
  hideAnnotateButton();
  const annotationSubjects = getSelectedNodeIds();
  isNewAnnotation = true;

  // Add the initial annotation to the chart to help with overlay positioning
  activeAnnotation = createAnnotation(
    `a${++annotationIdCount}`,
    annotationSubjects,
    queuedAnnotationContainer,
    undefined,
    "dashed"
  );
  await chart.merge(activeAnnotation);

  // Ensure the new annotations and its subjects are in view if required
  await fitToAnnotationIfOutOfBounds();
  await convertActiveAnnotationPosition("world");
  placeAnnotationPreview();
  setupAnnotationPreview();
}

async function finaliseAnnotation(action) {
  if (activeAnnotation === null) return;

  if (action === undefined || action === "dismiss") {
    const annotationText = activeAnnotation.t[1].t;
    action = annotationText === "" ? "delete" : "dismiss";
  }

  if (action === "delete") {
    chart.removeItem(activeAnnotation.id);
  } else {
    await chart.setProperties({ id: activeAnnotation.id, connectorStyle: { ls: "solid" } });
    // convert the annotation back to relative positioning if it has subjects
    if (activeAnnotation.subject.length > 0) {
      await convertActiveAnnotationPosition("relative");
    }
    const windowSelection = window.getSelection();
    // Clear text highlighting
    if (windowSelection.rangeCount !== 0) {
      windowSelection.removeAllRanges();
    }
  }

  annotationPreview.style.display = "none";
  await updateNodeColours(false, selectedNodes);
  if (isNewAnnotation && action === "delete") {
    // Maintain the selection when cancelling a new annotation
    placeAnnotateButtonOnNode(getLatestSelectedNodeId());
  } else {
    chart.selection([]);
    /* selection-change does not fire when triggered programmatically
        so we must updated the selectedNodes here */
    selectedNodes = [];
  }

  activeAnnotation = null;
  isNewAnnotation = null;
}

async function updateAnnotationContainer(containerType) {
  if (activeAnnotation === null) return;
  annotationPreview.style.display = "none";
  await chart.setProperties({
    id: activeAnnotation.id,
    connectorStyle: {
      container: containerType,
      subjectEnd: "dot",
    },
  });

  // Fit the view to the annotation and its subjects
  await fitToAnnotationIfOutOfBounds();
  placeAnnotationPreview();
  annotationPreview.style.display = "flex";
}

async function updateAnnotationTextAndHeight(text, height) {
  const activeAnnotationLabels = activeAnnotation.t;
  activeAnnotationLabels[1].t = text;
  await chart.setProperties({
    id: activeAnnotation.id,
    t: activeAnnotationLabels,
    h: height,
  });
}

function getSelectedNodeIds() {
  return selectedNodes.map(({ id }) => id);
}

function getLatestSelectedNodeId() {
  return selectedNodes[selectedNodes.length - 1].id;
}

function setSelectedContainerControl(type) {
  const containerControl = document.getElementById(type);
  containerControl.checked = true;
}

// Sets up the initial state for the preview based on the annotation being edited
function setupAnnotationPreview() {
  const {
    connectorStyle: { container },
    t,
  } = activeAnnotation;
  setSelectedContainerControl(container);
  const annotationText = t[1].t;
  annotationTextArea.value = annotationText;
  toggleAnnotationToolbar(annotationText !== "");
  updateTextAreaHeight();
}

async function onAnnotationKeyDown(e) {
  const { key } = e;
  if (activeAnnotation !== null) {
    if (key === "Escape") {
      finaliseAnnotation();
    }
    if (key === "Enter") {
      // Don't create new lines for enter
      e.preventDefault();
      await finaliseAnnotation("dismiss");
    }
  }
}

function toggleAnnotationToolbar(show) {
  const annotationToolbar = document.getElementById("annotation-toolbar");
  const ghostDoneButton = document.getElementById("ghost-done-button");
  const toolbarDisplay = show ? "flex" : "none";
  const ghostDoneButtonDisplay = show ? "none" : "block";
  annotationToolbar.style.display = toolbarDisplay;
  ghostDoneButton.style.display = ghostDoneButtonDisplay;
  annotationToolbarShown = show;
}

function updateSelectedNodes() {
  const selectedItemIds = chart.selection();
  selectedNodes = selectedItemIds
    .map((itemId) => chart.getItem(itemId))
    .filter((item) => item.type === "node");
}

/* Positions the annotate button based on the current
   chart zoom level and attached node position */
function updateAnnotateButtonPosition() {
  const nodeBaseSize = 26;
  const node = chart.getItem(nodeIdWithAnnotateButton);
  const { x, y } = chart.viewCoordinates(node.x, node.y);
  const { zoom } = chart.viewOptions();
  const nodeSize = nodeBaseSize * (node.e || 1) * zoom;
  annotateButton.style.left = `${x + nodeSize}px`;
  annotateButton.style.top = `${y - annotateButton.clientHeight - nodeSize}px`;
}

function placeAnnotateButtonOnNode(nodeId) {
  nodeIdWithAnnotateButton = nodeId;
  annotateButton.style.display = "block";
  updateAnnotateButtonPosition();
}

function isAnnotationOutOfBounds(id) {
  const annotationPosition = chart.labelPosition(id, 0);
  const chartContainerBounds = chartContainer.getBoundingClientRect();
  const outOfBounds =
    annotationPosition.x1 <= 0 || annotationPosition.x2 >= chartContainerBounds.width;
  return outOfBounds;
}

async function initialiseAnnotationEdit() {
  await chart.setProperties({
    id: activeAnnotation.id,
    connectorStyle: {
      ls: "dashed",
    },
  });
  // Highlight the annotation subjects
  chart.selection(activeAnnotation.subject);
  updateSelectedNodes();
  await updateNodeColours(
    true,
    activeAnnotation.subject.map((subjectId) => chart.getItem(subjectId))
  );
  await fitToAnnotationIfOutOfBounds();
  // pin the annotation to its world coordinate to prevent it from moving while being edited
  if (activeAnnotation.subject.length > 0) {
    await convertActiveAnnotationPosition("world");
  }
  placeAnnotationPreview();
  // Preconfigure the preview / controls for the annotation we are editing
  setupAnnotationPreview();
}

// Fits the view to the annotation and its subjects if required
async function fitToAnnotationIfOutOfBounds() {
  // Check whether we need to adjust the view to allow the annotation space
  if (isAnnotationOutOfBounds(activeAnnotation.id)) {
    await chart.zoom("fit", {
      ids: [activeAnnotation.id],
      animate: true,
      time: 400,
    });
  }
}

async function convertActiveAnnotationPosition(type) {
  const position =
    type === "relative"
      ? chart.getRelativeAnnotationPosition(activeAnnotation.id)
      : chart.getWorldAnnotationPosition(activeAnnotation.id);
  if (position) {
    await chart.setProperties({
      id: activeAnnotation.id,
      position,
    });
  }
}

function updateTextAreaHeight() {
  annotationTextArea.style.height = "auto";
  annotationTextArea.style.height = `${annotationTextArea.scrollHeight}px`;
}

/* KeyLines doesn't wrap strings without spaces so add new lines manually
   to handle long words which exceed the annotation body width */
function wrapLongWord(word) {
  let wrappedText = "";
  let tempLine = "";
  for (const char of word) {
    // Add characters to the measurement span incrementally to check if the wo
    textMeasurementSpan.textContent = `${tempLine}${char}`;
    if (textMeasurementSpan.getBoundingClientRect().width > annotationTextArea.clientWidth) {
      wrappedText += `${tempLine}\n`;
      tempLine = char;
    } else {
      tempLine += char;
    }
  }
  // Add remaining part of word
  wrappedText += tempLine;
  return wrappedText;
}

function formatAnnotationInputText(string) {
  let formattedText = "";
  const words = string.split(" ");
  words.forEach((word) => {
    textMeasurementSpan.textContent = word;
    /* Use the measurement span to check if a word doesn't fit on a single line in the text area 
         if it does wrap it across multiple lines */
    if (textMeasurementSpan.getBoundingClientRect().width > annotationTextArea.clientWidth) {
      formattedText += wrapLongWord(word) + " ";
    } else {
      formattedText += word + " ";
    }
  });
  return formattedText.trim();
}

async function onAnnotationInput() {
  // Remove any trailing whitespaces and new lines (\n) that may have been added from previous long words
  const cleanTextInput = annotationTextArea.value.replace(/\n/g, "").trim();
  const cleanTextLength = cleanTextInput.length;
  const showToolbar = cleanTextLength > 0 && !annotationToolbarShown;
  // Ensure annotation preview height grows / shrinks with the text input
  updateTextAreaHeight();

  if (showToolbar) {
    toggleAnnotationToolbar(true);
  } else if (annotationToolbarShown && cleanTextLength === 0) {
    // If we only have white space hide the toolbar
    toggleAnnotationToolbar(false);
  }

  // Format text so long words are wrapped correctly before updating chart annotation text
  const formattedText = formatAnnotationInputText(cleanTextInput);
  // Update the chart annotation text & height so we can position the preview
  await updateAnnotationTextAndHeight(formattedText, annotationPreview.offsetHeight);
  // Sync the preview overlay position with the chart annotation position
  placeAnnotationPreview();
}

function placeAnnotationPreview() {
  // Get the position of the annotation's background label to place the preview
  const annotationPosition = chart.labelPosition(activeAnnotation.id, 0);
  annotationPreview.style.top = `${annotationPosition.y1}px`;
  annotationPreview.style.left = `${annotationPosition.x1}px`;
  annotationPreview.style.display = "flex";
  annotationTextArea.focus();
}

// Adds / removes colour styling on nodes being annotated
async function updateNodeColours(active, nodes) {
  const activeNodeUpdates = nodes.map(({ id, fi }) => ({
    id,
    c: active ? colours.active : colours.defaultNodeBase,
    fi: {
      ...fi,
      c: active ? colours.activeFontIcon : colours.defaultFontIcon,
    },
  }));
  await chart.setProperties(activeNodeUpdates);
}

function hideAnnotateButton() {
  annotateButton.style.display = "none";
  nodeIdWithAnnotateButton = null;
}

function updateHandMode(navType) {
  handMode = navType === "hand" ? true : false;
  chart.options({ handMode });
}

function createIconSpanEl(className) {
  const iconSpanEl = document.createElement("span");
  iconSpanEl.classList.add(className);
  return iconSpanEl;
}

async function saveSnapshot() {
  const chartState = chart.serialize();
  const chartImage = await chart.export({
    type: "svg",
    fitTo: { width: 1392, height: 856 },
    fonts: {
      "Font Awesome 5 Free": {
        src: "/fonts/fontAwesome5/fa-solid-900.woff",
      },
      "CI-Icons": {
        src: "/fonts/ci-icons/CI-Icons.woff",
      },
      Muli: { src: "/fonts/Muli/Muli-Regular.ttf" },
    },
  });

  const snapshotId = `snapshot-${++snapshotIdCount}`;
  snapshotStore.set(snapshotId, {
    chartState,
    chartImage,
    selectedNodes,
    nodeIdWithAnnotateButton,
  });
  return {
    snapshotId,
    chartImage,
  };
}

function createSnapshotRowControls() {
  const snapshotControls = document.createElement("div");
  snapshotControls.id = "snapshot-controls";
  const downloadButton = document.createElement("button");
  const deleteButton = document.createElement("button");
  const downloadIcon = createIconSpanEl("ci-icon-download");
  const deleteIcon = createIconSpanEl("ci-icon-bin");
  const downloadLink = document.createElement("a");

  downloadLink.appendChild(downloadIcon);
  downloadButton.appendChild(downloadLink);
  deleteButton.appendChild(deleteIcon);
  snapshotControls.appendChild(downloadButton);
  snapshotControls.appendChild(deleteButton);

  downloadButton.addEventListener("click", (e) => onDownloadSnapshot(e));
  deleteButton.addEventListener("click", (e) => onDeleteSnapshot(e));
  return snapshotControls;
}

function createSnapshotRowInfo() {
  const snapshotInfo = document.createElement("div");
  snapshotInfo.classList.add("snapshot-info");
  const snapshotIdEl = document.createElement("span");
  snapshotIdEl.textContent = `Snapshot ${snapshotIdCount}`;
  snapshotIdEl.style.fontWeight = "bold";
  snapshotInfo.appendChild(snapshotIdEl);
  return snapshotInfo;
}

function createSnapshotRow(snapshotId, chartImage) {
  const snapshotRow = document.createElement("div");
  snapshotRow.classList.add("snapshot-row");
  snapshotRow.id = snapshotId;
  const snapshotImgContainer = document.createElement("div");
  snapshotImgContainer.classList.add("snapshot-img-container");
  const snapshotImageEl = document.createElement("img");
  snapshotImageEl.src = chartImage.url;
  snapshotImgContainer.appendChild(snapshotImageEl);
  const snapshotInfo = createSnapshotRowInfo(snapshotId);
  const snapshotControls = createSnapshotRowControls(snapshotId, snapshotRow);
  snapshotInfo.appendChild(snapshotControls);
  snapshotRow.appendChild(snapshotImgContainer);
  snapshotRow.appendChild(snapshotInfo);
  snapshotRow.addEventListener("click", onLoadSnapshot);
  // Preprend a row with controls to the snapshot list
  const snapshotList = document.getElementById("snapshot-list");
  snapshotList.prepend(snapshotRow);
}

async function onCaptureSnapshot() {
  // Ensure in progress annotations are finalised before snapshotting
  if (activeAnnotation) {
    await finaliseAnnotation();
  }
  snapshotButton.disabled = true;
  const { snapshotId, chartImage } = await saveSnapshot();
  createSnapshotRow(snapshotId, chartImage);

  // Show a preview thumbnail image of the snapshot
  const snapshotPreviewContainer = document.getElementById("snapshot-preview");
  snapshotPreviewContainer.style.display = "block";
  snapshotPreviewContainer.classList.add("fade-in-out");
  snapshotPreviewContainer.querySelector("img").src = chartImage.url;

  setTimeout(() => {
    snapshotPreviewContainer.classList.remove("fade-in-out");
    snapshotPreviewContainer.style.display = "none";
    snapshotButton.disabled = false;
  }, 2000);
}

function getClickedSnapshotId(e) {
  const snapshotRow = e.target.closest("div.snapshot-row");
  return snapshotRow.id;
}

async function onLoadSnapshot(e) {
  if (activeAnnotation !== null) {
    finaliseAnnotation();
  }
  const snapshotId = getClickedSnapshotId(e);

  // Load the serialized chart from the snapshotStore
  const snapshot = snapshotStore.get(snapshotId);
  await chart.load(snapshot.chartState);

  // Update the selection and the annotate button
  selectedNodes = snapshot.selectedNodes;
  chart.selection(getSelectedNodeIds());
  if (selectedNodes.length > 0) {
    placeAnnotateButtonOnNode(getLatestSelectedNodeId());
  } else {
    hideAnnotateButton();
  }
}

function onDeleteSnapshot(e) {
  e.stopPropagation();
  const snapshotId = getClickedSnapshotId(e);
  const snapshotRow = document.getElementById(snapshotId);
  const [downloadButton, deleteButton] = snapshotRow.querySelectorAll("button");

  // Clean up snapshot row event listeners and remove the row
  snapshotRow.removeEventListener("click", onLoadSnapshot);
  downloadButton.removeEventListener("click", onDownloadSnapshot);
  deleteButton.removeEventListener("click", onDeleteSnapshot);
  snapshotRow.remove();

  snapshotStore.delete(snapshotId);
}

function onDownloadSnapshot(e) {
  e.stopPropagation();
  const snapshotId = getClickedSnapshotId(e);
  const {
    chartImage: { url },
  } = snapshotStore.get(snapshotId);
  const snapshotLink = document.createElement("a"); // create the link to download the image
  snapshotLink.download = `${snapshotId}.svg`;
  snapshotLink.href = url;
  snapshotLink.click();
  URL.revokeObjectURL(snapshotLink.download);
}

// Enable cursor styling override while interacting with the chart
function setCursor(type) {
  // get all non-cursor classes
  const classes = Array.from(chartContainer.classList).filter((cn) => !cn.match(/^cursor-/));
  if (type) {
    classes.push(`cursor-${type}`);
  }
  chartContainer.className = classes.join(" ");
}

async function onSelectionChange() {
  const prevSelectedNodes = selectedNodes;
  updateSelectedNodes();

  if (activeAnnotation !== null) {
    const addedNodes = selectedNodes.filter(
      (node) => !prevSelectedNodes.some((prevNode) => prevNode.id === node.id)
    );
    const removedNodes = prevSelectedNodes.filter(
      (node) => !selectedNodes.some((currNode) => currNode.id === node.id)
    );
    const currSelectedNodeIds = getSelectedNodeIds();
    await updateNodeColours(true, addedNodes);
    await updateNodeColours(false, removedNodes);
    await chart.setProperties({
      id: activeAnnotation.id,
      subject: currSelectedNodeIds,
    });
    activeAnnotation.subject = currSelectedNodeIds;
  } else {
    // remove the glyph from the previous selected node if we have one
    if (nodeIdWithAnnotateButton) {
      hideAnnotateButton();
    }
    if (selectedNodes.length > 0) {
      placeAnnotateButtonOnNode(getLatestSelectedNodeId());
    }
  }

  // Base the container shape of the next annotation on whether marquee selection was last used
  queuedAnnotationContainer = isMarqueeDragging ? "rectangle" : "none";
}

async function onClick({ id, subItem, preventDefault }) {
  const clickedItem = id !== null ? chart.getItem(id) : null;
  if (activeAnnotation !== null && clickedItem?.type !== "node") {
    finaliseAnnotation();
    preventDefault();
    return;
  }

  if (clickedItem) {
    if (clickedItem.type === "annotation" && subItem?.index === editLabelIndex) {
      if (nodeIdWithAnnotateButton !== null) {
        hideAnnotateButton();
      }
      // Prevent selection from being cleared when an annotation is clicked
      preventDefault();
      setCursor(null);
      activeAnnotation = clickedItem;
      await initialiseAnnotationEdit();
    }
  }
}

async function onDragStart({ type, preventDefault }) {
  if (type === "marquee") isMarqueeDragging = true;
  if (activeAnnotation !== null) {
    // prevent marquee dragging from clearing selection while annotation
    // is being edited
    if (isMarqueeDragging) preventDefault();
    await finaliseAnnotation();
  }
}

function onDragEnd({ type }) {
  if (type === "marquee") isMarqueeDragging = false;
}

function onDragMove({ id }) {
  const selectedNodeIds = getSelectedNodeIds();
  // Update the annotate button position while dragging the attached node
  if (activeAnnotation === null && id && selectedNodeIds.includes(id)) {
    updateAnnotateButtonPosition();
  }
}

function onWheel({ preventDefault }) {
  // Prevent zooming the chart while updating an annotation
  if (activeAnnotation !== null) {
    preventDefault();
  }
}

function onViewChange() {
  // Update the annotate button position while zooming and panning the chart
  if (nodeIdWithAnnotateButton) {
    updateAnnotateButtonPosition();
  }
}

/* Ensures annotate button position is updated when nodes
   are moved using the keyboard arrow keys */
function onPreChange({ type }) {
  if (type === "move" && nodeIdWithAnnotateButton !== null) {
    setTimeout(() => {
      updateAnnotateButtonPosition();
    });
  }
}

async function onHover({ id, subItem }) {
  const hoveringEditLabel = id && subItem?.index === editLabelIndex;
  if (hoveringEditLabel) {
    // clear previous hover state when switching directly between annotation labels
    if (hoveredAnnotationId !== null && hoveredAnnotationId !== id) {
      await updateEditLabelHoverState(false, chart.getItem(hoveredAnnotationId));
    }
    hoveredAnnotationId = id;
  }
  if (hoveredAnnotationId !== null) {
    await updateEditLabelHoverState(hoveringEditLabel, chart.getItem(hoveredAnnotationId));
    const cursorUpdateType = hoveringEditLabel ? "pointer" : "null";
    setCursor(cursorUpdateType);
  }
  if (id === null) {
    hoveredAnnotationId = null;
  }
}

async function updateEditLabelHoverState(hovered, hoveredAnnotation) {
  const hoveredAnnotationLabel = hoveredAnnotation?.t;
  if (hoveredAnnotationLabel) {
    const editLabelHoverColour = hovered ? colours.hoveredEditLabel : colours.defaultEditLabel;
    hoveredAnnotation.t[editLabelIndex].fbc = editLabelHoverColour;
    await chart.setProperties({
      id: hoveredAnnotation.id,
      t: hoveredAnnotation.t,
    });
  }
}

function initialiseInteractions() {
  chart.on("selection-change", onSelectionChange);
  chart.on("click", onClick);
  chart.on("hover", onHover);
  chart.on("drag-start", onDragStart);
  chart.on("drag-end", onDragEnd);
  chart.on("drag-move", onDragMove);
  chart.on("wheel", onWheel);
  chart.on("view-change", onViewChange);
  chart.on("prechange", onPreChange);
  const navControls = document.querySelectorAll("input[name=nav-controls]");
  const containerControls = document.querySelectorAll("input[name=container-controls]");
  const confirmAnnotationUpdateButton = document.getElementById("done");
  const deleteAnnotationButton = document.getElementById("delete");
  const chartCanvas = document.querySelector("canvas");

  navControls.forEach((navControl) => {
    navControl.addEventListener("change", (e) => {
      updateHandMode(e.target.value);
    });
  });

  annotateButton.addEventListener("click", annotate);

  containerControls.forEach((containerControl) => {
    containerControl.addEventListener("change", async (e) => {
      await updateAnnotationContainer(e.target.value);
    });
  });

  annotationTextArea.addEventListener("input", onAnnotationInput);
  annotationTextArea.addEventListener("keydown", onAnnotationKeyDown);
  snapshotButton.addEventListener("click", onCaptureSnapshot);
  confirmAnnotationUpdateButton.addEventListener("click", () => finaliseAnnotation("dismiss"));
  deleteAnnotationButton.addEventListener("click", () => finaliseAnnotation("delete"));

  /* Pass the wheel event to the keylines chart to enable zooming 
      while hovering the annotate button */
  annotateButton.addEventListener(
    "wheel",
    (e) => {
      e.preventDefault();
      chartCanvas.dispatchEvent(new WheelEvent("wheel", e));
    },
    { passive: false }
  );

  // End annotation process on window resize
  window.addEventListener("resize", () => {
    if (activeAnnotation !== null) {
      finaliseAnnotation();
    }
  });
}

async function startKeyLines() {
  const options = {
    handMode: true,
    navigation: false,
    overview: false,
    minZoom: 0.38,
    hover: 20,
    fontFamily: "Muli",
    iconFontFamily: "Font Awesome 5 Free",
    imageAlignment: {
      "fas fa-user": { e: 0.7 },
      "fas fa-building": { e: 0.7 },
    },
    selectionColour: colours.active,
    selectionFontColour: "rgb(0, 0, 0)",
    gradient: {
      stops: [
        { r: 0, c: "rgb(237, 234, 254)" },
        { r: 0.47, c: "rgb(233, 244, 255)" },
        { r: 1, c: "rgb(224, 248, 255)" },
      ],
    },
    selectedNode: {
      ha0: {
        c: colours.active,
        r: 30,
        w: 1,
      },
    },
    selectedLink: {},
    linkEnds: {
      avoidLabels: true,
      spacing: "loose",
    },
  };

  chart = await KeyLines.create({ container: "klchart", options });
  await chart.load(data);
  await chart.layout("lens", { consistent: true });
  const { snapshotId, chartImage } = await saveSnapshot();
  createSnapshotRow(snapshotId, chartImage);

  initialiseInteractions();
}

function loadWebFontsAndStart() {
  Promise.all([
    document.fonts.load("24px 'Muli'"),
    document.fonts.load("24px 'Font Awesome 5 Free'"),
    document.fonts.load("24px 'CI-Icons'"),
  ]).then(startKeyLines);
}

window.addEventListener("DOMContentLoaded", loadWebFontsAndStart);
import KeyLines from "keylines";
export const colours = {
  active: "rgb(255, 0, 255)",
  defaultNodeBase: "rgb(196, 218, 221)",
  link: "rgb(192, 192, 192)",
  defaultFontIcon: "rgb(102, 139, 144)",
  activeFontIcon: "rgb(255, 255, 255)",
  defaultEditLabel: "rgb(248, 248, 248)",
  hoveredEditLabel: "rgb(84, 193, 255)",
};

const fontIcons = {
  person: {
    t: "fas fa-user",
    c: colours.defaultFontIcon,
  },
  company: {
    t: "fas fa-building",
    c: colours.defaultFontIcon,
  },
};

export const BASE_ANNOTATION = {
  type: "annotation",
  connectorStyle: {
    c: colours.active,
    subjectEnd: "dot",
  },
  position: {
    distance: 5,
    angle: 90,
  },
  w: 274,
  h: 46,
  c: "rgb(255, 255, 255)",
  borderRadius: 3,
  bw: 0,
  t: [
    {
      fbc: "transparent",
      minWidth: "stretch",
      minHeight: "stretch",
      position: { vertical: "middle", horizontal: "centre" },
      textWrap: "normal",
    },
    {
      t: "",
      fc: "rgb(0, 0, 0)",
      textAlignment: {
        horizontal: "left",
      },
      position: {
        horizontal: "left",
        vertical: "top",
      },
      padding: "16 12 12 12",
      textWrap: "normal",
      maxHeight: 2000,
      maxWidth: 274,
    },
    {
      fi: {
        t: "ci-icon-pencil-edit",
        c: "rgb(0, 0, 0)",
        ff: "CI-Icons",
      },
      fbc: colours.defaultEditLabel,
      position: {
        horizontal: "right",
        vertical: "bottom",
      },
      padding: 8,
      margin: 8,
      borderRadius: 6,
    },
  ],
};

export function createAnnotation(
  id,
  subject,
  container,
  text,
  ls = "solid",
  position = BASE_ANNOTATION.position,
  w = BASE_ANNOTATION.w,
  h = BASE_ANNOTATION.h
) {
  const t = BASE_ANNOTATION.t.map((t) => ({ ...t }));
  if (text) {
    const textContentLabelIndex = 1;
    t[textContentLabelIndex].t = text;
  }
  return {
    ...BASE_ANNOTATION,
    id,
    subject,
    connectorStyle: {
      ...BASE_ANNOTATION.connectorStyle,
      container,
      ls,
    },
    t,
    position,
    w,
    h,
  };
}

const buildingNodeScaleFactor = 0.634;

const defaultAnnotations = [
  createAnnotation(
    "a1",
    ["phantomcorporation"],
    "none",
    "This company was dissolved recently.",
    undefined,
    {
      distance: 5,
      angle: 290,
    },
    274,
    72
  ),
  createAnnotation(
    "a2",
    ["brookefields", "dianagreene"],
    "rectangle",
    "Ms Greene has only worked at companies that the investigation subject, Ms Fields, has also worked at.",
    undefined,
    {
      distance: 42,
      angle: 140,
    },
    274,
    103
  ),
  createAnnotation(
    "a3",
    [],
    "none",
    "Investigate dataset with employees and their employers.",
    undefined,
    {
      x: -524,
      y: -370,
    },
    274,
    86
  ),
];
export const defaultAnnotationsCount = defaultAnnotations.length;

export const data = {
  type: "LinkChart",
  items: [
    ...defaultAnnotations,
    // People
    {
      id: "dannymatthews",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Danny Matthews",
          fs: 14,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.person,
    },
    {
      id: "damondouglas",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Damon Douglas",
          fs: 14,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.person,
    },
    {
      id: "brookefields",
      type: "node",
      c: colours.defaultNodeBase,
      e: 1.4,
      t: [
        {
          t: "Brooke Fields",
          fs: 14,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.person,
    },
    {
      id: "adrianmcdaniel",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Adrian McDaniel",
          fs: 14,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.person,
    },
    {
      id: "ginacarlson",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Gina Carlson",
          fs: 14,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.person,
    },
    {
      id: "noelluna",
      type: "node",
      c: colours.defaultNodeBase,
      t: {
        t: "Noel Luna",
        fs: 14,
        fbc: "transparent",
        position: "s",
      },
      fi: fontIcons.person,
    },
    {
      id: "dianagreene",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Diana Greene",
          fs: 14,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.person,
    },
    {
      id: "garyking",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Gary King",
          fs: 14,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.person,
    },
    {
      id: "felixfloyd",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Felix Floyd",
          fs: 14,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.person,
    },

    {
      id: "tamilindsey",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Tami Lindsey",
          fs: 14,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.person,
      h: 10,
    },
    // Companies
    {
      id: "diamondaviation",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Diamond Aviation",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "bansheewares",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Bansheewares",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "summitechnologies",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Summitechnologies",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "cloudwalk",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Cloudwalk",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "grottocorp",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Grotto Corp",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "jettechs",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Jettechs",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "bluetronics",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Bluetronics",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "green",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Green",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "melonarts",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Melon Arts",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "marblightning",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Marblightning",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "phantomcorporation",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Phantom Corporation",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "phoenixland",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Phoenixland",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "marblightning",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Marb Lightning",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "marblightning",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Marb Lightning",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "cycloration",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Cycloration",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "honeytelligence",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Honeytelligence",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "crystalsun",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Crystalsun",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "summittechnologies",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Summit Technologies",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "tempesttechnologies",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Tempest Technologies",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "green",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Green",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },
    {
      id: "marblightning",
      type: "node",
      c: colours.defaultNodeBase,
      t: [
        {
          t: "Marblightning",
          fs: 20,
          fbc: "transparent",
          position: "s",
        },
      ],
      fi: fontIcons.company,
      e: buildingNodeScaleFactor,
    },

    // Links
    {
      id: "dannymatthews-diamondaviation",
      type: "link",
      c: colours.link,
      id1: "dannymatthews",
      id2: "diamondaviation",
      a2: true,
    },
    {
      id: "dannymatthews-grottocorp",
      type: "link",
      c: colours.link,
      id1: "dannymatthews",
      id2: "grottocorp",
      a2: true,
    },
    {
      id: "dannymatthews-bansheewares",
      type: "link",
      c: colours.link,
      id1: "dannymatthews",
      id2: "bansheewares",
      a2: true,
    },
    {
      id: "adrianmcdaniel-grottocorp",
      type: "link",
      c: colours.link,
      id1: "adrianmcdaniel",
      id2: "grottocorp",
      a2: true,
    },
    {
      id: "adrianmcdaniel-tempesttechnologies",
      type: "link",
      c: colours.link,
      id1: "adrianmcdaniel",
      id2: "tempesttechnologies",
      a2: true,
    },
    {
      id: "brookefields-bluetronics",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "bluetronics",
      a2: true,
    },
    {
      id: "brookefields-summittechnologies",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "summittechnologies",
      a2: true,
    },
    {
      id: "brookefields-cloudwalk",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "cloudwalk",
      a2: true,
    },
    {
      id: "ginacarlson-cloudwalk",
      type: "link",
      c: colours.link,
      id1: "ginacarlson",
      id2: "cloudwalk",
      a2: true,
    },
    {
      id: "dianagreene-cloudwalk",
      type: "link",
      c: colours.link,
      id1: "dianagreene",
      id2: "cloudwalk",
      a2: true,
    },
    {
      id: "brookefields-summitechnologies",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "summitechnologies",
      a2: true,
    },
    {
      id: "brookefields-jettechs",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "jettechs",
      a2: true,
    },
    {
      id: "tamilindsey-phoenixland",
      type: "link",
      c: colours.link,
      id1: "tamilindsey",
      id2: "phoenixland",
      a2: true,
    },
    {
      id: "tamilindsey-tempesttechnologies",
      type: "link",
      c: colours.link,
      id1: "tamilindsey",
      id2: "tempesttechnologies",
      a2: true,
    },
    {
      id: "tamilindsey-cycloration",
      type: "link",
      c: colours.link,
      id1: "tamilindsey",
      id2: "cycloration",
      a2: true,
    },
    {
      id: "tamilindsey-marblightning",
      type: "link",
      c: colours.link,
      id1: "tamilindsey",
      id2: "marblightning",
      a2: true,
    },

    {
      id: "brookefields-green",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "green",
      a2: true,
    },

    {
      id: "brookefields-melonarts",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "melonarts",
      a2: true,
    },

    {
      id: "brookefields-marblightning",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "marblightning",
      a2: true,
    },

    {
      id: "ginacarlson-bluetronics",
      type: "link",
      c: colours.link,
      id1: "ginacarlson",
      id2: "bluetronics",
      a2: true,
    },
    {
      id: "ginacarlson-jettechs",
      type: "link",
      c: colours.link,
      id1: "ginacarlson",
      id2: "jettechs",
      a2: true,
    },
    {
      id: "noelluna-phantomcorporation",
      type: "link",
      c: colours.link,
      id1: "noelluna",
      id2: "phantomcorporation",
      a2: true,
    },
    {
      id: "felixfloyd-phantomcorporation",
      type: "link",
      c: colours.link,
      id1: "felixfloyd",
      id2: "phantomcorporation",
      a2: true,
    },
    {
      id: "brookefields-phantomcorporation",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "phantomcorporation",
      a2: true,
    },
    {
      id: "brookefields-cycloration",
      type: "link",
      c: colours.link,
      id1: "brookefields",
      id2: "cycloration",
      a2: true,
    },
    {
      id: "adrianmcdaniel-honeytelligence",
      type: "link",
      c: colours.link,
      id1: "adrianmcdaniel",
      id2: "honeytelligence",
      a2: true,
    },
    {
      id: "noelluna-honeytelligence",
      type: "link",
      c: colours.link,
      id1: "noelluna",
      id2: "honeytelligence",
      a2: true,
    },
    {
      id: "noelluna-green",
      type: "link",
      c: colours.link,
      id1: "noelluna",
      id2: "green",
      a2: true,
    },
    {
      id: "damondouglas-summittechnologies",
      type: "link",
      c: colours.link,
      id1: "damondouglas",
      id2: "summittechnologies",
      a2: true,
    },
    {
      id: "damondouglas-phantomcorporation",
      type: "link",
      c: colours.link,
      id1: "damondouglas",
      id2: "phantomcorporation",
      a2: true,
    },
    {
      id: "damondouglas-crystalsun",
      type: "link",
      c: colours.link,
      id1: "damondouglas",
      id2: "crystalsun",
      a2: true,
    },
    {
      id: "felixfloyd-crystalsun",
      type: "link",
      c: colours.link,
      id1: "felixfloyd",
      id2: "crystalsun",
      a2: true,
    },
    {
      id: "dianagreene-green",
      type: "link",
      c: colours.link,
      id1: "dianagreene",
      id2: "green",
      a2: true,
    },
    {
      id: "dianagreene-melonarts",
      type: "link",
      c: colours.link,
      id1: "dianagreene",
      id2: "melonarts",
      a2: true,
    },
    {
      id: "garyking-marblightning",
      type: "link",
      c: colours.link,
      id1: "garyking",
      id2: "marblightning",
      a2: true,
    },
    {
      id: "garyking-jettechs",
      type: "link",
      c: colours.link,
      id1: "garyking",
      id2: "jettechs",
      a2: true,
    },
  ],
};
<!doctype html>
<html lang="en" style="background-color: #2d383f">
  <head>
    <meta charset="utf-8" />
    <title>Annotating Charts</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="@fortawesome/[email protected]/css/fontawesome.css"
    />
    <link
      rel="stylesheet"
      type="text/css"
      href="@fortawesome/[email protected]/css/solid.css"
    />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/ci-icons.css" />
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div id="klchart" class="klchart">
      <div class="overlay" oncontextmenu="return false;">
        <div class="static-overlay">
          <div class="overlay-toolbar">
            <div class="switch" id="nav-controls">
              <input type="radio" name="nav-controls" id="marquee" value="marquee" /><label
                class="ci-icon-marquee"
                for="marquee"
              ></label
              ><input type="radio" name="nav-controls" id="cursor" value="hand" checked /><label
                class="ci-icon-cursor"
                for="cursor"
              ></label>
            </div>
            <button class="overlay-button static-overlay-button" id="snapshot-button">
              <span class="ci-icon-snapshot"></span>
            </button>
          </div>
          <div id="snapshot-preview"><img draggable="false" alt="A chart preview export" /></div>
        </div>
        <button class="overlay-button" id="annotate-button">
          <span class="ci-icon-annotate"></span>
        </button>
        <div id="annotation-preview" oncontextmenu="return false;">
          <div id="annotation-upper">
            <textarea
              id="annotation-text"
              placeholder="Add comment"
              rows="1"
              spellcheck="false"
            ></textarea
            ><button class="control-button" id="ghost-done-button" disabled>
              <span class="ci-icon-check"></span>
            </button>
          </div>
          <div id="annotation-toolbar">
            <div class="switch" id="container-controls">
              <input type="radio" name="container-controls" id="none" value="none" checked /><label
                class="ci-icon-diagonal-line"
                for="none"
              ></label
              ><input type="radio" name="container-controls" id="circle" value="circle" /><label
                class="ci-icon-circle"
                for="circle"
              ></label
              ><input
                type="radio"
                name="container-controls"
                id="rectangle"
                value="rectangle"
              /><label class="ci-icon-rectangle" for="rectangle"></label>
            </div>
            <div id="edit-controls">
              <button class="overlay-button" id="delete"><span class="ci-icon-bin"></span></button
              ><button class="overlay-button" id="done"><span class="ci-icon-check"></span></button>
            </div>
          </div>
        </div>
        <span id="text-measure"></span>
      </div>
    </div>
    <script type="module" src="./code.js"></script>
  </body>
</html>
.overlay {
  position: absolute;
  width: 100%;
  height: 100%;
  overflow: hidden;
  /* Allow chart interaction through the overlay div*/
  pointer-events: none;
}

/* Includes the toolbar and snapshot preview image */
.static-overlay {
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.overlay-toolbar {
  display: flex;
  width: 100%;
  box-sizing: border-box;
  justify-content: space-between;
}

/* Enable pointer events on the toolbar controls themselves */
.overlay-toolbar * {
  pointer-events: auto;
  z-index: 500;
}

/* Enable cursor overriding while hovering the chart */
.cursor-pointer canvas {
  cursor: pointer !important;
}

/* Radio Controls used for nav controls
   and annotation container setting  */
.switch {
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Hide the radio input and only
   show the label as an icon  */
.switch input {
  display: none !important;
}

.switch label {
  display: flex !important;
  font-size: 17px;
  user-select: none;
  align-items: center !important;
  justify-content: center !important;
  background-image: none;
}

.switch label:first-of-type {
  border-radius: 6px 0px 0px 6px;
}

.switch label:last-of-type {
  border-radius: 0px 6px 6px 0px;
}

#nav-controls label {
  width: 38px;
  height: 38px;
  background-color: rgb(248, 248, 248);
  color: black;
}

#nav-controls :checked + label {
  background-color: rgb(0, 163, 255);
}

#nav-controls :not(:checked) + label:hover {
  background-color: rgba(84, 193, 255, 1);
}

.switch :not(:checked) + label {
  cursor: pointer !important;
}

#nav-controls > .ci-icon-cursor {
  font-size: 12px;
}

#nav-controls > .ci-icon-marquee {
  font-size: 22px;
}

.overlay-button {
  background-color: rgb(0, 163, 255);
  border: none;
  cursor: pointer !important;
  color: black;
  &:hover {
    background-color: rgb(84, 193, 255);
  }

  &:not(:disabled):hover {
    background-color: rgb(84, 193, 255) !important;
    > span {
      color: rgb(0, 0, 0);
    }
  }

  &:disabled {
    background-color: rgba(0, 163, 255);
    border: none;
    cursor: not-allowed !important;
    color: black !important;

    &:hover {
      border: none;
      background-color: rgba(0, 163, 255);
    }
  }

  &:focus {
    background-color: rgba(0, 163, 255) !important;
    color: black !important;
  }
}

.static-overlay-button {
  font-size: 16px;
  padding: 8px 16px 8px 16px;
  border-radius: 6px !important;
}

#snapshot-button {
  width: 38px;
  height: 38px;
  padding: 2px 1px 0px 0px;
  font-size: 14px;
}

/* Overlay button (position (top/left) is updated from JS) */
#annotate-button {
  position: absolute;
  z-index: 1000;
  width: 32px;
  height: 32px;
  border-radius: 20px 20px 20px 0px !important;
  padding: 0px;
  pointer-events: auto;
  display: none;
}

/* Preview overlay*/
#annotation-preview {
  display: none; /* Initially none otherwise flex */
  position: absolute;
  flex-direction: column;
  padding: 10px;
  border-radius: 3px;
  width: 274px;
  max-width: 274px;
  gap: 4px;
  background-color: rgb(255, 255, 255);
  z-index: 499;
  box-sizing: border-box;
  pointer-events: auto;
}

#annotation-upper {
  display: flex;
  align-items: center;
}

#annotation-toolbar {
  display: none;
  justify-content: space-between;
  gap: 50px;
  padding: 0px;
  width: 100%;
}

#edit-controls {
  display: flex;
  gap: 6px;
}

#edit-controls > button {
  background-color: rgb(248, 248, 248);
  cursor: pointer;
  border: none;
  width: 26px;
  height: 26px;
  padding: 0px;
  border-radius: 6px !important;

  &:hover {
    background-color: (84, 193, 255);
  }
}

#annotation-text {
  resize: none;
  border: none;
  font-family: "Muli";
  width: 100%;
  height: auto;
  overflow: hidden;
  line-height: 16px;
  font-size: 14px;
  background-color: rgb(255, 255, 255);
  &:focus {
    outline: none;
  }
}

#text-measure {
  font-family: "Muli";
  line-height: 15px;
  font-size: 14px;
  padding: 2px;
  visibility: hidden;
}

/* Place holder disabled button for when we first
   create an empty annotation */
#ghost-done-button {
  width: 26px;
  height: 26px;
  padding: 0px;
  background-color: #213d450d;
  border: none;
  border-radius: 6px;
  cursor: auto;

  &:disabled > span {
    color: black !important;
  }
}

.ci-icon-check {
  font-size: 10px;
  margin-top: 4px;
}

.ci-icon-bin {
  font-size: 13px;
  margin-top: 2px;
}

#container-controls label {
  width: 26px;
  height: 26px;
  font-size: 14px;
}

#container-controls :checked + label {
  background-color: rgb(206, 234, 251);
}

#container-controls :not(:checked):hover + label {
  background-color: rgb(84, 193, 255);
}

#snapshot-preview {
  display: none;
  background-color: rgb(255, 255, 255);
  border: 1px solid rgb(0, 0, 0) !important;
  border-radius: 3px;
  width: 348px;
  height: 214px;
  align-self: flex-end;
  pointer-events: auto;
  z-index: 1001;
  box-sizing: content-box;
}

.fade-in-out {
  opacity: 1;
  animation: fade 2s ease-out;
}

@keyframes fade {
  0%,
  100% {
    opacity: 0;
  }
  20%,
  75% {
    opacity: 1;
  }
}

.snapshot-row {
  display: flex;
  justify-content: flex-start;
  gap: 20px;
  height: 96px;
  text-align: left;
  margin: 0px;

  &:hover {
    cursor: pointer;
    background-color: rgba(0, 153, 104, 0.1);

    .snapshot-img-container {
      padding: 4px 9px 4px 4px;
    }
  }
}

#snapshot-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.snapshot-img-container img {
  height: 100%;
  object-fit: contain;
  background-color: rgb(255, 255, 255);
}

.snapshot-info {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 8px 0px 8px 0px;
}

#snapshot-controls {
  display: flex;
  gap: 20px;

  > button {
    padding: 4px !important;
    width: 26px;
    height: 26px;
    border: none;
    background-color: transparent !important;

    span {
      color: rgb(0, 153, 104);
    }

    &:hover {
      background-color: rgba(255, 255, 255, 0.4) !important;
    }
  }
}

.cicontent > p > span {
  display: inline-block;
  width: 22px;
  height: 22px;
  vertical-align: bottom;
  pointer-events: none;
  background-color: rgb(234, 234, 234);

  &:hover {
    background-color: initial;
  }

  > i {
    font-size: 10px;
  }
}

.inline-snapshot-button {
  border-radius: 2px;
  margin: 0px 5px 0px 4px;
}

.inline-edit-button {
  margin: 0px 6px 0px 4px;
}

.inline-annotate-button {
  border-radius: 20px 20px 20px 0px;
  margin: 0px 4px 0px 5px;
}

/* Playground specific styling */
#demorhscontent {
  top: 74px !important;
}

.snapshot-preview-playground {
  align-self: flex-start !important;
}

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.