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;
}