Skip to content

Commit

Permalink
Add renderNode and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
kmcginnes committed Apr 12, 2024
1 parent 800ac88 commit 8c2698d
Show file tree
Hide file tree
Showing 8 changed files with 315 additions and 26 deletions.
1 change: 1 addition & 0 deletions packages/graph-explorer/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const config: Config = {
"src/App.ts",
"src/setupTests.ts",
],
setupFiles: ["<rootDir>/setupTests.ts"],
coverageProvider: "v8",
};

Expand Down
5 changes: 3 additions & 2 deletions packages/graph-explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"recoil": "^0.7.7",
"swiper": "^8.4.7",
"use-deep-compare-effect": "^1.8.1",
"uuid": "^8.3.2"
"uuid": "^9.0.1"
},
"devDependencies": {
"@babel/core": "^7.23.2",
Expand Down Expand Up @@ -149,7 +149,8 @@
"type-fest": "^2.19.0",
"typescript": "^4.9.5",
"vite": "^4.5.3",
"webpack": "^5.76.0"
"webpack": "^5.76.0",
"whatwg-fetch": "^3.6.20"
},
"overrides": {
"json5@>=2.0.0 <2.2.2": "2.2.2",
Expand Down
191 changes: 191 additions & 0 deletions packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* @jest-environment jsdom
*/

import { describe, it, expect, jest } from "@jest/globals";
import { ICONS_CACHE, VertexIconConfig, renderNode } from "./renderNode";
import {
createRandomColor,
createRandomName,
} from "../../utils/testing/randomData";

global.fetch =
jest.fn<
(
input: RequestInfo | URL,
init?: RequestInit | undefined
) => Promise<Response>
>();

describe("renderNode", () => {
beforeEach(() => {
ICONS_CACHE.clear();
jest.resetAllMocks();
});

it("should return undefined given no icon url", async () => {
const mockedFetch = jest.mocked(global.fetch);
const node: VertexIconConfig = {
type: createRandomName("vertex"),
color: createRandomColor(),
iconUrl: undefined,
iconImageType: "image/svg+xml",
};

const result = await renderNode(node);

expect(result).toBeUndefined();
expect(mockedFetch).not.toBeCalled();
expect(ICONS_CACHE.size).toEqual(0);
});

it("should return undefined when error occurs in fetch", async () => {
const mockedFetch = jest
.mocked(global.fetch)
.mockRejectedValue(new Error("Failed"));
const node: VertexIconConfig = {
type: createRandomName("vertex"),
color: createRandomColor(),
iconUrl: createRandomName("iconUrl"),
iconImageType: "image/svg+xml",
};

const result = await renderNode(node);

expect(mockedFetch).toBeCalledWith(node.iconUrl);
expect(result).toBeUndefined();
expect(ICONS_CACHE.size).toEqual(0);
});

it("should return icon url given image type is not an SVG", async () => {
const mockedFetch = jest.mocked(global.fetch);
const node: VertexIconConfig = {
type: createRandomName("vertex"),
color: createRandomColor(),
iconUrl: createRandomName("iconUrl"),
iconImageType: "image/png",
};

const result = await renderNode(node);

expect(result).toBe(node.iconUrl);
expect(mockedFetch).not.toBeCalled();
expect(ICONS_CACHE.size).toEqual(0);
});

it("should return processed SVG string keeping original color", async () => {
const originalColor = createRandomColor();
const svgContent = `<svg fill="${originalColor}" xmlns="http://www.w3.org/2000/svg"/>`;
const mockedFetch = jest
.mocked(global.fetch)
.mockResolvedValue(new Response(new Blob([svgContent])));
const node: VertexIconConfig = {
type: createRandomName("vertex"),
color: createRandomColor(),
iconUrl: createRandomName("iconUrl"),
iconImageType: "image/svg+xml",
};

const result = await renderNode(node);

expect(mockedFetch).toBeCalledWith(node.iconUrl);
expect(result).toBeDefined();
expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,");
const decodedResult = decodeSvg(result);
expect(decodedResult).toEqual(
wrapExpectedSvg(
`<svg fill="${originalColor}" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"/>`
)
);
});

it("should return processed SVG string replacing currentColor with default color when custom color not provided", async () => {
const svgContent = `<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg"/>`;
const mockedFetch = jest
.mocked(global.fetch)
.mockResolvedValue(new Response(new Blob([svgContent])));
const iconUrl = createRandomName("iconUrl");
const node: VertexIconConfig = {
type: createRandomName("vertex"),
color: undefined,
iconUrl,
iconImageType: "image/svg+xml",
};

const result = await renderNode(node);

expect(mockedFetch).toBeCalledWith(iconUrl);
expect(result).toBeDefined();
expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,");
const decodedResult = decodeSvg(result);
expect(decodedResult).toEqual(
wrapExpectedSvg(
`<svg fill="#128EE5" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"/>`
)
);
});

it("should return processed SVG string replacing currentColor with provided custom color", async () => {
const svgContent = `<svg fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"/>`;
const mockedFetch = jest
.mocked(global.fetch)
.mockResolvedValue(new Response(new Blob([svgContent])));
const node: VertexIconConfig = {
type: createRandomName("vertex"),
color: createRandomColor(),
iconUrl: createRandomName("iconUrl"),
iconImageType: "image/svg+xml",
};

const result = await renderNode(node);

expect(mockedFetch).toBeCalledWith(node.iconUrl);
expect(result).toBeDefined();
expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,");
const decodedResult = decodeSvg(result);
expect(decodedResult).toEqual(
wrapExpectedSvg(
`<svg fill="${node.color}" stroke="${node.color}" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"/>`
)
);
});

it("should return processed SVG string modifying the width and height", async () => {
const originalColor = createRandomColor();
const svgContent = `<svg fill="${originalColor}" viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg"/>`;
const mockedFetch = jest
.mocked(global.fetch)
.mockResolvedValue(new Response(new Blob([svgContent])));
const node: VertexIconConfig = {
type: createRandomName("vertex"),
color: createRandomColor(),
iconUrl: createRandomName("iconUrl"),
iconImageType: "image/svg+xml",
};

const result = await renderNode(node);

expect(mockedFetch).toBeCalledWith(node.iconUrl);
expect(result).toBeDefined();
expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,");
const decodedResult = decodeSvg(result);
expect(decodedResult).toEqual(
wrapExpectedSvg(
`<svg fill="${originalColor}" viewBox="0 0 24 24" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg"/>`
)
);
});
});

/** Wraps SVG string in another SVG element matching what is expected. */
function wrapExpectedSvg(svgContent: string): string {
return `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg>
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg">
${svgContent}
</svg>`;
}

/** Decodes the string and removes the data type URL prefix, returning only the SVG portion. */
function decodeSvg(result: string | undefined) {
return decodeURIComponent(result!).replace("data:image/svg+xml;utf8,", "");
}
83 changes: 83 additions & 0 deletions packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { VertexTypeConfig } from "../../core";

export type VertexIconConfig = Pick<
VertexTypeConfig,
"type" | "iconUrl" | "iconImageType" | "color"
>;

export const ICONS_CACHE: Map<string, string> = new Map();

export async function renderNode(
vtConfig: VertexIconConfig
): Promise<string | undefined> {
if (!vtConfig.iconUrl) {
return;
}

if (vtConfig.iconImageType !== "image/svg+xml") {
return vtConfig.iconUrl;
}

// To avoid multiple requests, cache icons under the same URL
if (ICONS_CACHE.get(vtConfig.iconUrl)) {
return ICONS_CACHE.get(vtConfig.iconUrl);
}

try {
const response = await fetch(vtConfig.iconUrl);
let iconText = await response.text();

iconText = updateSize(iconText);
iconText = embedSvgInsideCytoscapeSvgWrapper(iconText);
iconText = applyCurrentColor(iconText, vtConfig.color || "#128EE5");
iconText = encodeSvg(iconText);

// Save to the cache
ICONS_CACHE.set(vtConfig.iconUrl, iconText);
return iconText;
} catch (error) {
// Ignore the error and move on
console.error(`Failed to fetch the icon data for vertex ${vtConfig.type}`);
return;
}
}

/**
* Embeds the given SVG content inside an SVG wrapper that is designed to work well with Cytoscape rendering.
* @param svgContent The SVG content to embed
* @returns SVG string
*/
function embedSvgInsideCytoscapeSvgWrapper(svgContent: string): string {
return `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg>
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg">
${svgContent}
</svg>`;
}

/**
* Replaces `currentColor` with the given color to make sure the SVG applies the right color in Cytoscape.
* @param svgContent
* @param color
* @returns
*/
function applyCurrentColor(svgContent: string, color: string) {
return svgContent.replace(/currentColor/gm, color);
}

function updateSize(svgContent: string): string {
const parser = new DOMParser();
const serializer = new XMLSerializer();

const doc = parser.parseFromString(svgContent, "application/xml");

doc.documentElement.setAttribute("width", "100%");
doc.documentElement.setAttribute("height", "100%");

const result = serializer.serializeToString(doc.documentElement);

return result;
}

function encodeSvg(svgContent: string): string {
return "data:image/svg+xml;utf8," + encodeURIComponent(svgContent);
}
22 changes: 6 additions & 16 deletions packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import Color from "color";
import { useEffect, useState } from "react";
import { EdgeData } from "../../@types/entities";
import type { GraphProps } from "../../components";
import colorizeSvg from "../../components/utils/canvas/colorizeSvg";
import { useConfiguration } from "../../core";
import useTextTransform from "../../hooks/useTextTransform";
import { renderNode } from "./renderNode";

const ICONS_CACHE: Map<string, string> = new Map();
const LINE_PATTERN = {
solid: undefined,
dashed: [5, 6],
Expand All @@ -28,29 +27,20 @@ const useGraphStyles = () => {
continue;
}

// To avoid multiple requests, cache icons under the same URL
let iconText = vtConfig.iconUrl
? ICONS_CACHE.get(vtConfig.iconUrl)
: undefined;
if (vtConfig.iconUrl && !iconText) {
const response = await fetch(vtConfig.iconUrl);
iconText = await response.text();
ICONS_CACHE.set(vtConfig.iconUrl, iconText);
}
// Process the image data or SVG
const backgroundImage = await renderNode(vtConfig);

styles[`node[type="${vt}"]`] = {
"background-image":
iconText && vtConfig.iconImageType === "image/svg+xml"
? colorizeSvg(iconText, vtConfig.color || "#128EE5") ||
"data(__iconUrl)"
: vtConfig.iconUrl,
"background-image": backgroundImage,
"background-color": vtConfig.color,
"background-opacity": vtConfig.backgroundOpacity,
"border-color": vtConfig.borderColor,
"border-width": vtConfig.borderWidth,
"border-opacity": vtConfig.borderWidth ? 1 : 0,
"border-style": vtConfig.borderStyle,
shape: vtConfig.shape,
width: 24,
height: 24,
};
}

Expand Down
8 changes: 3 additions & 5 deletions packages/graph-explorer/src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
// Sets up `fetch` for JSDom environment.
// https://github.com/jsdom/jsdom/issues/1724#issuecomment-720727999
import "whatwg-fetch";
13 changes: 13 additions & 0 deletions packages/graph-explorer/src/utils/testing/randomData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ export function createRandomBoolean(): boolean {
return Math.random() < 0.5;
}

/**
* Randomly creates a hex value for an RGB color.
* @returns The hex string of the random color.
*/
export function createRandomColor(): string {
const letters = "0123456789ABCDEF".split("");
let color = "#";
for (let i = 0; i < 6; i++) {
color += letters[Math.round(Math.random() * 15)];
}
return color;
}

/**
* Randomly returns the provided value or undefined.
* @returns Either the value or undefined.
Expand Down
Loading

0 comments on commit 8c2698d

Please sign in to comment.