Search

Advanced Annotation Styles

Annotations

Create annotations with labels, buttons and interactive controls.

Advanced Annotation Styles
View live example →

A beautiful and intuitive annotation design will not only look great in your application, but also help chart users learn the user interface so they can quickly start using the chart and sharing their insights.

Styling the annotation body

The body is the main part of the annotation that can display information, images or user controls. You can modify it in the Annotation API to set custom dimensions, fill colour or borders, and also add labels and glyphs.

Labels and glyphs

The t array for labels and g array for glyphs can be used to add any number of labels and glyphs with text, images and font icons. Labels and glyphs on annotations behave the same way as labels and glyphs on nodes, so you can get even more design inspiration in our Advanced Node Styles demo.

Custom interactions

Labels and glyphs can also serve as UI elements such as buttons, icons or toggles. Use pointer events and hover events to respond to user actions and add interaction styling.

To learn how to create user-editable text fields and other custom elements using HTML overlay, see the User-Created Annotations demo.

Key functions used:

import {
  data,
  itemsMap,
  colours,
  themeAnnotations,
  threadAnnotations,
  icons,
  showAnnotationGlyph,
} from "./data.js";

import KeyLines from "keylines";

let chart;
const subItemTypePropMap = {
  glyph: "g",
  label: "t",
};
/**
 * Contains the data and click handlers needed to manage and process user interactions with chart items
 * Sub items (labels / glyphs) act as buttons
 * - an onClick handler is specified for each button and called via the chart 'click' event
 * - a hover style is specified for each hoverable button and applied using chart.setProperties() via the chart 'hover' event
 * The lastHoveredButton property stores the item id and subItem id of the most recently hovered button
 * so that the hover effect can be removed when it is unhovered
 */
const interactionInfo = {
  // store the last hovered button's parent id and subItemIndex for style updates
  lastHoveredButton: null,
  items: {
    a4: {
      subItemType: "glyph",
      buttons: {
        0: {
          onClick: async () => await toggleShowHideAnnotation("hide"),
          styles: {
            default: {
              c: colours.a4.defaultButton,
              b: colours.a4.defaultButton,
            },
            hovered: {
              c: colours.a4.hoveredButton,
              b: colours.a4.hoveredButton,
            },
          },
        },
      },
    },
    n4: {
      subItemType: "glyph",
      buttons: {
        0: {
          onClick: async () => await toggleShowHideAnnotation("show"),
          styles: {
            default: {
              fi: {
                t: icons.comment,
                c: colours.a4.defaultButton,
              },
            },
            hovered: {
              fi: {
                t: icons.comment,
                c: colours.a4.hoveredButton,
              },
            },
          },
        },
      },
    },
    a5: {
      subItemType: "label",
      buttons: {
        1: {
          onClick: async () => await updateAnnotation(themeAnnotations.light),
          styles: {
            default: {
              fbc: colours.a5.defaultButton,
              fc: "rgb(0, 0, 0)",
            },
            hovered: {
              fbc: colours.a5.hoveredButton,
              fc: "rgb(255, 255, 255)",
            },
          },
        },
        2: {
          onClick: async () => await updateAnnotation(themeAnnotations.dark),
          styles: {
            default: {
              fbc: colours.a5.defaultButton,
              fc: "rgb(0, 0, 0)",
            },
            hovered: {
              fbc: colours.a5.hoveredButton,
              fc: "rgb(255, 255, 255)",
            },
          },
        },
      },
    },
    a6: {
      subItemType: "label",
      buttons: {
        5: {
          onClick: async () => await updateAnnotation(threadAnnotations.open),
        },
        10: {
          onClick: async () => await updateAnnotation(threadAnnotations.closed),
        },
      },
    },
  },
};

/* Formats the code snippets neatly in the display box on the right-hand side
   if no annotation is provided, prompt the user to select an annotation */
function prettyPrint(item) {
  const codeDisplayEl = document.getElementById("display");
  if (item?.type === "annotation") {
    const json = JSON.stringify(item, undefined, 1);
    codeDisplayEl.textContent = json;
  } else {
    codeDisplayEl.textContent = "Click an annotation";
  }
}

