Search

User-Created Annotations

Annotations

Let chart users annotate nodes with interactive annotations.

User-Created Annotations
View live example →

Select a node and click the Add button to create an annotation.

Each annotation has an Edit button to edit the text or delete the annotation.

This story uses a HTML overlay to allow creation and editing of the annotation text, and uses labels to create interactive elements on the annotations themselves.

See also

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

import { Chart } from "regraph";

import { colors, createAnnotation, icons, isNode, items } from "./data";

const editLabelIndex = 2;
const layout = {
  name: "sequential",
  level: "level",
  tightness: 0,
  stretch: 0.54,
  animate: false,
};
const defaultOverlayState = {
  annotationPreviewText: null,
  annotationPreviewPosition: null,
  annotationPreviewPositionSynced: false,
  annotateButtonPosition: null,
};

function AnnotateButton({ annotate, position }) {
  return (
    <button
      type="button"
      className="button"
      id="annotate-button"
      onClick={annotate}
      style={{
        top: position.top,
        left: position.left,
      }}
      aria-label="Annotate"
    >
      <i>
        <img src="/img/annotate.svg" alt="Annotate" />
      </i>
    </button>
  );
}

function AnnotationPreview({
  position,
  text,
  actions: { updateAnnotationTextAndHeight, finalizeAnnotation },
}) {
  const [previewText, setPreviewText] = useState(text ?? "");
  const textMeasurementSpanRef = useRef(null);
  const textAreaRef = useRef(null);
  const previewDivRef = useRef(null);

  const onAnnotationInput = (e) => {
    const textInput = e.target.value;
    e.target.style.height = "auto";
    e.target.style.height = `${e.target.scrollHeight}px`;
    setPreviewText(textInput);
  };
  const onKeyDown = ({ key, nativeEvent }) => {
    if (key === "Enter") {
      // prevent new lines being added to our editor
      nativeEvent.preventDefault();
    }
  };
  const onKeyUp = ({ key }) => {
    if (key === "Enter" || key === "Escape") {
      finalizeAnnotation("dismiss");
    }
  };
  const formatAnnotationInputText = (str) => {
    let formattedText = "";
    const words = str.split(" ");
    words.forEach((word) => {
      textMeasurementSpanRef.current.textContent = word;
      /* Use the hidden 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 (
        textMeasurementSpanRef.current.getBoundingClientRect().width >
        textAreaRef.current.clientWidth
      ) {
        formattedText += `${wrapLongWord(word)} `;
      } else {
        formattedText += `${word} `;
      }
    });
    return formattedText.trim();
  };

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

  useEffect(() => {
    if (textAreaRef.current) {
      // focus the cursor at the end of the existing annotation text
      const length = textAreaRef.current.value.length;
      textAreaRef.current.focus();
      textAreaRef.current.setSelectionRange(length, length);
      // size the annotation preview to fit the existing text
      textAreaRef.current.style.height = "auto";
      textAreaRef.current.style.height = `${textAreaRef.current.scrollHeight}px`;
    }
  }, []);

  /* update the actual annotation on text area input so we can
     position the preview correctly as the annotation changes size */
  useEffect(() => {
    if (previewDivRef.current) {
      const cleanTextInput = previewText.replace(/\n/g, "").trim();
      // Format text so long words are wrapped correctly before updating chart annotation text
      const formattedText = formatAnnotationInputText(cleanTextInput);
      updateAnnotationTextAndHeight(formattedText, previewDivRef.current.offsetHeight);
    }
  }, [previewText]);

  return (
    <>
      <div
        id="annotation-preview"
        style={{ top: position?.top, left: position?.left }}
        ref={previewDivRef}
      >
        <div id="annotation-upper">
          <textarea
            placeholder="Add comment"
            defaultValue={text ?? ""}
            id="annotation-text"
            rows={1}
            onInput={onAnnotationInput}
            ref={textAreaRef}
            spellCheck={false}
            wrap="hard"
            onKeyDown={onKeyDown}
            onKeyUp={onKeyUp}
          />
          {text && (
            <button
              type="button"
              className="button"
              id="ghost-done-button"
              aria-label="Placeholder"
              disabled
            >
              <i className="material-icons">{icons.done}</i>
            </button>
          )}
        </div>
        {previewText && (
          <div id="annotation-toolbar">
            <div id="edit-controls">
              <button
                type="button"
                className="button"
                id="delete-button"
                aria-label="Delete"
                onClick={() => finalizeAnnotation("delete")}
                onContextMenu={(e) => e.preventDefault()}
              >
                <i className="material-icons">{icons.delete}</i>
              </button>
              <button
                type="button"
                className="button"
                id="done-button"
                aria-label="Done"
                onClick={() => finalizeAnnotation("dismiss")}
                onContextMenu={(e) => e.preventDefault()}
              >
                <i className="material-icons">{icons.done}</i>
              </button>
            </div>
          </div>
        )}
      </div>
      <span id="text-measure" ref={textMeasurementSpanRef} />
    </>
  );
}

function UserCreatedAnnotations() {
  const [state, setState] = useState({
    items: items,
    selectedNodeIds: [],
    annotateButtonPosition: null,
    annotationBeingUpdatedId: null,
    animation: {
      animate: true,
      time: 300,
    },
    selection: undefined,
  });
  const [overlayState, setOverlayState] = useState(defaultOverlayState);
  const [chartClasses, setChartClasses] = useState([]);
  const chartContainerRef = useRef(null);
  const chartRef = useRef(null);
  const hasLoaded = useRef(false);
  const annotationCount = useRef(1);
  const zoom = useRef();
  const canvasElRef = useRef(null);

  useEffect(() => {
    // get the canvas element (ReGraph doesn't expose the canvas element so we can't forward the ref)
    const canvasEl = document.querySelector("canvas");
    if (canvasEl) {
      canvasElRef.current = canvasEl;
    }
  }, []);

  useEffect(() => {
    const handleResize = () => finalizeAnnotation();
    window.addEventListener("resize", handleResize);
    // clean up the resize event listener
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, [state.annotationBeingUpdatedId]);

  // Update the position of the annotate button upon node selection cahnge
  useEffect(() => {
    updateAnnotateButtonPosition();
  }, [state.selectedNodeIds]);

  // When we clear selection manually give selection
  // control back to ReGraph by setting the prop to undefined
  useEffect(() => {
    setState((current) => ({
      ...current,
      selection: undefined,
    }));
  }, [state.selection]);

  const updateAnnotateButtonPosition = () => {
    const selectedNodeCount = state.selectedNodeIds.length;
    let annotateButtonPosition = null;

    // Hide the annotate button when no nodes are selected
    if (selectedNodeCount > 0) {
      const mostRecentlySelectedNodeId = state.selectedNodeIds[state.selectedNodeIds.length - 1];
      const nodeWithAnnotateButton = state.items[mostRecentlySelectedNodeId];
      const nodeSizeMultiplier = nodeWithAnnotateButton.size || 1;
      const nodeSize = 24 * nodeSizeMultiplier * zoom.current;
      const itemViewCoords = chartRef.current?.getViewCoordinatesOfItem(mostRecentlySelectedNodeId);

      // Hide the annotate button when the attached node isn't in view
      annotateButtonPosition =
        itemViewCoords !== undefined
          ? {
              top: `${itemViewCoords.y - nodeSize - 27}px`,
              left: `${itemViewCoords.x + nodeSize}px`,
            }
          : null;
    }
    setOverlayState((current) => {
      return {
        ...current,
        annotateButtonPosition,
      };
    });
  };

  const annotate = () => {
    const subject = state.selectedNodeIds;
    const annotationId = `annotation-${++annotationCount.current}`;
    const annotation = createAnnotation(subject, "", {
      width: 274,
      height: 46,
    });
    // Hide the overlay button
    setOverlayState((current) => ({
      ...current,
      annotateButtonPosition: null,
    }));
    // Add the new annotation to the chart
    setState((current) => ({
      ...current,
      annotationBeingUpdatedId: annotationId,
      items: {
        ...current.items,
        [annotationId]: annotation,
      },
      selection: [],
    }));
  };

  const getAnnotationBeingUpdated = () => {
    if (state.annotationBeingUpdatedId) {
      return state.items[state.annotationBeingUpdatedId];
    }
    return null;
  };

  const updateAnnotationTextAndHeight = (text, height) => {
    const annotationBeingUpdated = getAnnotationBeingUpdated();
    if (annotationBeingUpdated === null) return;
    const labels = [...annotationBeingUpdated.label];
    // Update the content label's text
    labels[1] = {
      ...labels[1],
      text,
    };
    setState((current) => ({
      ...current,
      items: {
        ...current.items,
        [state.annotationBeingUpdatedId]: {
          ...annotationBeingUpdated,
          shape: {
            ...annotationBeingUpdated.shape,
            height,
          },
          label: labels,
        },
      },
    }));
    setOverlayState((current) => ({
      ...current,
      annotationPreviewText: text,
      // re-place the preview overlay when we update the text / height
      annotationPreviewPositionSynced: false,
    }));
  };

  const finalizeAnnotation = (action) => {
    const annotation = getAnnotationBeingUpdated();
    if (annotation === null) return;
    const updatedItems = {
      ...state.items,
    };
    if (action === "delete" || !overlayState.annotationPreviewText) {
      delete updatedItems[state.annotationBeingUpdatedId];
    }
    setState((current) => ({
      ...current,
      annotationBeingUpdatedId: null,
      items: updatedItems,
      selectedNodeIds: [],
      selection: [],
    }));
    setTimeout(() => setOverlayState(defaultOverlayState), 1);
  };

  const editAnnotation = (annotationId) => {
    const annotation = state.items[annotationId];

    // Get the details of the annotation we are editing so we can pre-fill the overlay text / container
    const { label } = annotation;
    setState((current) => ({
      ...current,
      annotationBeingUpdatedId: annotationId,
      selection: [],
    }));
    setOverlayState((current) => ({
      ...current,
      annotationPreviewText: label[1].text,
      // hide the annotate button if we are showing it
      annotateButtonPosition: null,
    }));
  };

  const isAnnotateButtonVisible = () =>
    state.selectedNodeIds.length > 0 && state.annotationBeingUpdatedId === null;

  const onClickHandler = (e) => {
    if (state.annotationBeingUpdatedId !== null) {
      e.preventDefault();
      finalizeAnnotation();
    }
    // Edit annotation
    else if (e.id && e.itemType === "annotation" && e.subItem?.index === editLabelIndex) {
      editAnnotation(e.id);
    }
  };

  const onDragHandler = () => {
    if (isAnnotateButtonVisible()) {
      updateAnnotateButtonPosition();
    } else if (state.annotationBeingUpdatedId !== null) {
      finalizeAnnotation();
    }
  };

  const onViewChangeHandler = (viewChange) => {
    if (state.annotationBeingUpdatedId !== null) {
      finalizeAnnotation();
    }
    if (isAnnotateButtonVisible()) {
      setTimeout(updateAnnotateButtonPosition, 1);
    }
    if ("zoom" in viewChange && viewChange.zoom !== zoom.current) {
      if (zoom.current) {
        zoom.current = viewChange.zoom;
      }
      if (state.annotationBeingUpdatedId !== null) {
        setOverlayState((current) => ({
          ...current,
          annotationPreviewPositionSynced: false,
        }));
      }
    }
  };

  const onItemInteractionHandler = ({ id, itemType, selected, hovered, subItem, setStyle }) => {
    if (itemType === "node" && selected) {
      setStyle({
        [id]: {
          color: colors.selectedNode,
        },
      });
    }
    const hoveringEditAnnotationLabel =
      itemType === "annotation" && hovered && subItem?.index === 2;
    if (hoveringEditAnnotationLabel) {
      setStyle({
        [id]: {
          label: [{}, {}, { backgroundColor: colors.hoveredEditLabel }],
        },
      });
    }
  };

  const onHoverHandler = ({ id, subItem }) => {
    const hoveringEditAnnotationLabel = id && subItem?.index === editLabelIndex;
    setChartClasses((current) =>
      hoveringEditAnnotationLabel
        ? [...current, "cursor-pointer"]
        : current.filter((className) => !className.startsWith("cursor-"))
    );
  };

  const onChangeHandler = ({ selection, view }) => {
    if (selection) {
      // Only nodes can be selected as subjects for annotations in this story
      const selectedNodeIds = Object.entries(selection)
        .filter(([, item]) => isNode(item))
        .map(([id]) => id);
      setState((current) => ({
        ...current,
        selectedNodeIds,
      }));
    }

    if (view) {
      // Update the zoom level so we can position the annotate button appropriately
      zoom.current = view.zoom;
    }

    if (!hasLoaded.current) {
      setState((current) => ({
        ...current,
        animation: {
          ...current.animation,
          animate: false,
        },
      }));
    }
  };

  const isAnnotationOutOfChartBounds = (annotationPosition) => {
    if (chartContainerRef.current === null) return null;
    const chartContainerBounds = chartContainerRef.current.getBoundingClientRect();
    const outOfVerticalBounds =
      annotationPosition.y1 <= 0 || annotationPosition.y2 >= chartContainerBounds.height;
    const outOfHorizontalBounds =
      annotationPosition.x1 <= 0 || annotationPosition.x2 >= chartContainerBounds.width;
    return outOfVerticalBounds || outOfHorizontalBounds;
  };

  const getAnnotationPreviewPosition = (annotationPosition) => {
    return {
      top: `${annotationPosition.y1}px`,
      left: `${annotationPosition.x1}px`,
    };
  };

  const onUpdateCompleteHandler = async () => {
    if (chartRef.current === null || chartContainerRef.current === null) return;
    if (state.annotationBeingUpdatedId && overlayState.annotationPreviewPositionSynced === false) {
      const annotationPosition = chartRef.current.labelPosition(state.annotationBeingUpdatedId, 0);
      if (annotationPosition) {
        const annotationIsOutOfBounds = isAnnotationOutOfChartBounds(annotationPosition);
        // // Update the preview position
        setOverlayState((current) => ({
          ...current,
          annotationPreviewPosition: getAnnotationPreviewPosition(annotationPosition),
          annotationPreviewPositionSynced: true,
        }));
      }
    }
  };

  return (
    <div id="user-created-annotations" className={chartClasses.join(" ")} ref={chartContainerRef}>
      <div className="overlay">
        {overlayState.annotateButtonPosition !== null && (
          <AnnotateButton position={overlayState.annotateButtonPosition} annotate={annotate} />
        )}
        {overlayState.annotationPreviewPosition && (
          <AnnotationPreview
            position={overlayState.annotationPreviewPosition}
            text={overlayState.annotationPreviewText}
            actions={{ updateAnnotationTextAndHeight, finalizeAnnotation }}
          />
        )}
      </div>
      <Chart
        ref={chartRef}
        items={state.items}
        layout={layout}
        onClick={onClickHandler}
        onDrag={onDragHandler}
        onViewChange={onViewChangeHandler}
        onChange={onChangeHandler}
        onUpdateComplete={onUpdateCompleteHandler}
        onItemInteraction={onItemInteractionHandler}
        onHover={onHoverHandler}
        options={{
          navigation: false,
          overview: false,
          backgroundColor: "rgb(231, 234, 224)",
          hoverDelay: 10,
          zoom: {
            adaptiveStyling: false,
          },
          labels: {
            fontFamily: "Muli",
            maxLength: 20,
          },
          iconFontFamily: "Material Icons",
          selection: {},
          fit: "none",
        }}
        animation={state.animation}
        selection={state.selection}
      />
    </div>
  );
}

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

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

const root = createRoot(document.getElementById("regraph"));
root.render(<Demo />);
export function createAnnotation(subject, text, shape = BASE_ANNOTATION.shape) {
  const labels = BASE_ANNOTATION.label.map((label) => ({ ...label }));
  if (text) {
    const textContentLabelIndex = 1;
    labels[textContentLabelIndex].text = text;
  }
  return {
    ...BASE_ANNOTATION,
    subject,
    label: labels,
    shape,
  };
}

export function isNode(item) {
  return item && !item.id1 && !item.id2 && !item.subject;
}

export const icons = {
  pencilEdit: "drive_file_rename_outline",
  done: "check",
  delete: "delete_outline",
};

export const colors = {
  defaultNode: "rgb(30, 93, 220)",
  selectedNode: "rgb(102, 153, 255)",
  defaultLink: "rgb(202, 202, 202)",
  defaultEditLabel: "rgba(254, 167, 154, 0.3)",
  hoveredEditLabel: "rgb(254, 167, 154)",
  annotationConnector: "rgb(241, 93, 91)",
};

export const BASE_ANNOTATION = {
  connectorStyle: {
    color: colors.annotationConnector,
  },
  color: "rgb(255, 255, 255)",
  shape: {
    width: 274,
    height: 46,
  },
  border: {
    radius: 3,
    color: "rgb(201, 218, 252)",
  },
  label: [
    {
      backgroundColor: "transparent",
      minWidth: "stretch",
      minHeight: "stretch",
      position: { vertical: "middle", horizontal: "center" },
      textWrap: "normal",
    },
    {
      color: "rgb(0, 0, 0)",
      textAlignment: {
        horizontal: "left",
      },
      bold: false,
      position: {
        vertical: "top",
        horizontal: "left",
      },
      padding: "16 12 12 12",
      textWrap: "normal",
      maxHeight: 2000,
      maxWidth: 274,
    },
    {
      fontIcon: {
        text: icons.pencilEdit,
        color: "rgb(0, 0, 0)",
      },
      fontSize: 16,
      backgroundColor: colors.defaultEditLabel,
      position: {
        horizontal: "right",
        vertical: "bottom",
      },
      padding: 7,
      margin: 8,
      border: {
        radius: 6,
      },
    },
  ],
};

export const items = {
  n1: {
    color: colors.defaultNode,
    data: {
      level: 1,
    },
  },
  n2: {
    color: colors.defaultNode,
    data: {
      level: 1,
    },
  },
  n3: {
    color: colors.defaultNode,
    data: {
      level: 2,
    },
  },
  "n1-n2": {
    color: colors.defaultLink,
    id1: "n1",
    id2: "n2",
  },
  "n2-n3": {
    color: colors.defaultLink,
    id1: "n2",
    id2: "n3",
  },
  "n1-n3": {
    color: colors.defaultLink,
    id1: "n1",
    id2: "n3",
  },
  a1: createAnnotation(["n2"], "Select a node to create an annotation", {
    width: 274,
    height: 73,
  }),
};

export const annotationPositions = {
  a1: {
    distance: 80,
  },
};
<!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");

#user-created-annotations {
  display: flex;
  flex: 1;
  overflow: hidden;
  position: relative;
  height: 100%;
}

#user-created-annotations .overlay {
  position: absolute;
  width: 100%;
}

/* Common base style applied across the buttons */
#user-created-annotations .button {
  border: none;
  cursor: pointer;
  font-family: "Muli", sans-serif;
  margin: 0px;
  background-color: rgb(241, 93, 91);
  &:hover {
    background-color: rgb(254, 167, 154);
  }
}

#annotate-button {
  position: absolute;
  z-index: 399;
  width: 27px;
  height: 27px;
  border-radius: 20px 20px 20px 0px;
  padding: 2px 0px 0px 0px;
}

#annotation-preview {
  display: 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;
}

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

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

#ghost-done-button,
#edit-controls > button {
  width: 26px;
  height: 26px;
  border-radius: 3px;
  border: none;
  align-items: center;
  justify-content: center;
  display: flex;
}

/* Place holder disabled button for when we first
   create an empty annotation */
#ghost-done-button {
  padding: 0px;
  background-color: #213d450d;
  cursor: auto;
  &:disabled > span {
    color: black !important;
  }
}

#annotation-toolbar {
  display: flex;
  justify-content: flex-end;
}

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

#edit-controls > button {
  cursor: pointer;
}

#delete-button {
  background-color: rgba(254, 167, 154, 0.3);
  &:hover {
    background-color: rgb(254, 167, 154);
  }
}

#user-created-annotations .material-icons {
  font-size: 16px;
}

/* Hidden span elemenet to check whether we need to add newlines to annotation text */
#user-created-annotations #text-measure {
  font-family: "Muli", sans-serif;
  line-height: 15px;
  font-size: 14px;
  padding: 2px;
  visibility: hidden;
}

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.