Skip to content

Commit

Permalink
[explorer] Implement map of nodes (MystenLabs#3766)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jordan-Mysten authored Aug 10, 2022
1 parent b5c3d5c commit 93bdf39
Show file tree
Hide file tree
Showing 12 changed files with 11,419 additions and 18 deletions.
10 changes: 9 additions & 1 deletion explorer/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@types/puppeteer": "^5.4.5",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@types/topojson-client": "^3.1.1",
"autoprefixer": "^10.4.2",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-header": "^3.1.1",
Expand All @@ -33,7 +34,11 @@
"@mysten/sui.js": "file:../../sdk/typescript",
"@sentry/react": "^7.6.0",
"@sentry/tracing": "^7.6.0",
"@tanstack/react-query": "^4.0.10",
"@tanstack/react-table": "^8.1.4",
"@visx/geo": "^2.10.0",
"@visx/responsive": "^2.10.0",
"@visx/tooltip": "^2.10.0",
"bn.js": "^5.2.0",
"classnames": "^2.3.1",
"prism-react-renderer": "^1.3.5",
Expand All @@ -43,6 +48,7 @@
"react-dom": "^17.0.2",
"react-ga4": "^1.4.1",
"react-router-dom": "^6.2.1",
"topojson-client": "^3.1.0",
"vanilla-cookieconsent": "^2.8.0",
"web-vitals": "^2.1.4"
},
Expand All @@ -65,7 +71,9 @@
},
"resolutions": {
"async": "3.2.2",
"nth-check": "2.0.1"
"nth-check": "2.0.1",
"@types/react": "17.x",
"@types/react-dom": "17.x"
},
"browserslist": {
"production": [
Expand Down
44 changes: 33 additions & 11 deletions explorer/client/src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,49 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEffect, useState } from 'react';

import Footer from '../components/footer/Footer';
import Header from '../components/header/Header';
import { NetworkContext, useNetwork } from '../context';
import AppRoutes from '../pages/config/AppRoutes';

import styles from './App.module.css';

const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
});

function App() {
const [network, setNetwork] = useNetwork();
const [queryClient] = useState(createQueryClient);

// TODO: Verify this behavior:
useEffect(() => {
queryClient.clear();
}, [network, queryClient]);

return (
<NetworkContext.Provider value={[network, setNetwork]}>
<div className={styles.app}>
<Header />
<main>
<section className={styles.suicontainer}>
<AppRoutes />
</section>
</main>
<Footer />
</div>
</NetworkContext.Provider>
<QueryClientProvider client={queryClient}>
<NetworkContext.Provider value={[network, setNetwork]}>
<div className={styles.app}>
<Header />
<main>
<section className={styles.suicontainer}>
<AppRoutes />
</section>
</main>
<Footer />
</div>
</NetworkContext.Provider>
</QueryClientProvider>
);
}

Expand Down
37 changes: 37 additions & 0 deletions explorer/client/src/components/validator-map/MapFeature.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { useCallback } from 'react';

import { type Feature } from './types';

interface Props {
path: string | null;
feature: Feature;
onMouseOver(event: React.MouseEvent, countryCode?: string): void;
onMouseOut(): void;
}

export function MapFeature({ path, feature, onMouseOver, onMouseOut }: Props) {
const handleMouseOver = useCallback(
(e: React.MouseEvent) => {
onMouseOver(e, feature.properties.alpha2);
},
[feature.properties.alpha2, onMouseOver]
);

if (!path) {
return null;
}

return (
<path
onMouseOver={handleMouseOver}
onMouseMove={handleMouseOver}
onMouseOut={onMouseOut}
d={path}
fill="white"
strokeWidth={0}
/>
);
}
36 changes: 36 additions & 0 deletions explorer/client/src/components/validator-map/NodesLocation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { type NodeLocation } from './types';

interface Props {
node: NodeLocation;
projection: (loc: [number, number]) => [number, number] | null;
}

// NOTE: This should be tweaked based on the average number of nodes in a location:
const NODE_MULTIPLIER = 2;
const MIN_NODE_SIZE = 3;
const MAX_NODE_SIZE = 15;