// Update the itemsMap so we can prettyPrint only the required properties of the annotation
async function updateAnnotation(updatedAnnotation) {
  itemsMap[updatedAnnotation.id] = updatedAnnotation;
  await chart.setProperties(updatedAnnotation);
}

async function toggleShowHideAnnotation(action) {
  const toggleShowHideAnnotationId = "a4";
  // Show / hide the annotation
  chart[action](toggleShowHideAnnotationId, { animate: true, time: 280 });

  // Add / remove the show glyph from the node subject
  const nodeSubjectUpdate = {
    id: "n4",
    g: action === "hide" ? [showAnnotationGlyph] : undefined,
  };
  await setHoveredState(toggleShowHideAnnotationId, "0", false);
  await chart.setProperties(nodeSubjectUpdate);
}

function isHoveringButton(id, subItem) {
  const itemInteractionInfo = interactionInfo.items[id];
  return (
    itemInteractionInfo?.subItemType === subItem.type && itemInteractionInfo?.buttons[subItem.index]
  );
}

// Enable cursor styling override while interacting with the chart
function setCursor(hovering) {
  const type = hovering ? "pointer" : null;
  const chartContainer = document.getElementById("klchart");
  // 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 setHoveredState(id, subItem, hovering) {
  if (hovering === undefined) {
    hovering = isHoveringButton(id, subItem);
  }
  const itemIdToUpdate = hovering ? id : interactionInfo.lastHoveredButton?.id;
  if (itemIdToUpdate) {
    setCursor(hovering);
    const itemInteractionInfo = interactionInfo.items[itemIdToUpdate];
    let subItemIndexToUpdate = hovering
      ? subItem.index
      : interactionInfo.lastHoveredButton.subItemIndex;
    const itemToUpdate = chart.getItem(itemIdToUpdate);
    const subItemPropName = subItemTypePropMap[interactionInfo.items[itemIdToUpdate].subItemType];
    const subItemArray = itemToUpdate[subItemPropName];
    let styleName = null;
    if (
      subItemArray[subItemIndexToUpdate] &&
      subItemArray[subItemIndexToUpdate].fbc !== colours.a5.selectedButton
    ) {
      styleName = hovering ? "hovered" : "default";
    }
    // Remove the hover effect from the previously hovered theme button if we are travelling
    // straight from the other theme button
    else if (
      interactionInfo.lastHoveredButton &&
      subItemArray[interactionInfo.lastHoveredButton.subItemIndex]?.fbc !==
        colours.a5.selectedButton
    ) {
      subItemIndexToUpdate = interactionInfo.lastHoveredButton.subItemIndex;
      styleName = "default";
    }
    if (styleName) {
      const styleUpdate = itemInteractionInfo.buttons[subItemIndexToUpdate]?.styles?.[styleName];
      if (styleUpdate) {
        subItemArray[subItemIndexToUpdate] = {
          ...subItemArray[subItemIndexToUpdate],
          ...styleUpdate,
        };
        await chart.setProperties({
          id: itemIdToUpdate,
          [subItemPropName]: subItemArray,
        });
      }
      interactionInfo.lastHoveredButton = hovering
        ? {
            id,
            subItemIndex: subItem.index,
          }
        : null;
    }
  }
}

function initialiseInteractions() {
  chart.on("click", async ({ id, subItem, preventDefault }) => {
    if (itemsMap[id]) {
      // Prevent item selection
      preventDefault();
      if (subItem.type === "label" || subItem.type === "glyph") {
        // if the subItem is clickable trigger the appropriate onClick action
        if (interactionInfo.items[id] && interactionInfo.items[id].buttons[subItem.index]) {
          interactionInfo.items[id].buttons[subItem.index].onClick();
        }
      }
    }
    prettyPrint(itemsMap[id]);
  });
  chart.on("hover", async ({ id, subItem }) => await setHoveredState(id, subItem));
}

async function startKeyLines() {
  const options = {
    handMode: true,
    backColour: "rgb(230, 234, 224)",
    navigation: false,
    minZoom: 0.3,
    hover: 10,
    zoom: { adaptiveStyling: false },
    iconFontFamily: "CI-Icons",
    imageAlignment: {
      [icons.x]: { e: 0.8 },
      [icons.comment]: { e: 2 },
    },
    legacyGlyphAndLabelOrdering: false,
  };

  chart = await KeyLines.create({ container: "klchart", options });
  await chart.load(data);
  await chart.zoom("fit");

  initialiseInteractions();
}

