Search

Advanced Annotation Gallery

Annotations

Create annotations with labels, buttons and controls.

Advanced Annotation Gallery
View live example →

Click on an annotation to see its styling data in the overlay.

For more advanced design needs, ReGraph lets you create annotations with multiple glyphs and labels, font icons, images, custom interactions and interaction styling.

See also

import React, { useRef, useState } from "react";
import { createRoot } from "react-dom/client";

import cloneDeep from "lodash/cloneDeep";
import { Chart } from "regraph";

import {
  colors,
  data,
  dataPositions,
  getShowAnnotationGlyphSvg,
  showAnnotationGlyph,
  showHideAnnotation,
  themeAnnotations,
  threadAnnotations,
} from "./data";

import "@ci/theme/rg/css/properties.css";

function AdvancedAnnotations() {
  const [items, setItems] = useState(data);
  const [animation, setAnimation] = useState();
  const [fit, setFit] = useState("all");
  const [clickedAnnotationId, setClickedAnnotationId] = useState(null);
  const [updatedPositions, setUpdatedPositions] = useState(dataPositions);
  const [chartClasses, setChartClasses] = useState([]);
  const hasLoaded = useRef(false);
  /**
   * 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 'onClick' event
   * - a hover style is specified for each hoverable button and applied via setStyle in
   *   the chart 'onItemInteraction' event
   */
  const interactionInfo = {
    n4: {
      subItemType: "glyph",
      index: {
        0: {
          hoverStyles: {
            image: getShowAnnotationGlyphSvg(colors.a4.hoveredButton),
          },
          onClick: () =>
            setItems({
              ...items,
              a4: showHideAnnotation,
              n4: {
                ...items.n4,
                glyphs: undefined,
              },
            }),
        },
      },
    },
    a4: {
      subItemType: "glyph",
      index: {
        0: {
          hoverStyles: {
            color: colors.a4.hoveredButton,
            border: { color: colors.a4.hoveredButton },
          },
          onClick: () =>
            setItems({
              ...items,
              a4: undefined,
              n4: {
                ...items.n4,
                glyphs: [showAnnotationGlyph],
              },
            }),
        },
      },
    },
    a5: {
      subItemType: "label",
      index: {
        1: {
          hoverStyles: {
            color: "rgb(255, 255, 255)",
            backgroundColor: colors.a5.hoveredButton,
          },
          onClick: () => updateAnnotation("a5", themeAnnotations.light),
        },
        2: {
          hoverStyles: {
            color: "rgb(255, 255, 255)",
            backgroundColor: colors.a5.hoveredButton,
          },
          onClick: () => updateAnnotation("a5", themeAnnotations.dark),
        },
      },
    },
    a6: {
      subItemType: "label",
      index: {
        5: {
          onClick: async () => updateAnnotation("a6", threadAnnotations.open),
        },
        10: {
          onClick: async () => updateAnnotation("a6", threadAnnotations.closed),
        },
      },
    },
  };

  const updateAnnotation = (id, updatedAnnotation) => {
    setItems({
      ...items,
      [id]: updatedAnnotation,
    });
  };

  const handleClick = ({ id, subItem, itemType, preventDefault }) => {
    if (id && itemType === "annotation") {
      setClickedAnnotationId(id);
    } else if (clickedAnnotationId) {
      setClickedAnnotationId(null);
    }
    if (subItem?.type === "label" || subItem?.type === "glyph") {
      const subItemIndex = subItem.index;
      if (interactionInfo[id] && interactionInfo[id].index[subItemIndex]) {
        interactionInfo[id].index[subItemIndex].onClick();
        preventDefault();
      }
    }
  };

  const handleItemInteraction = async ({ id, subItem, setStyle, hovered }) => {
    // If we are hovering over a hoverable button apply a styling update
    if (hovered && subItem && interactionInfo?.[id]?.index[subItem?.index]?.hoverStyles) {
      const hoverStyle = getHoverStyle(id, subItem.type, subItem.index);
      setStyle({
        [id]: hoverStyle,
      });
    }
  };

  const handleHover = async ({ id, subItem }) => {
    const isClickableButton = interactionInfo[id]?.index?.[subItem?.index]?.onClick !== undefined;

    // // On a hover interaction over a subItem button, append a class to the chart to style the cursor appropriately
    setChartClasses((current) =>
      isClickableButton
        ? [...current, "cursor-pointer"]
        : current.filter((className) => !className.startsWith("cursor-"))
    );
  };

  const handleChange = async ({ annotationPositions }) => {
    // Disable animation after the initial chart load
    if (!hasLoaded.current) {
      hasLoaded.current = true;
      setAnimation({
        animate: false,
      });
      setFit("none");
    }
    if (annotationPositions) {
      // Track annotation position changes so we can display them to the user
      setUpdatedPositions((current) => ({
        ...current,
        annotations: annotationPositions,
      }));
    }
  };

  const getHoverStyle = (id, subItemType, subItemIndex) => {
    const subItemPropName = subItemType === "label" ? subItemType : "glyphs";
    const subItem = cloneDeep(items[id][subItemPropName]);
    if (
      subItem &&
      (!colors[id]?.selectedButton ||
        subItem[subItemIndex].backgroundColor !== colors[id].selectedButton)
    ) {
      subItem[subItemIndex] = interactionInfo[id].index[subItemIndex].hoverStyles;
      return {
        [subItemPropName]: subItem,
      };
    }
    return null;
  };

  return (
    <div style={{ width: "100%", height: "100%" }} className={chartClasses.join(" ")}>
      <Chart
        items={items}
        positions={dataPositions.nodes}
        onClick={handleClick}
        onChange={handleChange}
        onItemInteraction={handleItemInteraction}
        onHover={handleHover}
        options={{
          navigation: false,
          overview: false,
          backgroundColor: "rgb(231, 234, 224)",
          selection: false,
          hoverDelay: 10,
          fit,
          zoom: {
            adaptiveStyling: false,
          },
          labels: {
            legacyGlyphAndLabelOrdering: false,
          },
          iconFontFamily: "Material Icons",
          imageAlignment: {
            ["close"]: { size: 1.4 },
          },
        }}
        animation={animation}
      />
      {clickedAnnotationId === null || items[clickedAnnotationId] === undefined ? (
        <pre className="properties annotations empty">
          Click an annotation to see its properties
        </pre>
      ) : (
        <pre className="properties annotations">
          {JSON.stringify(
            {
              items: {
                [clickedAnnotationId]: {
                  ...items[clickedAnnotationId],
                },
              },
              annotationPositions: updatedPositions.annotations[clickedAnnotationId],
            },
            null,
            2
          )}
        </pre>
      )}
    </div>
  );
}