export function NodesLocation({ node, projection }: Props) {
const position = projection(node.location);
const r = Math.max(
Math.min(Math.floor(node.count / NODE_MULTIPLIER), MAX_NODE_SIZE),
MIN_NODE_SIZE
);

if (!position) return null;

return (
<g style={{ pointerEvents: 'none' }}>
<circle
cx={position[0]}
cy={position[1]}
r={r}
fill="#6FBCF0"
opacity={0.4}
/>
</g>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
.card {
@apply rounded-lg bg-cardDark flex flex-col justify-end min-h-[220px] antialiased relative;
}

.container {
@apply p-6 flex flex-col items-start sm:items-end sm:flex-row md:flex-col md:items-start justify-between gap-8 relative z-10 pointer-events-none;
}

.contents {
@apply flex flex-col gap-8;
}

.title {
@apply uppercase text-xs font-semibold text-sui-grey-65;
}

.stat {
@apply font-semibold text-2xl text-sui-grey-90;
}

.button {
@apply p-2 text-sui-grey-80 border border-solid border-sui-grey-55 text-sm font-semibold rounded-md inline-flex items-center gap-2 no-underline pointer-events-auto whitespace-nowrap;
}

.mapcontainer {
@apply absolute inset-0 z-0 overflow-hidden;
}

.map {
@apply absolute top-0 bottom-0 right-0 md:left-32 pointer-events-none md:pointer-events-auto;
}

.map svg {
@apply overflow-visible;
}

.tooltip {
@apply bg-sui-grey-100 text-white p-2 absolute z-20 text-xs font-sans rounded-md min-w-[100px] hidden md:block z-40;
}

.tipitem {
@apply uppercase flex items-center justify-between font-semibold;
}

.tipdivider {
@apply my-1 h-px bg-sui-grey-90;
}
168 changes: 168 additions & 0 deletions explorer/client/src/components/validator-map/ValidatorMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { useQuery } from '@tanstack/react-query';
import { ParentSizeModern } from '@visx/responsive';
import { TooltipWithBounds, useTooltip } from '@visx/tooltip';
import React, { useCallback, useMemo } from 'react';

import { ReactComponent as ForwardArrowDark } from '../../assets/SVGIcons/forward-arrow-dark.svg';
import { WorldMap } from './WorldMap';
import { type NodeLocation } from './types';

import styles from './ValidatorMap.module.css';

const HOST = 'https://imgmod.sui.io';

type NodeList = [
ip: string,
city: string,
region: string,
countryCode: string,
loc: string
][];

type CountryNodes = Record<string, { count: number; countryCode: string }>;

const regionNamesInEnglish = new Intl.DisplayNames(['en'], { type: 'region' });

export default function ValidatorMap() {
const { data } = useQuery(['validator-map'], async () => {
const res = await fetch(`${HOST}/location`, {
method: 'GET',
});

return res.json() as Promise<NodeList>;
});

const { nodes, countryCount, countryNodes } = useMemo<{
nodes: NodeLocation[];
countryCount?: number;
countryNodes: CountryNodes;
}>(() => {
if (!data) {
return { nodes: [], countryNodes: {} };
}

const nodeLocations: Record<string, NodeLocation> = {};
const countryNodes: CountryNodes = {};

data.forEach(([, city, region, countryCode, loc]) => {
const key = `${city}-${region}-${countryCode}`;

countryNodes[countryCode] ??= {
count: 0,
countryCode,
};
countryNodes[countryCode].count += 1;

nodeLocations[key] ??= {
count: 0,
city,
countryCode,
location: loc
.split(',')
.reverse()
.map((geo) => parseFloat(geo)) as [number, number],
};
nodeLocations[key].count += 1;
});

return {
nodes: Object.values(nodeLocations),
countryCount: Object.keys(countryNodes).length,
countryNodes,
};
}, [data]);

const {
tooltipData,
tooltipLeft,
tooltipTop,
tooltipOpen,
showTooltip,
hideTooltip,
} = useTooltip<string>();

const handleMouseOver = useCallback(
(event: React.MouseEvent<SVGElement>, countryCode?: string) => {
const owner = event.currentTarget.ownerSVGElement;
if (!owner) return;

const rect = owner.getBoundingClientRect();

if (countryCode && countryNodes[countryCode]) {
showTooltip({
tooltipLeft: event.clientX - rect.x,
tooltipTop: event.clientY - rect.y,
tooltipData: countryCode,
});
} else {
hideTooltip();
}
},
[showTooltip, countryNodes, hideTooltip]
);

return (
<div className={styles.card}>
<div className={styles.container}>
<div className={styles.contents}>
<div>
<div className={styles.title}>Total Nodes</div>
<div className={styles.stat}>{data?.length}</div>
</div>
<div>
<div className={styles.title}>Total Countries</div>
<div className={styles.stat}>{countryCount}</div>
</div>
</div>

<a
href="https://bit.ly/sui_validator"
className={styles.button}
>
<span>Become a Validator</span>
<ForwardArrowDark />
</a>
</div>

<div className={styles.mapcontainer}>
<div className={styles.map}>
<ParentSizeModern>
{(parent) => (
<WorldMap
nodes={nodes}
width={parent.width}
height={parent.height}
onMouseOver={handleMouseOver}
onMouseOut={hideTooltip}
/>
)}
</ParentSizeModern>
</div>
</div>

{tooltipOpen && tooltipData && (
<TooltipWithBounds
top={tooltipTop}
left={tooltipLeft}
className={styles.tooltip}
// NOTE: Tooltip will un-style itself if we provide a style object:
style={{}}
>
<div className={styles.tipitem}>
<div>Nodes</div>
<div>{countryNodes[tooltipData].count}</div>
</div>
<div className={styles.tipdivider} />
<div>
{regionNamesInEnglish.of(
countryNodes[tooltipData].countryCode
) || countryNodes[tooltipData].countryCode}
</div>
</TooltipWithBounds>
)}
</div>
);
}
Loading

0 comments on commit 93bdf39

Please sign in to comment.