// if you need font icons, keep this.
// If not, call startKeyLines() from the listener on the last line and delete this function
function loadFonts() {
  document.fonts.load('24px "CI-Icons"').then(startKeyLines);
}

window.addEventListener("DOMContentLoaded", loadFonts);
import KeyLines from "keylines";

export const icons = {
  clock: "ci-icon-clock",
  shoppingCart: "ci-icon-trolley",
  pizza: "ci-icon-pizza",
  messageAlert: "ci-icon-message-alert",
  x: "ci-icon-x",
  comment: "ci-icon-comment",
};

const showAnnotationGlyphColours = {
  defaultButton: "rgb(250, 141, 255)",
  hoveredButton: "rgb(255, 0, 255)",
};

export const colours = {
  a4: showAnnotationGlyphColours,
  n4: showAnnotationGlyphColours,
  a5: {
    light: {
      background: "rgb(255, 255, 255)",
      primaryText: "rgb(0, 0, 0)",
      secondaryText: "rgb(255, 255, 255)",
    },
    dark: {
      background: "rgb(34, 38, 41)",
      primaryText: "rgb(255, 255, 255)",
      secondaryText: "rgb(0, 0, 0)",
    },
    selectedButton: "rgb(55, 103, 88)",
    defaultButton: "rgb(213, 221, 225)",
    hoveredButton: "rgb(24, 69, 55)",
  },
};

export const showAnnotationGlyph = {
  p: "ne",
  c: "transparent",
  b: "transparent",
  fi: {
    t: icons.comment,
    c: colours.a4.defaultButton,
  },
};

function makeThemeToggleAnnotation(theme) {
  const selectedThemeColours = colours.a5[theme];
  const backgroundColour = selectedThemeColours.background;
  const lightTheme = theme === "light";
  const lightButtonColour = lightTheme ? colours.a5.selectedButton : colours.a5.defaultButton;
  const darkButtonColour = lightTheme ? colours.a5.defaultButton : colours.a5.selectedButton;
  const buttonPadding = [6, 40, 6, 40];
  return {
    id: "a5",
    type: "annotation",
    subject: ["n5", "n6"],
    w: 324,
    h: 74,
    c: backgroundColour,
    bw: 0,
    borderRadius: 16,
    connectorStyle: {
      container: "rectangle",
      subjectEnd: "none",
    },
    t: [
      {
        t: "Add buttons to your annotation",
        fbc: "transparent",
        fc: selectedThemeColours.primaryText,
        position: {
          horizontal: "centre",
          vertical: "top",
        },
        padding: {
          top: 10,
          bottom: 8,
        },
      },
      {
        t: "Light mode",
        fbc: lightButtonColour,
        fc: selectedThemeColours.secondaryText,

        borderRadius: [20, 0, 0, 20],
        padding: buttonPadding,
      },
      {
        t: "Dark mode",
        fbc: darkButtonColour,
        fc: selectedThemeColours.primaryText,
        borderRadius: [0, 20, 20, 0],
        padding: buttonPadding,
        position: {
          vertical: "inherit",
        },
      },
    ],
  };
}

export const threadComments = [
  makeThreadComment(
    "/im/people/Person2.png",
    "Niall Milligan",
    "Show a thread of collaboration between colleagues",
    3,
    true
  ),
  makeThreadComment(
    "/im/people/Person3.png",
    "Emanuela Cooper",
    "Display more information when requested by the user",
    2,
    false
  ),
];

function makeThreadComment(imageUrl, name, comment, elapsedHours, isFirstInThread) {
  return [
    {
      u: imageUrl,
      position: {
        horizontal: "left",
        vertical: isFirstInThread ? "top" : "inherit",
      },
      maxWidth: 35,
      maxHeight: 35,
      margin: {
        top: 10,
        left: 10,
      },
    },
    {
      t: name,
      fb: true,
      position: {
        vertical: "inherit",
        horizontal: 45,
      },
      margin: {
        top: 8,
      },
      padding: [10, 10, 3, 10],
    },
    {
      t: comment,
      padding: [3, 10, 3, 10],
    },
    {
      t: `${elapsedHours} hours ago`,
      padding: [3, 10, 12, 10],
      fs: 10,
    },
    {
      fs: 0,
      fbc: "rgba(0, 0, 0, 0.1)",
      minWidth: 311,
      minHeight: 1,
      position: {
        horizontal: "centre",
      },
      padding: 0,
    },
  ];
}

