Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ランダムに木を生成する #8

Merged
merged 13 commits into from
Oct 22, 2024
172 changes: 125 additions & 47 deletions app/graph/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,98 +2,146 @@

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<cytoscape.Core>();
const [graphText, setGraphText] = useState("3\n1 2");
const [treeSizeText, setTreeSizeText] = useState("");
const [layout, setLayout] = useState<Layout>("random");
const [indexing, setIndexing] = useState<Indexing>("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 | Error>(null);
const [parseError, setParseError] = useState<null | ParseError>(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<HTMLTextAreaElement>) => {
const value = e.target.value;
setGraphText(value);
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) => {
setIndexing(ind);
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<typeof parseGraph>;
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);
}
};

const elements: Element[] = [];
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}` },
});
}

Expand All @@ -103,26 +151,51 @@ export default function Graph() {
htmlFor="graph-text"
className="block text-sm font-medium leading-6 text-gray-900"
>
Graph Input
Graph input
</label>
<div className="mt-2">
<textarea
id="graph-text"
rows={4}
className={clsx(
"block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6",
error && "text-red-900 ring-red-300 focus:ring-red-500",
parseError && "text-red-900 ring-red-300 focus:ring-red-500",
)}
value={graphText}
onChange={handleTextAreaChange}
/>
</div>
{error && (
{parseError && (
<div className="text-sm text-red-700">
line {error.line + 1}: {error.message}
line {parseError.line + 1}: {parseError.message}
</div>
)}
<VSpace size="M" />
<label
htmlFor="tree-size"
className="block text-sm font-medium leading-6 text-gray-900"
>
Tree size
</label>
<div className="mt-2 flex rounded-md shadow-sm">
<input
id="tree-size"
type="number"
inputMode="numeric"
min={TREE_SIZE_MIN}
value={treeSizeText}
onChange={(e) => setTreeSizeText(e.target.value)}
className="block w-full rounded-none rounded-l-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
<button
type="button"
onClick={handleTreeGenerateClick}
className="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
Generate
</button>
</div>
<VSpace size="M" />
<fieldset>
<legend className="text-sm font-semibold leading-6 text-gray-900">
Layout
Expand Down Expand Up @@ -175,7 +248,7 @@ export default function Graph() {
<VSpace size="M" />
<fieldset>
<legend className="text-sm font-semibold leading-6 text-gray-900">
Edge Type
Edge type
</legend>
<VSpace size="S" />
<Field className="flex items-center">
Expand Down Expand Up @@ -206,7 +279,7 @@ export default function Graph() {
{
selector: "node",
style: {
label: "data(label)",
label: "data(id)",
color: "white",
"text-halign": "center",
"text-valign": "center",
Expand All @@ -225,3 +298,8 @@ export default function Graph() {
</>
);
}

// [min, max)
function getRandomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min) + min);
}
51 changes: 51 additions & 0 deletions app/graph/prufer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, test } from "vitest";
import { pruferDecode } from "./prufer";

test.each([
{
// 0 -- 1
code: [],
edges: [[0, 1]],
},
{
// 1 -- 0 -- 2
code: [0],
edges: [
[0, 1],
[0, 2],
],
},
{
// 0 -- 1 -- 2
code: [1],
edges: [
[1, 0],
[1, 2],
],
},
{
// 0 -- 2 -- 1
code: [2],
edges: [
[2, 0],
[1, 2],
],
},
{
// 1
// |
// 0 -- 3 -- 4 -- 5
// |
// 2
code: [3, 3, 3, 4],
edges: [
[3, 0],
[3, 1],
[3, 2],
[4, 3],
[4, 5],
],
},
])("$code", ({ code, edges }) => {
expect(pruferDecode(code)).toStrictEqual(edges);
});
23 changes: 23 additions & 0 deletions app/graph/prufer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// https://en.wikipedia.org/wiki/Pr%C3%BCfer_sequence

export function pruferDecode(code: number[]): [number, number][] {
const n = code.length + 2;
const deg = new Array(n).fill(1);
for (const x of code) {
deg[x]++;
}

const edges: [number, number][] = [];
for (const x of code) {
const y = deg.findIndex((d) => d === 1);
deg[x]--;
deg[y]--;
edges.push([x, y]);
}

const x = deg.findIndex((d) => d === 1);
const y = deg.findLastIndex((d) => d === 1);
edges.push([x, y]);

return edges;
}
1 change: 0 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"target": "ES6",
"allowJs": true,
"skipLibCheck": true,
"strict": true,
Expand Down