chore: 🔧 add chord graph

This commit is contained in:
Anthony Potdevin 2020-11-15 19:00:44 +01:00
parent a183cc5ab9
commit 97ba42beb9
No known key found for this signature in database
GPG key ID: 4403F1DFBE779457
6 changed files with 466 additions and 16 deletions

170
package-lock.json generated
View file

@ -8044,6 +8044,11 @@
"@types/node": "*"
}
},
"@types/classnames": {
"version": "2.2.11",
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
"integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@ -8094,6 +8099,50 @@
"integrity": "sha512-6+OPzqhKX/cx5xh+yO8Cqg3u3alrkhoxhE5ZOdSEv0DOzJ13lwJ6laqGU0Kv6+XDMFmlnGId04LtY22PsFLQUw==",
"dev": true
},
"@types/d3-chord": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-1.0.10.tgz",
"integrity": "sha512-U6YojfET6ITL1/bUJo+/Lh3pMV9XPAfOWwbshl3y3RlgAX9VO/Bxa13IMAylZIDY4VsA3Gkh29kZP1AcAeyoYA=="
},
"@types/d3-color": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.1.tgz",
"integrity": "sha512-xkPLi+gbgUU9ED6QX4g6jqYL2KCB0/3AlM+ncMGqn49OgH0gFMY/ITGqPF8HwEiLzJaC+2L0I+gNwBgABv1Pvg=="
},
"@types/d3-interpolate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-1.4.2.tgz",
"integrity": "sha512-ylycts6llFf8yAEs1tXzx2loxxzDZHseuhPokrqKprTQSTcD3JbJI1omZP1rphsELZO3Q+of3ff0ZS7+O6yVzg==",
"requires": {
"@types/d3-color": "^1"
}
},
"@types/d3-path": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
"integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
},
"@types/d3-scale": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.2.1.tgz",
"integrity": "sha512-j+FryQSVk3GHLqjOX/RsHwGHg4XByJ0xIO1ASBTgzhE9o1tgeV4kEWLOzMzJRembKalflk5F03lEkM+4V6LDrQ==",
"requires": {
"@types/d3-time": "*"
}
},
"@types/d3-shape": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.5.tgz",
"integrity": "sha512-aPEax03owTAKynoK8ZkmkZEDZvvT4Y5pWgii4Jp4oQt0gH45j6siDl9gNDVC5kl64XHN2goN9jbYoHK88tFAcA==",
"requires": {
"@types/d3-path": "^1"
}
},
"@types/d3-time": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz",
"integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw=="
},
"@types/express": {
"version": "4.17.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz",
@ -8288,8 +8337,7 @@
"@types/lodash": {
"version": "4.14.157",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.157.tgz",
"integrity": "sha512-Ft5BNFmv2pHDgxV5JDsndOWTRJ+56zte0ZpYLowp03tW+K+t8u8YMOzAnpuqPgzX6WO1XpDIUm7u04M8vdDiVQ==",
"dev": true
"integrity": "sha512-Ft5BNFmv2pHDgxV5JDsndOWTRJ+56zte0ZpYLowp03tW+K+t8u8YMOzAnpuqPgzX6WO1XpDIUm7u04M8vdDiVQ=="
},
"@types/lodash.groupby": {
"version": "4.6.6",
@ -8383,8 +8431,7 @@
"@types/prop-types": {
"version": "15.7.3",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
"dev": true
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
},
"@types/qrcode.react": {
"version": "1.0.1",
@ -8409,7 +8456,6 @@
"version": "16.9.56",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.56.tgz",
"integrity": "sha512-gIkl4J44G/qxbuC6r2Xh+D3CGZpJ+NdWTItAPmZbR5mUS+JQ8Zvzpl0ea5qT/ZT3ZNTUcDKUVqV3xBE8wv/DyQ==",
"dev": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@ -8418,8 +8464,7 @@
"csstype": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.4.tgz",
"integrity": "sha512-xc8DUsCLmjvCfoD7LTGE0ou2MIWLx0K9RCZwSHMOdynqRsP4MtUcLeqh1HcQ2dInwDTqn+3CE0/FZh1et+p4jA==",
"dev": true
"integrity": "sha512-xc8DUsCLmjvCfoD7LTGE0ou2MIWLx0K9RCZwSHMOdynqRsP4MtUcLeqh1HcQ2dInwDTqn+3CE0/FZh1et+p4jA=="
}
}
},
@ -8893,6 +8938,103 @@
"eslint-visitor-keys": "^2.0.0"
}
},
"@visx/chord": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@visx/chord/-/chord-1.0.0.tgz",
"integrity": "sha512-S2+LDiGUHqgDqmuNHERThFRvzA3uGp2Ne0Jxi/V7sncKxuW4bFNeyK21fh9xGMLVaI7Vv52ZSLi+BVcd71HUDA==",
"requires": {
"@types/classnames": "^2.2.9",
"@types/d3-chord": "^1.0.9",
"@types/react": "*",
"classnames": "^2.2.6",
"d3-chord": "^1.0.4",
"prop-types": "^15.6.1"
}
},
"@visx/curve": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@visx/curve/-/curve-1.0.0.tgz",
"integrity": "sha512-rN9TUf4uRmPuQ5Rd4kbvinSDsTbR61YB26+ucK6RNMHIr9aLmujpcPJhVwk22EWphRRGIxzK2OSp0d5dgpNppQ==",
"requires": {
"@types/d3-shape": "^1.3.1",
"d3-shape": "^1.0.6"
}
},
"@visx/group": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@visx/group/-/group-1.0.0.tgz",
"integrity": "sha512-2YlhGHTINUl7do046p/bkIYiD4xDv/sJ4JAaGrqFXwX68EJZ5Er/0gpZZ4nrADQlxB8/uyJvZzp1Q54ySfTMiA==",
"requires": {
"@types/classnames": "^2.2.9",
"@types/react": "*",
"classnames": "^2.2.5",
"prop-types": "^15.6.2"
}
},
"@visx/responsive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-1.1.0.tgz",
"integrity": "sha512-rKdhm9FDwN2PYWNXQ3X9JA+BOUb39bk8y2wYCW7/IzQtLUajzQeIHfYNGBfBZyeEj0J7b4l18ljTy3P//CFBmA==",
"requires": {
"@types/lodash": "^4.14.146",
"@types/react": "*",
"lodash": "^4.17.10",
"prop-types": "^15.6.1",
"resize-observer-polyfill": "1.5.1"
}
},
"@visx/scale": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@visx/scale/-/scale-1.1.0.tgz",
"integrity": "sha512-6m11vrqDD5zPhJdS2KW8fEtdpne3zgJ2YVqnM9naibaUsdA7oLXh54zUlKEd+mzYWLRYuwA+urFPWBSA7kiEaw==",
"requires": {
"@types/d3-interpolate": "^1.3.1",
"@types/d3-scale": "^3.1.0",
"@types/d3-time": "^1.0.10",
"d3-interpolate": "^1.4.0",
"d3-scale": "^3.0.1",
"d3-time": "^1.1.0"
},
"dependencies": {
"d3-array": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.8.0.tgz",
"integrity": "sha512-6V272gsOeg7+9pTW1jSYOR1QE37g95I3my1hBmY+vOUNHRrk9yt4OTz/gK7PMkVAVDrYYq4mq3grTiZ8iJdNIw=="
},
"d3-scale": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
"integrity": "sha512-8E37oWEmEzj57bHcnjPVOBS3n4jqakOeuv1EDdQSiSrYnMCBdMd3nc4HtKk7uia8DUHcY/CGuJ42xxgtEYrX0g==",
"requires": {
"d3-array": "^2.3.0",
"d3-format": "1 - 2",
"d3-interpolate": "1.2.0 - 2",
"d3-time": "1 - 2",
"d3-time-format": "2 - 3"
}
}
}
},
"@visx/shape": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@visx/shape/-/shape-1.1.0.tgz",
"integrity": "sha512-3tf+m0R2fAecz+eGgG4NMIl+LVEKm51CrM+AxSJEOrRiTe4syERgRXxOGa04BHDo75S66coBN+416L38GHri4g==",
"requires": {
"@types/classnames": "^2.2.9",
"@types/d3-path": "^1.0.8",
"@types/d3-shape": "^1.3.1",
"@types/lodash": "^4.14.146",
"@types/react": "*",
"@visx/curve": "1.0.0",
"@visx/group": "1.0.0",
"@visx/scale": "1.1.0",
"classnames": "^2.2.5",
"d3-path": "^1.0.5",
"d3-shape": "^1.2.0",
"lodash": "^4.17.15",
"prop-types": "^15.5.10"
}
},
"@webassemblyjs/ast": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
@ -13868,6 +14010,15 @@
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz",
"integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="
},
"d3-chord": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz",
"integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==",
"requires": {
"d3-array": "1",
"d3-path": "1"
}
},
"d3-collection": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz",
@ -25613,6 +25764,11 @@
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",