function makeThreadFooter(actionName) {
  const threadFooterLabels = [
    {
      t: actionName,
      position: {
        horizontal: "right",
        vertical: "inherit",
      },
      margin: {
        top: 2,
        right: 16,
      },
    },
    {
      fs: 0,
      fbc: "rgb(0, 0, 0)",
      minWidth: 34,
      minHeight: 1,
      position: {
        horizontal: "right",
        vertical: "inherit",
      },
      padding: 0,
      margin: {
        top: 24,
        right: 26,
      },
    },
  ];
  if (actionName === "show") {
    threadFooterLabels.push({
      t: "1 more reply",
      position: {
        horizontal: "left",
        vertical: "inherit",
      },
      margin: {
        top: 2,
        left: 16,
      },
    });
  }
  return threadFooterLabels;
}

function makeThreadAnnotation(state) {
  const threadAnnotationLabels = {
    closed: [...threadComments[0], ...makeThreadFooter("show")],
    open: [...threadComments.flat(), ...makeThreadFooter("close")],
  };
  return {
    id: "a6",
    type: "annotation",
    subject: ["n7", "n8", "n9"],
    c: "rgb(216, 207, 221)",
    borderRadius: 20,
    minWidth: 311,
    minHeight: state === "open" ? 236 : 140,
    connectorStyle: {
      container: "circle",
      subjectEnd: "none",
    },
    b: "black",
    t: threadAnnotationLabels[state],
  };
}

export const threadAnnotations = {
  closed: makeThreadAnnotation("closed"),
  open: makeThreadAnnotation("open"),
};

export const themeAnnotations = {
  light: makeThemeToggleAnnotation("light"),
  dark: makeThemeToggleAnnotation("dark"),
};

const columnXMidPoints = {
  1: 0,
  2: 800,
};

function makeNode(id, x, y, c, b) {
  return {
    id,
    type: "node",
    c,
    b,
    bw: 1,
    x,
    y,
  };
}

function makeLink(id1, id2, c) {
  return {
    id: `${id1}-${id2}`,
    type: "link",
    id1: id1,
    id2: id2,
    c,
  };
}