const FontReadyChart = React.lazy(() =>
  document.fonts.load("24px 'Material Icons").then(() => ({
    default: AdvancedAnnotations,
  }))
);

export function Demo() {
  return (
    <React.Suspense fallback="">
      <FontReadyChart />
    </React.Suspense>
  );
}

const root = createRoot(document.getElementById("regraph"));
root.render(<Demo />);
const showAnnotationGlyphColors = {
  defaultButton: "rgb(96, 109, 123)",
  hoveredButton: "rgb(36, 44, 50)",
};

export const colors = {
  a4: showAnnotationGlyphColors,
  n4: showAnnotationGlyphColors,
  a5: {
    light: {
      background: "rgb(255, 255, 255)",
      primaryText: "rgb(0, 0, 0)",
      secondaryText: "rgb(255, 255, 255)",
    },
    dark: {
      background: "rgb(46, 56, 66)",
      primaryText: "rgb(255, 255, 255)",
      secondaryText: "rgb(0, 0, 0)",
    },
    selectedButton: "rgb(4, 129, 112)",
    defaultButton: "rgb(245, 247, 250)",
    hoveredButton: "rgb(0, 66, 62)",
  },
};

export const dataPositions = {
  nodes: {},
};

export const showAnnotationGlyph = {
  position: "ne",
  color: "transparent",
  border: {
    color: "transparent",
  },
  image: getShowAnnotationGlyphSvg(showAnnotationGlyphColors.defaultButton),
  size: 1.6,
};

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

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

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

export const showHideAnnotation = {
  subject: "n4",
  connectorStyle: {
    color: "rgba(36, 44, 50, 1)",
    subjectEnd: "none",
  },
  border: {
    color: "rgba(36, 44, 50, 1)",
  },
  label: {
    text: "Show and hide your annotation",
  },
  glyphs: [
    {
      position: "ne",
      color: colors.a4.defaultButton,
      border: {
        color: colors.a4.defaultButton,
      },
      fontIcon: {
        text: "close",
        color: "rgb(255, 255, 255)",
      },
    },
  ],
};