View file

@ -35,6 +35,11 @@
"dependencies": {
"@apollo/client": "^3.2.5",
"@next/bundle-analyzer": "^10.0.1",
"@visx/chord": "^1.0.0",
"@visx/group": "^1.0.0",
"@visx/responsive": "^1.1.0",
"@visx/scale": "^1.1.0",
"@visx/shape": "^1.1.0",
"apollo-server-micro": "^2.19.0",
"balanceofsatoshis": "^7.3.2",
"bcryptjs": "^2.4.3",

View file

@ -19,6 +19,7 @@ import { ForwardChannelsReport } from 'src/views/home/reports/forwardReport/Forw
import { useState } from 'react';
import { ColorButton } from 'src/components/buttons/colorButton/ColorButton';
import { BarChart2, List } from 'react-feather';
import { ForwardChord } from 'src/views/forwards/forwardChord';
import {
SubTitle,
Card,
@ -26,6 +27,7 @@ import {
CardTitle,
Separation,
SingleLine,
ResponsiveLine,
} from '../src/components/generic/Styled';
const ForwardsView = () => {
@ -69,7 +71,7 @@ const ForwardsView = () => {
</ColorButton>
</SingleLine>
</CardTitle>
<SingleLine>
<ResponsiveLine>
<MultiButton margin={'8px 0'}>
{renderButton(1, 'D')}
{renderButton(7, '1W')}
@ -85,19 +87,25 @@ const ForwardsView = () => {
{renderTypeButton('fee', 'Fees')}
</MultiButton>
)}
</SingleLine>
</ResponsiveLine>
<Card mobileCardPadding={'0'} mobileNoBackground={true}>
{isTable ? (
{isTable ? (
<Card mobileCardPadding={'0'} mobileNoBackground={true}>
<ForwardsList days={days} />
) : (
<>
</Card>
) : (
<>
<Card mobileCardPadding={'0'} mobileNoBackground={true}>
<ForwardReport days={days} order={infoType} />
<Separation />
<ForwardChannelsReport days={days} order={infoType} />
</>
)}
</Card>
</Card>
<SubTitle>Chord Graph</SubTitle>
<Card>
<ForwardChord days={days} order={infoType} />
</Card>
</>
)}
</CardWithTitle>
</>
);

View file

@ -0,0 +1,108 @@
import React from 'react';
import { Arc } from '@visx/shape';
import { Group } from '@visx/group';
import { Chord, Ribbon } from '@visx/chord';
import { scaleOrdinal } from '@visx/scale';
const pink = '#ff2fab';
const orange = '#ffc62e';
const purple = '#dc04ff';
const purple2 = '#7324ff';
const red = '#d04376';
const green = '#52f091';
const blue = '#04a6ff';
const lime = '#00ddc6';
function descending(a: number, b: number): number {
return b < a ? -1 : b > a ? 1 : b >= a ? 0 : NaN;
}
export type SingleGroupProps = {
endAngle: number;
index: number;
startAngle: number;
value: number;
};
export type SingleChordProps = {
source: SingleGroupProps;
target: SingleGroupProps;
};
const groupColor = scaleOrdinal<number, string>({
domain: [0, 1, 2, 3, 4, 5, 6, 7],
range: [pink, orange, purple, purple2, red, green, blue, lime],
});
export type ChordProps = {
matrix: number[][];
width: number;
height: number;
centerSize?: number;
groupCallback?: (group: SingleGroupProps) => void;
chordCallback?: (chord: SingleChordProps) => void;
};
export const ChordGraph = ({
matrix,
width,
height,
centerSize = 20,
groupCallback,
chordCallback,
}: ChordProps) => {
const outerRadius = Math.min(width, height) * 0.5 - (centerSize + 10);
const innerRadius = outerRadius - centerSize;
return width < 10 ? null : (
<div className="chords">
<svg width={width} height={height}>
<Group top={height / 2} left={width / 2}>
<Chord matrix={matrix} padAngle={0.05} sortSubgroups={descending}>
{({ chords }) => (
<g>
{chords.groups.map((group, i) => (
<Arc
key={`key-${i}`}
data={group}
innerRadius={innerRadius}
outerRadius={outerRadius}
fill={groupColor(i)}
onClick={() => {
groupCallback && groupCallback(group);
}}
/>
))}
{chords.map((chord, i) => {
return (
<Ribbon
key={`ribbon-${i}`}
chord={chord}
radius={innerRadius}
fill={groupColor(chord.source.index)}
fillOpacity={0.75}
onClick={() => {
chordCallback && chordCallback(chord);
}}
/>
);
})}
</g>
)}
</Chord>
</Group>
</svg>
<style jsx>{`
.chords {
display: flex;
flex-direction: column;
user-select: none;
}
svg {
margin: 1rem 0;
cursor: pointer;
}
`}</style>
</div>
);
};

View file

@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react';
import { useGetForwardsPastDaysQuery } from 'src/graphql/queries/__generated__/getForwardsPastDays.generated';
import { toast } from 'react-toastify';
import { getErrorContent } from 'src/utils/error';
import { Forward } from 'src/graphql/types';
import { ParentSize } from '@visx/responsive';
import {
ChordGraph,
SingleGroupProps,
SingleChordProps,
} from 'src/components/chord';
import styled from 'styled-components';
import { renderLine } from 'src/components/generic/helpers';
import { DarkSubTitle } from 'src/components/generic/Styled';
import { ReportType } from '../home/reports/forwardReport/ForwardReport';
import { getChordMatrix } from './helpers';
const Wrapper = styled.div`
height: 800px;
width: 100%;
`;
const Center = styled.div`
width: '100%';
text-align: center;
`;
type SelectedProps =
| { type: 'group'; group: SingleGroupProps }
| { type: 'chord'; chord: SingleChordProps }
| null;
const getTitle = (order: ReportType) => {
switch (order) {
case 'fee':
return 'Total Fees (sats)';
case 'tokens':
return 'Total Tokens (sats)';
default:
return 'Total Amount (Forwards)';
}
};
export const ForwardChord = ({
days,
order,
}: {
days: number;
order: ReportType;
}) => {
const [selected, setSelected] = useState<SelectedProps>();
useEffect(() => {
setSelected(null);
}, [order]);
const { data, loading } = useGetForwardsPastDaysQuery({
ssr: false,
variables: { days },
onError: error => toast.error(getErrorContent(error)),
});
if (loading || !data?.getForwardsPastDays?.length) {
return null;
}
const { matrix, uniqueNodes } = getChordMatrix(
order,
data.getForwardsPastDays as Forward[]
);
const handleGroupClick = (group: SingleGroupProps) => {
setSelected({ type: 'group', group });
};
const handleChordClick = (chord: SingleChordProps) => {
setSelected({ type: 'chord', chord });
};
const renderInfo = () => {
if (!selected) {
return (
<Center>
<DarkSubTitle>Click the graph for specific info!</DarkSubTitle>
</Center>
);
}
if (selected.type === 'group') {
return (
<>
{renderLine('Node', uniqueNodes[selected.group.index])}
{renderLine(getTitle(order), selected.group.value)}
</>
);
}
if (selected.type === 'chord') {
return (
<>
{renderLine(
'Flow between',
`${uniqueNodes[selected.chord.source.index]} - ${
uniqueNodes[selected.chord.target.index]
}`
)}
{renderLine(getTitle(order), selected.chord.source.value)}
</>
);
}
};
return (
<>
{renderInfo()}
<Wrapper>
<ParentSize>
{parent => (
<ChordGraph
matrix={matrix}
width={parent.width}
height={parent.height}
groupCallback={handleGroupClick}
chordCallback={handleChordClick}
/>
)}
</ParentSize>
</Wrapper>
</>
);
};

View file

@ -0,0 +1,45 @@
import { Forward } from 'src/graphql/types';
import { ReportType } from '../home/reports/forwardReport/ForwardReport';
export const getChordMatrix = (order: ReportType, forwardArray: Forward[]) => {
const cleaned = forwardArray.map(f => {
let value = 1;
if (order === 'fee') {
value = f.fee;
} else if (order === 'tokens') {
value = f.tokens;
}
return {
aliasIn: f.incoming_node?.alias || 'Unknown',
aliasOut: f.outgoing_node?.alias || 'Unknown',
value,
};
});
const incomingNodes = cleaned.map(f => f.aliasIn);
const outgoingNodes = cleaned.map(f => f.aliasOut);
const uniqueNodes = [...new Set(incomingNodes), ...new Set(outgoingNodes)];
const nodeLength = uniqueNodes.length;
const matrix = new Array(nodeLength);
for (let i = 0; i < matrix.length; i++) {
matrix[i] = new Array(nodeLength).fill(0);
}
cleaned.forEach(f => {
const inIndex = uniqueNodes.indexOf(f.aliasIn);
const outIndex = uniqueNodes.indexOf(f.aliasOut);
const previousValue = matrix[inIndex][outIndex];
const previousOutValue = matrix[outIndex][inIndex];
matrix[inIndex][outIndex] = previousValue + f.value;
matrix[outIndex][inIndex] = previousOutValue + f.value;
});
return { uniqueNodes, matrix };
};