const items = [
  makeNode("n1", columnXMidPoints[1], 0, "rgb(124, 205, 197)"),
  makeNode("n2", columnXMidPoints[1], 325, "rgb(125, 130, 110)"),
  makeNode("n3", columnXMidPoints[1], 675, "rgb(255, 82, 82)"),
  makeNode("n4", columnXMidPoints[1], 950, "rgb(5, 170, 150)"),
  makeNode("n5", columnXMidPoints[2] - 112.5, 100, "rgb(210, 210, 210)", "rgb(0, 0, 0)"),
  makeNode("n6", columnXMidPoints[2] + 112.5, 100, "rgb(210, 210, 210)", "rgb(0, 0, 0)"),
  makeLink("n5", "n6", "rgb(202, 202, 202)"),
  makeNode("n7", columnXMidPoints[2] - 100, 700, "rgb(216, 207, 221)", "rgb(152, 136, 157)"),
  makeNode("n8", columnXMidPoints[2] + 100, 700, "rgb(216, 207, 221)", "rgb(152, 136, 157)"),
  makeNode("n9", columnXMidPoints[2], 875, "rgb(216, 207, 221)", "rgb(152, 136, 157)"),
  makeLink("n7", "n8", "rgb(160, 151, 164)"),
  makeLink("n7", "n9", "rgb(160, 151, 164)"),
  makeLink("n8", "n9", "rgb(160, 151, 164)"),
  {
    id: "a1",
    type: "annotation",
    subject: ["n1"],
    w: 274,
    h: 55,
    connectorStyle: {
      subjectEnd: "none",
      ls: "dashed",
    },
    bw: 0,
    t: [
      {
        t: "Add a timestamp",
        position: {
          horizontal: "left",
          vertical: "top",
        },
        padding: {
          top: 10,
          bottom: 3,
          left: 10,
        },
      },

      {
        t: "Apr 9, 2024 14:10 BST",
        fs: 9,
        fc: "rgb(80, 80, 80)",
        padding: {
          top: 3,
          right: 10,
          bottom: 10,
          left: 10,
        },
      },
    ],
  },
  {
    id: "a2",
    type: "annotation",
    subject: ["n2"],
    connectorStyle: {
      subjectEnd: "none",
      c: "rgb(125, 130, 110)",
    },
    w: 274,
    h: 62,
    c: "rgb(181, 185, 169)",
    b: "rgb(125, 130, 110)",
    t: [
      {
        fi: { t: icons.pizza },
        fbc: "rgb(255, 255, 255)",
        b: "rgb(241, 241, 241)",
        bw: 2,
        borderRadius: 3,
        padding: 6,
        position: {
          vertical: -12,
          horizontal: 12,
        },
        margin: {
          right: 2,
        },
      },
      {
        fi: { t: icons.shoppingCart },
        fbc: "rgb(255, 255, 255)",
        b: "rgb(241, 241, 241)",
        bw: 2,
        borderRadius: 3,
        padding: 6,
        position: {
          vertical: "inherit",
        },
        margin: {
          left: 2,
        },
      },
      {
        t: "Add icons",
        fb: true,
        position: { horizontal: 14 },
        padding: "6 2 2 0",
      },
      {
        fi: { t: icons.clock, c: "rgb(252, 252, 252)" },
        position: { horizontal: 14 },
        padding: "3 3 0 1",
      },
      {
        t: "Apr 9, 2024 14:10 BST",
        position: { vertical: "inherit" },
        padding: "4 0 0 3",
        textAlignment: { vertical: "middle" },
        fc: "rgb(80, 80, 80)",
        fs: 10,
      },
    ],
  },
  {
    id: "a3",
    type: "annotation",
    subject: ["n3"],
    connectorStyle: {
      c: "rgb(253, 77, 38)",
      subjectEnd: "none",
    },
    w: 200,
    h: 73,
    c: "rgb(254, 167, 154)",
    borderRadius: 3,
    t: [
      {
        fi: { t: icons.messageAlert },
        fbc: "rgb(255, 82, 82)",
        b: "rgb(255, 0, 0)",
        bw: 1,
        borderRadius: 3,
        padding: 6,
        position: {
          horizontal: "left",
          vertical: "top",
        },
        margin: {
          left: 10,
          right: 10,
          top: 16,
        },
      },
      {
        t: "Use annotations to show an alert",
        position: {
          horizontal: 32,
          vertical: "inherit",
        },
        padding: {
          top: 16,
          right: 10,
          bottom: 2,
          left: 10,
        },
      },
      {
        t: "Apr 9, 2024 14:10 BST",
        fs: 10,
        fc: "rgb(80, 80, 80)",
        position: {
          horizontal: 32,
        },
        padding: {
          top: 2,
          right: 10,
          bottom: 10,
          left: 10,
        },
      },
    ],
  },
  {
    id: "a4",
    type: "annotation",
    subject: ["n4"],
    connectorStyle: {
      c: "rgb(255, 0, 255)",
      subjectEnd: "none",
    },
    b: "rgb(255, 0, 255)",
    t: {
      t: "Show and hide your annotation",
    },
    g: [
      {
        p: "ne",
        c: colours.a4.defaultButton,
        b: colours.a4.defaultButton,
        fi: {
          t: icons.x,
          c: "rgb(0, 0, 0)",
        },
      },
    ],
  },
  themeAnnotations.light,
  threadAnnotations.closed,
];

export const itemsMap = {};
items.forEach((item) => (itemsMap[item.id] = item));

export const data = {
  type: "LinkChart",
  items,
};
<!doctype html>
<html lang="en" style="background-color: #2d383f">
  <head>
    <meta charset="utf-8" />
    <title>Advanced Annotation Styles</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="@ci/theme/kl/css/ci-icons.css" />
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div id="klchart" class="klchart"></div>
    <script type="module" src="./code.js"></script>
  </body>
</html>
/* Enable cursor overriding while hovering the chart */
.cursor-pointer canvas {
  cursor: pointer !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.