// Retrieves an inline SVG with a specified color
export function getShowAnnotationGlyphSvg(color) {
  return `data:image/svg+xml;base64,<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50 0C22.5 0 2.6077e-07 22.5 2.6077e-07 50C2.6077e-07 61.5 4 72.5 11.5 81.5L1.5 91.5C-0.5 93.5 -0.5 96.5 1.5 98.5C2.5 99.5 3.5 100 5
 100H50C77.5 100 100 77.5 100 50C100 22.5 77.5 0 50 0ZM30 55C27 55 25 53 25 50C25 47 27 45 30 45C33 45 35 47 35 50C35 53 33 55 30 55ZM50 55C47
  55 45 53 45 50C45 47 47 45 50 45C53 45 55 47 55 50C55 53 53 55 50 55ZM70 55C67 55 65 53 65 50C65 47 67 45 70 45C73 45 75 47 75 50C75 53 73 55 
  70 55Z" fill="${color}"/></svg>`;
}

function makeThemeToggleAnnotation(theme) {
  const selectedThemeColors = colors.a5[theme];
  const backgroundColour = selectedThemeColors.background;
  const lightTheme = theme === "light";
  const lightButtonColour = lightTheme ? colors.a5.selectedButton : colors.a5.defaultButton;
  const darkButtonColour = lightTheme ? colors.a5.defaultButton : colors.a5.selectedButton;
  const buttonPadding = [6, 40, 6, 40];
  return {
    subject: ["n5", "n6"],
    shape: {
      width: 324,
      height: 74,
    },
    color: backgroundColour,
    border: {
      radius: 16,
      width: 0,
    },
    connectorStyle: {
      container: "rectangle",
      subjectEnd: "none",
    },
    label: [
      {
        text: "Add buttons to your annotation",
        backgroundColor: "transparent",
        color: selectedThemeColors.primaryText,
        position: {
          horizontal: "centre",
          vertical: "top",
        },
        padding: {
          top: 10,
          bottom: 8,
        },
      },
      {
        text: "Light mode",
        backgroundColor: lightButtonColour,
        color: selectedThemeColors.secondaryText,
        border: {
          radius: [20, 0, 0, 20],
        },
        padding: buttonPadding,
      },
      {
        text: "Dark mode",
        backgroundColor: darkButtonColour,
        color: selectedThemeColors.primaryText,
        border: {
          radius: [0, 20, 20, 0],
        },
        padding: buttonPadding,
        position: {
          vertical: "inherit",
        },
      },
    ],
  };
}

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

