From 862f012a6512cb9a367dd35c0537fcda3309ffaf Mon Sep 17 00:00:00 2001 From: moririn2528 Date: Thu, 7 Sep 2023 17:07:24 +0900 Subject: [PATCH] add elkjs to position nodes automatically --- .../ts/components/PreferentialGraph.tsx | 304 ++++++++++-------- package-lock.json | 11 + package.json | 1 + 3 files changed, 180 insertions(+), 136 deletions(-) diff --git a/optuna_dashboard/ts/components/PreferentialGraph.tsx b/optuna_dashboard/ts/components/PreferentialGraph.tsx index 1b2b641c..defe8369 100644 --- a/optuna_dashboard/ts/components/PreferentialGraph.tsx +++ b/optuna_dashboard/ts/components/PreferentialGraph.tsx @@ -1,5 +1,12 @@ import React, { FC, useState, useCallback, useMemo, useEffect } from "react" -import { Card, CardContent, CardHeader, useTheme } from "@mui/material" +import { + Card, + CardContent, + useTheme, + Typography, + Box, + Chip, +} from "@mui/material" import { MarkdownRenderer } from "./Note" import ReactFlow, { Node, @@ -12,17 +19,22 @@ import ReactFlow, { MiniMap, Position, Handle, - XYPosition, } from "reactflow" import "reactflow/dist/style.css" +import ELK from "elkjs/lib/elk.bundled.js" +import { ElkNode } from "elkjs/lib/elk-api.js" +const elk = new ELK() const nodeWidth = 400 const nodeHeight = 300 -const nodeMargin = 50 +const nodeMargin = 60 + type NodeData = { trial?: Trial + isBest: boolean } const GraphNode: FC> = ({ data, isConnectable }) => { + const theme = useTheme() const trial = data.trial if (trial === undefined) { return null @@ -39,7 +51,25 @@ const GraphNode: FC> = ({ data, isConnectable }) => { overflow: "hidden", }} > - + + Trial {trial.number} + {data.isBest && ( + + )} + > = ({ data, isConnectable }) => { const nodeTypes: NodeTypes = { note: GraphNode, } - -const createNode = ( - x: number, - y: number, - trial: Trial, - bestGroupPos: XYPosition, - isBest: boolean -): Node => { - return { - id: `${trial.number}`, - type: "note", - data: { - label: `Trial ${trial.number}`, - trial: trial, - }, - position: { - x: bestGroupPos.x + nodeMargin + x * (nodeWidth + nodeMargin), - y: bestGroupPos.y + nodeMargin + y * (nodeHeight + nodeMargin), - }, - style: { - width: nodeWidth, - height: nodeHeight, - padding: 0, - }, - parentNode: isBest ? "bestGroup" : undefined, - } -} -const updateNode = ( - addX: number, - addY: number, - node: Node, - trial: Trial, - isBest: boolean -): Node => { - return { - ...node, - position: { - x: node.position.x + addX * (nodeWidth + nodeMargin), - y: node.position.y + addY * (nodeHeight + nodeMargin), - }, - data: { - ...node.data, - trial: trial, - }, - parentNode: isBest ? "bestGroup" : undefined, - } -} - -const initNodes: Node[] = [ - { - id: "bestGroup", - type: "default", - position: { - x: 0, - y: 0, - }, - data: { - label: "Best Trials", - }, - style: { - width: 2 * nodeMargin, - height: nodeHeight + 2 * nodeMargin, - padding: 0, - backgroundColor: "rgb(255,0,0,0.1)", - }, - }, -] - const defaultEdgeOptions: DefaultEdgeOptions = { animated: true, } +function reductionPreference( + input_preferences: [number, number][] +): [number, number][] { + const preferences: [number, number][] = [] + let n = 0 + for (const [source, target] of input_preferences) { + if ( + preferences.find((p) => p[0] === source && p[1] === target) !== undefined + ) { + continue + } + n = Math.max(n - 1, source, target) + 1 + preferences.push([source, target]) + } + if (n === 0) { + return [] + } + const graph: number[][] = Array.from({ length: n }, () => []) + const reverseGraph: number[][] = Array.from({ length: n }, () => []) + const degree: number[] = Array.from({ length: n }, () => 0) + for (const [source, target] of preferences) { + graph[source].push(target) + reverseGraph[target].push(source) + degree[target]++ + } + const topologicalOrder: number[] = [] + const q: number[] = [] + for (let i = 0; i < n; i++) { + if (degree[i] === 0) { + q.push(i) + } + } + while (q.length > 0) { + const v = q.pop() + if (v === undefined) break + topologicalOrder.push(v) + graph[v].forEach((u) => { + degree[u]-- + if (degree[u] === 0) { + q.push(u) + } + }) + } + if (topologicalOrder.length !== n) { + console.error("cycle detected") + return [] + } + + const response: [number, number][] = [] + const descendants: Set[] = Array.from( + { length: n }, + () => new Set() + ) + topologicalOrder.reverse().forEach((v) => { + const descendant = new Set([v]) + graph[v].forEach((u) => { + descendants[u].forEach((d) => descendant.add(d)) + }) + graph[v].forEach((u) => { + if (reverseGraph[u].filter((d) => descendant.has(d)).length === 1) { + response.push([v, u]) + } + }) + descendants[v] = descendant + }) + return response +} + export const PreferentialGraph: FC<{ studyDetail: StudyDetail | null }> = ({ studyDetail }) => { const theme = useTheme() - const [nodes, setNodes] = useState(initNodes) + const [nodes, setNodes] = useState([]) const [edges, setEdges] = useState([]) - const [historyCount, setHistoryCount] = useState(0) const onNodesChange: OnNodesChange = useCallback( (changes) => setNodes((nds) => applyNodeChanges(changes, nds)), [setNodes] @@ -147,70 +176,67 @@ export const PreferentialGraph: FC<{ useEffect(() => { if (studyDetail === null) return - const newHistoryCount = - (studyDetail.preference_history?.length ?? 0) - historyCount - if (newHistoryCount === 0) return - - setNodes((prev) => { - const newNodes: Node[] = [] - const appendIds: string[] = studyDetail.best_trials.map((t) => - t.number.toString() - ) // 新しく追加する Node の id, これと "bestGroup" 以外は newHistoryCount だけ下にスライドする - studyDetail.preference_history?.slice(historyCount).forEach((history) => { - appendIds.push(history.clicked.toString()) - }) - - const bestGroup = prev.find((node) => node.id === "bestGroup") - const bestGroupPos = bestGroup?.position ?? { x: 0, y: 0 } - console.log(bestGroupPos) - if (bestGroup !== undefined) { - newNodes.push({ - ...bestGroup, - style: { - ...bestGroup.style, - width: - nodeMargin + - studyDetail.best_trials.length * (nodeWidth + nodeMargin), - background: "rgb(255,200,200,0.1)", - }, - }) - } - - prev.forEach((node) => { - if (appendIds.includes(node.id)) return - if (node.id === "bestGroup") return - const trialNum = parseInt(node.id, 10) - if (node.id !== `${trialNum}`) { - console.error(`node.id is not trual number: ${node.id}`) - return - } - const trial = studyDetail.trials[trialNum] - newNodes.push(updateNode(0, newHistoryCount, node, trial, false)) - }) - const histories = studyDetail.preference_history?.slice(historyCount) - histories?.reverse().forEach((history, i) => { - const x = history.candidates.findIndex((c) => c === history.clicked) - newNodes.push( - createNode( - x, - i + 1, - studyDetail.trials[history.clicked], - bestGroupPos, - false - ) + if (!studyDetail.is_preferential || studyDetail.preferences === undefined) + return + const preferences = reductionPreference(studyDetail.preferences) + const graph: ElkNode = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.layered.spacing.nodeNodeBetweenLayers": nodeMargin.toString(), + "elk.spacing.nodeNode": nodeMargin.toString(), + }, + children: studyDetail.trials.map((trial) => ({ + id: `${trial.number}`, + targetPosition: "top", + sourcePosition: "bottom", + width: nodeWidth, + height: nodeHeight, + })), + edges: preferences.map(([source, target]) => ({ + id: `e${source}-${target}`, + sources: [`${source}`], + targets: [`${target}`], + style: { stroke: isDarkMode ? "#fff" : "#000" }, + })), + } + elk + .layout(graph) + .then((layoutedGraph) => { + setNodes( + layoutedGraph.children?.map((node, index) => { + const trial = studyDetail.trials[index] + return { + id: `${trial.number}`, + type: "note", + data: { + label: `Trial ${trial.number}`, + trial: trial, + isBest: + studyDetail.best_trials.find( + (t) => t.number === trial.number + ) !== undefined, + }, + position: { + x: node.x ?? 0, + y: node.y ?? 0, + }, + style: { + width: nodeWidth, + height: nodeHeight, + padding: 0, + }, + deletable: false, + connectable: false, + draggable: false, + } + }) ?? [] ) }) - studyDetail.best_trials.forEach((trial, i) => { - newNodes.push(createNode(i, 0, trial, bestGroupPos, true)) - }) - return newNodes - }) - setHistoryCount(studyDetail.preference_history?.length ?? 0) - }, [studyDetail]) - useEffect(() => { - if (studyDetail?.preferences === undefined) return + .catch(console.error) setEdges( - studyDetail?.preferences?.map((p) => { + preferences.map((p) => { return { id: `e${p[0]}-${p[1]}`, source: `${p[0]}`, @@ -219,7 +245,7 @@ export const PreferentialGraph: FC<{ } as Edge }) ?? [] ) - }, [studyDetail?.preferences, isDarkMode]) + }, [studyDetail, isDarkMode]) if (studyDetail === null || !studyDetail.is_preferential) { return null @@ -233,6 +259,12 @@ export const PreferentialGraph: FC<{ nodeTypes={nodeTypes} zoomOnScroll={false} panOnScroll={true} + minZoom={0.1} + defaultViewport={{ + x: 0, + y: 0, + zoom: 0.5, + }} > diff --git a/package-lock.json b/package-lock.json index c2bda719..a3f13d65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@react-three/fiber": "^8.13.6", "@types/three": "^0.154.0", "axios": "^1.2.1", + "elkjs": "^0.8.2", "notistack": "^3.0.1", "plotly.js-dist-min": "^2.22.0", "react": "^18.2.0", @@ -6035,6 +6036,11 @@ "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", "dev": true }, + "node_modules/elkjs": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.8.2.tgz", + "integrity": "sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -19690,6 +19696,11 @@ "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", "dev": true }, + "elkjs": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.8.2.tgz", + "integrity": "sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==" + }, "emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", diff --git a/package.json b/package.json index acbb0622..a675a1ce 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@react-three/fiber": "^8.13.6", "@types/three": "^0.154.0", "axios": "^1.2.1", + "elkjs": "^0.8.2", "notistack": "^3.0.1", "plotly.js-dist-min": "^2.22.0", "react": "^18.2.0",