diff --git a/web/package.json b/web/package.json index 4629d0ff..d84c6ce2 100644 --- a/web/package.json +++ b/web/package.json @@ -34,6 +34,7 @@ "mermaid": "^11.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-force-graph-2d": "^1.25.6", "react-hot-toast": "^2.4.1", "react-i18next": "^15.0.2", "react-leaflet": "^4.2.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f820677c..c0cdeeb8 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-force-graph-2d: + specifier: ^1.25.6 + version: 1.25.6(react@18.3.1) react-hot-toast: specifier: ^2.4.1 version: 2.4.1(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1170,6 +1173,9 @@ packages: '@vue/compiler-sfc': optional: true + '@tweenjs/tween.js@25.0.0': + resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1402,6 +1408,10 @@ packages: abort-controller-x@0.4.3: resolution: {integrity: sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA==} + accessor-fn@1.5.1: + resolution: {integrity: sha512-zZpFYBqIL1Aqg+f2qmYHJ8+yIZF7/tP6PUGx2/QM0uGPSO5UegpinmkNwDohxWtOj586BpMPVRUjce2HI6xB3A==} + engines: {node: '>=12'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1502,6 +1512,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bezier-js@6.1.4: + resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1536,6 +1549,10 @@ packages: caniuse-lite@1.0.30001662: resolution: {integrity: sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA==} + canvas-color-tracker@1.3.1: + resolution: {integrity: sha512-eNycxGS7oQ3IS/9QQY41f/aQjiO9Y/MtedhCgSdsbLSxC9EyUD8L3ehl/Q3Kfmvt8um79S45PBV+5Rxm5ztdSw==} + engines: {node: '>=12'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -1664,6 +1681,9 @@ packages: resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} engines: {node: '>=12'} + d3-binarytree@1.0.2: + resolution: {integrity: sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==} + d3-brush@3.0.0: resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} engines: {node: '>=12'} @@ -1705,6 +1725,10 @@ packages: resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} engines: {node: '>=12'} + d3-force-3d@3.0.5: + resolution: {integrity: sha512-tdwhAhoTYZY/a6eo9nR7HP3xSW/C6XvJTbeRpR92nlPzH6OiE+4MliN9feuSFd0tPtEUo+191qOhCTWx3NYifg==} + engines: {node: '>=12'} + d3-force@3.0.0: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} engines: {node: '>=12'} @@ -1725,6 +1749,9 @@ packages: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-octree@1.0.2: + resolution: {integrity: sha512-Qxg4oirJrNXauiuC94uKMbgxwnhdda9xRLl9ihq45srlJ4Ga3CSgqGcAL8iW7N5CIv4Oz8x3E734ulxyvHPvwA==} + d3-path@1.0.9: resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} @@ -2036,6 +2063,10 @@ packages: for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + force-graph@1.44.2: + resolution: {integrity: sha512-SthMFx+CdvMM4kwN/KoqmNx1//H5smwukOgQi7VsyqxDQU72vFE6/1/TDqb5NG8UnZjjaKEMcusOnAkpV+4zNA==} + engines: {node: '>=12'} + foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} @@ -2043,6 +2074,9 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + fromentries@1.3.2: + resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2200,6 +2234,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + index-array-by@1.4.2: + resolution: {integrity: sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==} + engines: {node: '>=12'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -2359,6 +2397,10 @@ packages: javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} + jerrypick@1.1.1: + resolution: {integrity: sha512-XTtedPYEyVp4t6hJrXuRKr/jHj8SC4z+4K0b396PMkov6muL+i8IIamJIvZWe3jUspgIJak0P+BaWKawMYNBLg==} + engines: {node: '>=12'} + jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true @@ -2402,6 +2444,10 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + kapsule@1.14.6: + resolution: {integrity: sha512-wSi6tHNOfXrIK2Pvv6BhZ9ukzhbp+XZlOOPWSVGUbqfFsnnli4Eq8FN6TaWJv2e17sY5+fKYVxa4DP2oPGlKhg==} + engines: {node: '>=12'} + katex@0.16.11: resolution: {integrity: sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==} hasBin: true @@ -2769,6 +2815,12 @@ packages: peerDependencies: react: ^18.3.1 + react-force-graph-2d@1.25.6: + resolution: {integrity: sha512-qFarXF1pazVtGUoJul7cecqVGdfLoMQiX/98mewULM9YXDXesDfVExBaLkoz9KHN/guWdoSfH2sTUHQA9hpmDw==} + engines: {node: '>=12'} + peerDependencies: + react: '*' + react-hot-toast@2.4.1: resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} engines: {node: '>=10'} @@ -2795,6 +2847,12 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-kapsule@2.4.1: + resolution: {integrity: sha512-dAPhdUHZWbCH46lF2wHSqtJ3bdmNCYiV10v7QBgzYGAVMup/SZt6WHgP0PzURQvinVRKpq6PmsO4af1W37mQdg==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.13.1' + react-leaflet@4.2.1: resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==} peerDependencies: @@ -3139,6 +3197,9 @@ packages: resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} engines: {node: '>=10'} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyexec@0.3.0: resolution: {integrity: sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==} @@ -4290,6 +4351,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@tweenjs/tween.js@25.0.0': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.25.6 @@ -4575,6 +4638,8 @@ snapshots: abort-controller-x@0.4.3: {} + accessor-fn@1.5.1: {} + acorn-jsx@5.3.2(acorn@8.12.1): dependencies: acorn: 8.12.1 @@ -4697,6 +4762,8 @@ snapshots: balanced-match@1.0.2: {} + bezier-js@6.1.4: {} + binary-extensions@2.3.0: {} brace-expansion@1.1.11: @@ -4733,6 +4800,10 @@ snapshots: caniuse-lite@1.0.30001662: {} + canvas-color-tracker@1.3.1: + dependencies: + tinycolor2: 1.6.0 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -4869,6 +4940,8 @@ snapshots: d3-axis@3.0.0: {} + d3-binarytree@1.0.2: {} + d3-brush@3.0.0: dependencies: d3-dispatch: 3.0.1 @@ -4910,6 +4983,14 @@ snapshots: dependencies: d3-dsv: 3.0.1 + d3-force-3d@3.0.5: + dependencies: + d3-binarytree: 1.0.2 + d3-dispatch: 3.0.1 + d3-octree: 1.0.2 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + d3-force@3.0.0: dependencies: d3-dispatch: 3.0.1 @@ -4928,6 +5009,8 @@ snapshots: dependencies: d3-color: 3.1.0 + d3-octree@1.0.2: {} + d3-path@1.0.9: {} d3-path@3.1.0: {} @@ -5392,6 +5475,23 @@ snapshots: dependencies: is-callable: 1.2.7 + force-graph@1.44.2: + dependencies: + '@tweenjs/tween.js': 25.0.0 + accessor-fn: 1.5.1 + bezier-js: 6.1.4 + canvas-color-tracker: 1.3.1 + d3-array: 3.2.4 + d3-drag: 3.0.0 + d3-force-3d: 3.0.5 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + index-array-by: 1.4.2 + kapsule: 1.14.6 + lodash-es: 4.17.21 + foreground-child@3.3.0: dependencies: cross-spawn: 7.0.3 @@ -5399,6 +5499,8 @@ snapshots: fraction.js@4.3.7: {} + fromentries@1.3.2: {} + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -5552,6 +5654,8 @@ snapshots: imurmurhash@0.1.4: {} + index-array-by@1.4.2: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -5703,6 +5807,8 @@ snapshots: javascript-natural-sort@0.7.1: {} + jerrypick@1.1.1: {} + jiti@1.21.6: {} js-base64@3.7.7: {} @@ -5734,6 +5840,10 @@ snapshots: object.assign: 4.1.5 object.values: 1.2.0 + kapsule@1.14.6: + dependencies: + lodash-es: 4.17.21 + katex@0.16.11: dependencies: commander: 8.3.0 @@ -6123,6 +6233,13 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-force-graph-2d@1.25.6(react@18.3.1): + dependencies: + force-graph: 1.44.2 + prop-types: 15.8.1 + react: 18.3.1 + react-kapsule: 2.4.1(react@18.3.1) + react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: goober: 2.1.14(csstype@3.1.3) @@ -6144,6 +6261,12 @@ snapshots: react-is@18.3.1: {} + react-kapsule@2.4.1(react@18.3.1): + dependencies: + fromentries: 1.3.2 + jerrypick: 1.1.1 + react: 18.3.1 + react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -6556,6 +6679,8 @@ snapshots: throttle-debounce@3.0.1: {} + tinycolor2@1.6.0: {} + tinyexec@0.3.0: {} to-fast-properties@2.0.0: {} diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx index 950496dd..3c0dc86e 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx @@ -3,6 +3,7 @@ import { isEqual } from "lodash-es"; import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon } from "lucide-react"; import { Memo, MemoProperty } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; +import MemoRelationForceGraph from "../MemoRelationForceGraph"; interface Props { memo: Memo; @@ -21,7 +22,13 @@ const MemoDetailSidebar = ({ memo, className }: Props) => { className, )} > -
+
+ {memo.relations.length > 0 && ( +
+ + Relations +
+ )}