function makeThreadFooter(actionName) {
  const threadFooterLabels = [
    {
      text: actionName,
      position: {
        horizontal: "right",
        vertical: "inherit",
      },
      margin: {
        top: 2,
        right: 16,
      },
    },
    {
      fontSize: 0,
      backgroundColor: "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({
      text: "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 {
    subject: ["n7", "n8", "n9"],
    connectorStyle: {
      container: "circle",
      subjectEnd: "none",
    },
    shape: {
      minWidth: 311,
      minHeight: state === "open" ? 236 : 140,
    },
    color: colors.a5.light.background,
    border: {
      radius: 20,
      color: "rgb(0, 0, 0)",
    },
    label: threadAnnotationLabels[state],
  };
}

function makeNode(id, x, y, color, borderColor) {
  dataPositions.nodes[id] = {
    x,
    y,
  };
  return {
    [id]: {
      id,
      color,
      border: {
        color: borderColor,
        width: 1,
      },
    },
  };
}

function makeLink(id1, id2, color) {
  return {
    [`${id1}-${id2}`]: {
      type: "link",
      id1,
      id2,
      color,
    },
  };
}

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

export const data = {
  ...makeNode("n1", columnXMidPoints[1], 0, "rgb(50, 152, 138)"),
  ...makeNode("n2", columnXMidPoints[1], 325, "rgb(30, 93, 220)"),
  ...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(158, 189, 250)", "rgb(102, 153, 255)"),
  ...makeNode("n8", columnXMidPoints[2] + 100, 700, "rgb(158, 189, 250)", "rgb(102, 153, 255)"),
  ...makeNode("n9", columnXMidPoints[2], 875, "rgb(158, 189, 250)", "rgb(102, 153, 255)"),
  ...makeLink("n7", "n8", "rgb(102, 153, 255)"),
  ...makeLink("n7", "n9", "rgb(102, 153, 255)"),
  ...makeLink("n8", "n9", "rgb(102, 153, 255)"),
  a1: {
    subject: "n1",
    shape: {
      width: 274,
      height: 55,
    },
    connectorStyle: {
      subjectEnd: "none",
      ls: "dashed",
    },
    border: {
      width: 0,
    },
    label: [
      {
        text: "Add a timestamp",
        position: {
          horizontal: "left",
          vertical: "top",
        },
        padding: {
          top: 10,
          bottom: 3,
          left: 10,
        },
      },
      {
        text: "Apr 9, 2024 14:10 BST",
        fontSize: 9,
        color: "rgb(80, 80, 80)",
        padding: {
          top: 3,
          right: 10,
          bottom: 10,
          left: 10,
        },
      },
    ],
  },
  a2: {
    subject: "n2",
    connectorStyle: {
      subjectEnd: "none",
      color: "rgb(30, 93, 220)",
    },
    shape: {
      width: 274,
      height: 62,
    },
    color: "rgb(158, 189, 250)",
    border: {
      color: "rgb(30, 93, 220)",
    },
    label: [
      {
        fontIcon: { text: "local_pizza", scale: 4 },
        fontSize: 16,
        backgroundColor: "rgb(255, 255, 255)",
        border: {
          color: "rgb(241, 241, 241)",
          width: 2,
          radius: 3,
        },
        padding: 6,
        position: {
          vertical: -12,
          horizontal: 16,
        },
        margin: {
          right: 2,
        },
      },
      {
        fontIcon: { text: "trolley" },
        fontSize: 16,
        backgroundColor: "rgb(255, 255, 255)",
        border: {
          color: "rgb(241, 241, 241)",
          width: 2,
          radius: 3,
        },
        padding: 6,
        position: {
          vertical: "inherit",
        },
        margin: {
          left: 2,
        },
      },
      {
        text: "Add icons",
        bold: true,
        position: { horizontal: 14 },
        padding: "7 2 4 0",
      },
      {
        fontIcon: { text: "watch_later", color: "rgb(252, 252, 252)" },
        fontSize: 16,
        position: { horizontal: 14 },
        padding: "1 3 0 1",
      },
      {
        text: "Apr 9, 2024 14:10 BST",
        position: { vertical: "inherit" },
        padding: "3 0 0 3",
        textAlignment: { vertical: "middle" },
        color: "rgb(80, 80, 80)",
        fontSize: 10,
      },
    ],
  },
  a3: {
    subject: "n3",
    connectorStyle: {
      color: "rgb(253, 77, 38)",
      subjectEnd: "none",
    },
    shape: {
      width: 200,
      height: 73,
    },
    color: "rgb(254, 167, 154)",
    borderRadius: 3,
    label: [
      {
        fontIcon: { text: "mark_email_unread" },
        fontSize: 16,
        backgroundColor: "rgb(255, 82, 82)",
        border: {
          color: "rgb(255, 0, 0)",
          width: 1,
          radius: 3,
        },
        padding: 6,
        position: {
          horizontal: "left",
          vertical: "top",
        },
        margin: {
          left: 10,
          right: 10,
          top: 16,
        },
      },
      {
        text: "Use annotations to show an alert",
        position: {
          horizontal: 32,
          vertical: "inherit",
        },
        padding: {
          top: 16,
          right: 10,
          bottom: 2,
          left: 10,
        },
      },
      {
        text: "Apr 9, 2024 14:10 BST",
        fontSize: 10,
        color: "rgb(80, 80, 80)",
        position: {
          horizontal: 32,
        },
        padding: {
          top: 2,
          right: 10,
          bottom: 10,
          left: 10,
        },
      },
    ],
  },
  a4: showHideAnnotation,
  a5: themeAnnotations.light,
  a6: threadAnnotations.closed,
};
<!doctype html>
<html>
  <head>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div id="regraph" style="height: 100vh"></div>
    <script type="module" src="./code.jsx"></script>
  </body>
</html>
/* Import for Google's Material Icons */
@import url("https://fonts.googleapis.com/icon?family=Material+Icons");

.cursor-pointer canvas {
  cursor: pointer !important;
}

pre.properties.annotations {
  max-width: 50%;
}

pre.properties.annotations.empty {
  min-width: 350px;
}

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.