Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions contributors/atharva-kamble.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Name: Atharva Kamble <br>
GitHub username: atharvaneu

---

### Summary:

I wrote the NodeDistributionMap.tsx component. This component displays a svg graph by using the @visx svg library developed and maintained by AirBnB. This map shows the latest 100 nodes that are generated in the Bitcoin blockchain and provides details about individual nodes such as country name, town name, host name, host url. I am using the bitnodes.io API.

### Engineering:

1. I had to learn more about Mercator maps and how latitudes and longitudes are converted for a Mercator map projection. Mercator maps are a type of projections which map the globe on a 2 dimensional surface - they are widely used since our device screens are 2 dimensional as well. A characteristic of a Mercator map projection is that a map is divided into two hemispheres, and the field of view for regions/countries closer to the equator is quite less, but keeps on increasing as we go away from the equator. A good example to prove this is - in a Mercator project Greenland appears to be of the same size as of the continent of Africa.

2. I fetched data from `bitnodes.io` api that provides latest snapshots about the nodes in the BTC blockchain network. The API is used to get information like node height (number), host name, host URL, region, country, and town. Since I can't use this data directly I first had to clean the data - meaning remove any unnecessary information or fix formatting errors. Then, I sliced the data to 100 nodes across the world - since the API provides data for thousands of nodes - but it would have been impractical to implement and render all those nodes. So I capped the nodes to 100.

3. For displaying the map, I am using `@visx` svg manipulation library which is developed and maintained by AirBnB. It's a pretty low level SVG manipulation engine that has several inner modules such as `@visx/geo` (provides the world map out of the box), `@visx/shape`, `@visx/scale`. There's also a `world-topo.json` file that is required by the @visx library to map out the world - this file basically contains all the topological data of the world.

