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:
- chart.export
- chart.labelPosition
- chart.serialize
- chart.viewCoordinates
- chart.getWorldAnnotationPosition
- chart.getRelativeAnnotationPosition
- Annotations API
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;
}