diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 9337824..0807141 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -2,52 +2,63 @@ import { Field, Label, Switch } from "@headlessui/react"; import clsx from "clsx"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { z } from "zod"; import CytoscapeComponent from "react-cytoscapejs"; import { VSpace } from "@/components/VSpace"; import { parseGraph } from "./parse"; +import { pruferDecode } from "./prufer"; -export default function Graph() { - const layouts = ["random", "grid", "circle"] as const; - type Layout = (typeof layouts)[number]; - const indexings = ["0-indexed", "1-indexed"] as const; - type Indexing = (typeof indexings)[number]; - - type Graph = { - n: number; - edges: { from: number; to: number }[]; - }; - type Params = { - text: string; - option: { indexing: Indexing }; - }; - // parse error - type Error = { - line: number; - message: string; - }; +const TREE_SIZE_MIN = 1; +const TreeSize = z.coerce.number().int().min(TREE_SIZE_MIN); - type Element = - | { - group: "nodes"; - data: { id: string; label: string }; - renderedPosition: { x: number; y: number }; - } - | { group: "edges"; data: { id: string; source: string; target: string } }; +const layouts = ["random", "grid", "circle"] as const; +type Layout = (typeof layouts)[number]; +const indexings = ["0-indexed", "1-indexed"] as const; +type Indexing = (typeof indexings)[number]; + +type Graph = { + n: number; + edges: { from: number; to: number }[]; +}; +type ParseParams = { + option: { indexing: Indexing }; +}; +type ParseError = { + line: number; + message: string; +}; + +type Element = + | { + group: "nodes"; + data: { id: string }; + renderedPosition: { x: number; y: number }; + } + | { group: "edges"; data: { source: string; target: string } }; +export default function Graph() { const cyRef = useRef(); const [graphText, setGraphText] = useState("3\n1 2"); + const [treeSizeText, setTreeSizeText] = useState(""); const [layout, setLayout] = useState("random"); const [indexing, setIndexing] = useState("1-indexed"); const [directed, setDirected] = useState(true); // 最後に parse が成功したときのグラフとパラメータ - const [graph, setGraph] = useState<{ data: Graph; params: Params }>({ + const [graph, setGraph] = useState<{ data: Graph; params: ParseParams }>({ data: { n: 3, edges: [{ from: 1, to: 2 }] }, - params: { text: graphText, option: { indexing } }, + params: { option: { indexing } }, }); - const [error, setError] = useState(null); + const [parseError, setParseError] = useState(null); + // むー 何かおかしい気がする + const [forceReLayout, setForceReLayout] = useState(0); + + // biome-ignore lint/correctness/useExhaustiveDependencies(forceReLayout): CytoscapeComponent に elements が入ったあとに layout.run を実行する必要がある + useEffect(() => { + cyRef.current?.layout({ name: layout }).run(); + }, [layout, forceReLayout]); const handleTextAreaChange = (e: React.ChangeEvent) => { const value = e.target.value; @@ -55,9 +66,32 @@ export default function Graph() { updateGraph(value, { indexing }); }; + const handleTreeGenerateClick = () => { + const treeSize = TreeSize.safeParse(treeSizeText); + if (treeSize.success) { + const n = treeSize.data; + const code: number[] = []; + for (let i = 0; i < n - 2; i++) { + code.push(getRandomInt(0, n)); + } + const treeEdges = n === 0 ? [] : pruferDecode(code); + const edges = treeEdges.map(([from, to]) => { + if (indexing === "1-indexed") { + return { from: from + 1, to: to + 1 }; + } + return { from, to }; + }); + const text = `${n}\n${edges.map((e) => `${e.from} ${e.to}`).join("\n")}`; + setGraphText(text); + setDirected(false); + updateGraph({ n, edges }, { indexing }); + } else { + // TODO + } + }; + const handleLayoutChange = (ly: Layout) => { setLayout(ly); - cyRef.current?.layout({ name: ly }).run(); }; const handleIndexingChange = (ind: Indexing) => { @@ -65,13 +99,27 @@ export default function Graph() { updateGraph(graphText, { indexing: ind }); }; - const updateGraph = (text: string, option: { indexing: Indexing }) => { - const graph = parseGraph(text, { indexStart: option.indexing }); - if (graph.ok) { - setGraph({ data: graph.data, params: { text, option } }); - setError(null); + const updateGraph = (g: string | Graph, option: { indexing: Indexing }) => { + let newGraph: ReturnType; + if (typeof g === "string") { + newGraph = parseGraph(g, { indexStart: option.indexing }); } else { - setError(graph.error); + newGraph = { ok: true, data: g }; + } + + if (newGraph.ok) { + setGraph({ data: newGraph.data, params: { option } }); + setParseError(null); + // 頂点が増減したときには layout しなおすのがよさそう + // indexing が変わると頂点 0 が増えたり消えたりする + if ( + newGraph.data.n !== graph.data.n || + option.indexing !== graph.params.option.indexing + ) { + setForceReLayout(forceReLayout + 1); + } + } else { + setParseError(newGraph.error); } }; @@ -79,21 +127,21 @@ export default function Graph() { const width = cyRef.current?.width() ?? 300; const height = cyRef.current?.height() ?? 400; for (let i = 1; i <= graph.data.n; i++) { - const label = + const id = graph.params.option.indexing === "0-indexed" ? `${i - 1}` : `${i}`; elements.push({ group: "nodes", - data: { id: `n${label}`, label }, + data: { id }, renderedPosition: { x: (Math.random() + 0.5) * (width / 2), y: (Math.random() + 0.5) * (height / 2), }, }); } - for (const [i, e] of graph.data.edges.entries()) { + for (const e of graph.data.edges) { elements.push({ group: "edges", - data: { id: `e${i}`, source: `n${e.from}`, target: `n${e.to}` }, + data: { source: `${e.from}`, target: `${e.to}` }, }); } @@ -103,7 +151,7 @@ export default function Graph() { htmlFor="graph-text" className="block text-sm font-medium leading-6 text-gray-900" > - Graph Input + Graph input