4. We are using grafbase so instead of making a direct axios request to the API, we use grafbase to have a centralized graphql endpoint.
1,048 changes: 1,027 additions & 21 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
},
"dependencies": {
"@apollo/client": "^3.9.7",
"@types/topojson": "^3.2.6",
"@types/topojson-client": "^3.1.4",
"@visx/geo": "^3.5.0",
"@visx/group": "^3.3.0",
"@visx/mock-data": "^3.3.0",
"@visx/responsive": "^3.3.0",
"@visx/scale": "^3.5.0",
"@visx/shape": "^3.5.0",
"@visx/tooltip": "^3.3.0",
"@visx/visx": "^3.8.0",
"antd": "^5.15.3",
"axios": "^1.6.8",
"chart.js": "^4.4.2",
Expand All @@ -21,13 +31,15 @@
"echarts": "^5.5.0",
"echarts-for-react": "^3.0.2",
"highcharts-react-official": "^3.2.1",
"net": "^1.0.2",
"next": "14.1.0",
"react": "^18",
"react-chartjs-2": "^5.2.0",
"react-datepicker": "^6.4.0",
"react-dom": "^18",
"react-router-dom": "^6.22.3",
"swr": "^2.2.5"
"swr": "^2.2.5",
"topojson-client": "^3.1.0"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
Expand Down
24 changes: 15 additions & 9 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import DistributionChart from "./components/minerdistributionpool/DistributionCh
import MinerDetails from "./minerdetails/page";
import "bootstrap/dist/css/bootstrap.min.css";
import CryptoMarketData from "../components/CryptoMarketData";
import App from '../components/transactions';
import BitcoinBlocks from '@/pages/LatestBlocks/BitcoinBlocks'
import App from "../components/transactions";
import BitcoinBlocks from "@/pages/LatestBlocks/BitcoinBlocks";
import Link from "next/link";
import CoinMarket from "../components/CoinMarket"

Expand All @@ -34,6 +34,7 @@ import LiquidTransaction from "@/components/LiquidTransaction/LiquidTransaction"
import Assets from "@/components/Assets/Assets";
import BitcoinTransaction from "@/components/BitcoinTransaction/BitcoinTransaction";
import Ethereum from "@/components/Ethereum/Ethereum";
import NodeDistributionMap from "@/components/GeoMap/NodeDistributionMap";
import DifficultyAdjustment from "./difficultyAdjustment/DifficultyAdjustment";
import TransactionFee from "./difficultyAdjustment/TransactionFee";
import Statistics from './components/best-fee/Statistics.tsx'
Expand Down Expand Up @@ -160,28 +161,32 @@ export default async function Home() {
<MempoolRecent />
</div>

<div className={styles.containerRow}>
<components.DailyBlockCountData />
<div className={styles.containerRow}>
<components.DailyBlockCountData />

<div className="">
<h4>Bitcoin top 100 nodes mapped</h4>
<NodeDistributionMap height={400} width={400} />
</div>
</div>

<div>
<BitcoinBlocks/>
<BitcoinBlocks />
</div>

<div>
<CryptoMarketData/>
<CryptoMarketData />
</div>


<div>
<h1>Transactions</h1>
<TransactionDetails />
</div>
<div>
<LiquidTransaction />
<LiquidTransaction />
</div>
<div>
<Assets />
<Assets />
</div>
<div>
<BitcoinTransaction />
Expand All @@ -206,6 +211,7 @@ export default async function Home() {
<div>
<Bitcoinassetdata />
</div>

<div className="main-content">
<CoinMarket />
</div>
Expand Down
274 changes: 274 additions & 0 deletions src/components/GeoMap/NodeDistributionMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
"use client";

import React, { useState, useEffect } from "react";
import { scaleQuantize, scaleLinear } from "@visx/scale";
import { Mercator, Graticule } from "@visx/geo";
import * as topojson from "topojson-client";
import topology from "./world-topo.json";
import axios from "axios";
import { bitnodes } from "./bitnodesSnapshot";

export const background = "#f9f7e8";

export type GeoMercatorProps = {
width: number;
height: number;
events?: boolean;
className?: string;
rotate?: any;
};

interface FeatureShape {
type: "Feature";
id: string;
geometry: { coordinates: [number, number][][]; type: "Polygon" };
properties: { name: string };
}

const world = topojson?.feature(
// @ts-ignore: module error in not recognizing type for topology
topology,
topology.objects.units
) as unknown as {
type: "FeatureCollection";
features: FeatureShape[];
};

const color = scaleQuantize({
domain: [
Math.min(...world.features.map((f) => f.geometry.coordinates.length)),
Math.max(...world.features.map((f) => f.geometry.coordinates.length)),
],
range: [
"#ffb01d",
"#ffa020",
"#ff9221",
"#ff8424",
"#ff7425",
"#fc5e2f",
"#f94b3a",
"#f63a48",
],
});

function degreesToRadians(degrees: number) {
return (degrees * Math.PI) / 180;
}

function latLonToOffsets(
latitude: number,
longitude: number,
mapWidth: number,
mapHeight: number
) {
const FE = 180; // false easting
const radius = mapWidth / (2 * Math.PI);

const latRad = degreesToRadians(latitude);
const lonRad = degreesToRadians(longitude + FE);

const x = lonRad * radius;

const yFromEquator = radius * Math.log(Math.tan(Math.PI / 4 + latRad / 2));
const y = mapHeight / 2 - yFromEquator;

return { x, y };
}

interface TooltipShape {
hostName: string;
hostURL: string;
town: string;
region: string;
nodeNumber?: string;
}

const NUMBER_OF_NODES = 100;

export default function ({
width,
height,
className,
rotate,
events = false,
}: GeoMercatorProps) {
const [tooltip, setTooltip] = useState<TooltipShape | null>(null);
const [maximized, setMaximized] = useState<boolean>(false);
const [coords, setCoords] = useState<any>();

const centerX = (width * (maximized ? 2 : 1)) / 2;
const centerY = (height * (maximized ? 2 : 1)) / 2;
const scale = ((width * (maximized ? 2 : 1)) / 630) * 100;

useEffect(() => {
async function fetchAndPopulateNodes() {
try {
const response = await bitnodes();

const nodes = response?.bitnodes?.snapshot?.nodes;
const keys = Object.keys(nodes)
.filter((ele) => !ele.includes(".onion:")) // this means the nodes are using a TOR network and lat/long are set to 0
.slice(0, NUMBER_OF_NODES);

const _tmp = keys.map((ele: any) => {
return [
nodes[ele][8], // long
nodes[ele][9], // lat
nodes[ele][12], // Host name
nodes[ele][5], // Host URL
nodes[ele][6], // Town name
nodes[ele][10], // Region
nodes[ele][4], // Node number/height
];
});

setCoords(() => _tmp);
} catch (error) {
console.error("BitNodes API call error:", error);
}
}

fetchAndPopulateNodes();
}, []);

return width * (maximized ? 2 : 1) < 10 ? null : (
<div className="relative" data-testid="nodeDistributionMain">
<Tooltip isVisible={tooltip === null} data={tooltip} />
<svg
width={width * (maximized ? 2 : 1)}
height={height * (maximized ? 2 : 1)}
className={`${className}`}
data-testid="nodeDistributionMap"
>
<rect
x={0}
y={0}
width={width * (maximized ? 2 : 1)}
height={height * (maximized ? 2 : 1)}
fill={background}
rx={14}
/>
<Mercator<FeatureShape>
data={world.features}
scale={scale}
translate={[centerX, centerY + 10]}
>
{(mercator) => (
<g>
<Graticule
graticule={(g) => mercator.path(g) || ""}
stroke="rgba(33,33,33,0.05)"
/>
{mercator.features.map(({ feature, path }, i) => (
<path
key={`map-feature-${i}`}
d={path || ""}
fill={color(feature.geometry.coordinates.length)}
stroke={background}
strokeWidth={0.5}
onClick={() => {
if (events)
alert(
`Clicked: ${feature.properties.name} (${feature.id})`
);
}}
/>
))}
</g>
)}
</Mercator>
<g>
{coords?.map((point: any, index: number) => {
const { x, y } = latLonToOffsets(
// @ts-ignore: point is of type [number, number]
point[0],
// @ts-ignore: point is of type [number, number]
point[1],
width * (maximized ? 2 : 1),
height * (maximized ? 2 : 1)
);

let fillColor = "#00ad17";

return (
<circle
r={maximized ? 3.5 : 1}
x={x}
y={y}
fill={fillColor}
stroke="#333"
strokeWidth={0}
opacity={1.7}
transform={`translate(${[x, y]})`}
className="cursor-pointer hover:fill-green-300"
onMouseEnter={() =>
setTooltip(() => {
return {
hostName: point[2],
hostURL: point[3],
town: point[4],
region: point[5],
nodeNumber: point[6],
};
})
}
onMouseLeave={() => setTooltip(null)}
/>
);
})}
</g>
</svg>
<Overlay
className=""
text={maximized ? "minimize" : "maximize"}
isMaximized={maximized}
maximize={setMaximized}
/>
</div>
);
}

interface TooltipProps {
className?: string;
isVisible: boolean;
data: TooltipShape | null;
}

function Tooltip({ className, isVisible, data }: TooltipProps) {
return (
<div
className={`absolute bottom-5 left-5 p-1 bg-slate-800 text-white rounded text-xs transition ease-in-out delay-300 ${
isVisible ? "hidden" : "block"
} ${className}`}
>
<span className="font-bold">Host name</span>:{" "}
<span className="text-gray-400">{data?.hostName}</span>,{" "}
<span className="font-bold">Host domain</span>:{" "}
<span className="text-gray-400">{data?.hostURL}</span> <br />
<span className="font-bold">Town</span>:{" "}
<span className="text-gray-400">{data?.town}</span>,{" "}
<span className="font-bold">Region</span>:
<span className="text-gray-400"> {data?.region}</span> <br />
<span className="font-bold">Node number</span>:{" "}
<span className="text-gray-400">{data?.nodeNumber}</span>
</div>
);
}

interface OverlayProps {
className?: string;
text: string;
isMaximized: boolean;
maximize: React.Dispatch<React.SetStateAction<boolean>>;
}

function Overlay({ className, text, isMaximized, maximize }: OverlayProps) {
return (
<div
className={`absolute top-5 right-5 p-1 bg-slate-800 text-white rounded font-sm font-bold hover:bg-slate-600 cursor-pointer ${className}`}
onClick={() => maximize(() => !isMaximized)}
>
{text}
</div>
);
}
Loading