Created at diff --git a/web/src/components/MemoEditor/ActionButton/AddMemoRelationPopover.tsx b/web/src/components/MemoEditor/ActionButton/AddMemoRelationPopover.tsx index 0ce91612..df8dfc55 100644 --- a/web/src/components/MemoEditor/ActionButton/AddMemoRelationPopover.tsx +++ b/web/src/components/MemoEditor/ActionButton/AddMemoRelationPopover.tsx @@ -27,7 +27,7 @@ const AddMemoRelationPopover = (props: Props) => { const [isFetching, setIsFetching] = useState(true); const [fetchedMemos, setFetchedMemos] = useState([]); const [selectedMemos, setSelectedMemos] = useState([]); - const [embedded, setEmbedded] = useState(true); + const [embedded, setEmbedded] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); const filteredMemos = fetchedMemos.filter( diff --git a/web/src/components/MemoRelationForceGraph/MemoRelationForceGraph.tsx b/web/src/components/MemoRelationForceGraph/MemoRelationForceGraph.tsx new file mode 100644 index 00000000..6004dc59 --- /dev/null +++ b/web/src/components/MemoRelationForceGraph/MemoRelationForceGraph.tsx @@ -0,0 +1,50 @@ +import { useColorScheme } from "@mui/joy"; +import clsx from "clsx"; +import { useEffect, useRef, useState } from "react"; +import ForceGraph2D from "react-force-graph-2d"; +import { Memo } from "@/types/proto/api/v1/memo_service"; +import { FGMethods } from "./types"; +import { convertMemoRelationsToGraphData } from "./utils"; + +interface Props { + memo: Memo; + className?: string; +} + +const MAIN_NODE_COLOR = "#14b8a6"; +const DEFAULT_NODE_COLOR = "#a1a1aa"; + +const MemoRelationForceGraph = ({ className, memo }: Props) => { + const { mode } = useColorScheme(); + const containerRef = useRef(null); + const graphRef = useRef(undefined); + const [graphSize, setGraphSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + if (!containerRef.current) return; + setGraphSize(containerRef.current.getBoundingClientRect()); + }, []); + + const onNodeClick = () => { + // TODO: Handle node click event + }; + + return ( +

+ (node.name === memo.name ? MAIN_NODE_COLOR : DEFAULT_NODE_COLOR)} + nodeRelSize={3} + linkColor={() => (mode === "light" ? "" : "#525252")} + graphData={convertMemoRelationsToGraphData(memo.relations)} + onNodeClick={onNodeClick} + /> +
+ ); +}; + +export default MemoRelationForceGraph; diff --git a/web/src/components/MemoRelationForceGraph/index.ts b/web/src/components/MemoRelationForceGraph/index.ts new file mode 100644 index 00000000..f89b461b --- /dev/null +++ b/web/src/components/MemoRelationForceGraph/index.ts @@ -0,0 +1,5 @@ +import MemoRelationForceGraph from "./MemoRelationForceGraph"; + +export * from "./utils"; + +export default MemoRelationForceGraph; diff --git a/web/src/components/MemoRelationForceGraph/types.ts b/web/src/components/MemoRelationForceGraph/types.ts new file mode 100644 index 00000000..178500d1 --- /dev/null +++ b/web/src/components/MemoRelationForceGraph/types.ts @@ -0,0 +1,11 @@ +import { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-2d"; + +export interface NodeType { + name: string; +} + +export interface LinkType { + // ...add more additional properties relevant to the link here. +} + +export interface FGMethods extends ForceGraphMethods, LinkObject> {} diff --git a/web/src/components/MemoRelationForceGraph/utils.ts b/web/src/components/MemoRelationForceGraph/utils.ts new file mode 100644 index 00000000..3ebdbeaf --- /dev/null +++ b/web/src/components/MemoRelationForceGraph/utils.ts @@ -0,0 +1,35 @@ +import { GraphData, LinkObject, NodeObject } from "react-force-graph-2d"; +import { MemoRelation } from "@/types/proto/api/v1/memo_relation_service"; +import { LinkType, NodeType } from "./types"; + +export const convertMemoRelationsToGraphData = (memoRelations: MemoRelation[]): GraphData => { + const nodesMap = new Map>(); + const links: LinkObject[] = []; + + // Iterate through memoRelations to populate nodes and links. + memoRelations.forEach((relation) => { + const { memo, relatedMemo, type } = relation; + + // Add memo node if not already present. + if (!nodesMap.has(memo)) { + nodesMap.set(memo, { id: memo, name: memo }); + } + + // Add related_memo node if not already present. + if (!nodesMap.has(relatedMemo)) { + nodesMap.set(relatedMemo, { id: relatedMemo, name: relatedMemo }); + } + + // Create link between memo and relatedMemo. + links.push({ + source: memo, + target: relatedMemo, + type, // Include the type of relation as a property of the link. + }); + }); + + return { + nodes: Array.from(nodesMap.values()), + links, + }; +};