Skip to content

Commit a91bcdb

Browse files
authored
Merge pull request #3 from ia7ck/graph-input
textarea への入力からグラフを表示する
2 parents 4e0b1f0 + 2e5068b commit a91bcdb

File tree

7 files changed

+1382
-40
lines changed

7 files changed

+1382
-40
lines changed

.github/workflows/ci.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,14 @@ jobs:
1818
uses: biomejs/setup-biome@v2
1919
- name: Run Biome
2020
run: biome ci .
21+
test:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- name: Checkout
25+
uses: actions/checkout@v4
26+
- name: Install Node
27+
uses: actions/setup-node@v4
28+
- name: Install Deps
29+
run: npm clean-install
30+
- name: Test
31+
run: npm run test

app/graph/layout.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Metadata } from "next";
2+
3+
export const metadata: Metadata = {
4+
title: "Graph",
5+
};
6+
7+
export default function Layout({ children }: { children: React.ReactNode }) {
8+
return <>{children}</>;
9+
}

app/graph/page.tsx

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,61 @@
11
"use client";
22

3+
import { VSpace } from "@/components/VSpace";
4+
import type cytoscape from "cytoscape";
5+
import { useRef, useState } from "react";
36
import CytoscapeComponent from "react-cytoscapejs";
7+
import { parseGraph } from "./parse";
48

59
export default function Graph() {
6-
const elements = [
7-
{ data: { id: "one", label: "Node 1" }, position: { x: 100, y: 100 } },
8-
{ data: { id: "two", label: "Node 2" }, position: { x: 200, y: 200 } },
9-
{
10-
data: { source: "one", target: "two", label: "Edge from Node1 to Node2" },
11-
},
12-
];
10+
const cyRef = useRef<cytoscape.Core>();
11+
const [graphText, setGraphText] = useState("");
12+
const [elements, setElements] = useState<cytoscape.ElementDefinition[]>([]);
13+
14+
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
15+
const value = e.target.value;
16+
const graph = parseGraph(value);
17+
if (graph !== null) {
18+
const width = cyRef.current?.width() ?? 0;
19+
const height = cyRef.current?.height() ?? 0;
20+
const newElements: typeof elements = [];
21+
for (let i = 1; i <= graph.n; i++) {
22+
newElements.push({
23+
data: { id: `${i}`, label: `Node ${i}` },
24+
renderedPosition: {
25+
x: (Math.random() + 0.5) * (width / 2),
26+
y: (Math.random() + 0.5) * (height / 2),
27+
},
28+
});
29+
}
30+
for (const e of graph.edges) {
31+
newElements.push({ data: { source: `${e.from}`, target: `${e.to}` } });
32+
}
33+
setElements(newElements);
34+
}
35+
setGraphText(value);
36+
};
37+
1338
return (
14-
<CytoscapeComponent
15-
elements={elements}
16-
style={{ width: "600px", height: "600px" }}
17-
/>
39+
<>
40+
<textarea value={graphText} onChange={handleTextAreaChange} />
41+
<div />
42+
<button
43+
type="button"
44+
onClick={() => {
45+
cyRef.current?.layout({ name: "random" }).run();
46+
}}
47+
>
48+
layout
49+
</button>
50+
<CytoscapeComponent
51+
className="border"
52+
cy={(cy) => {
53+
cyRef.current = cy;
54+
}}
55+
elements={elements}
56+
style={{ width: "100%", height: "80vh" }}
57+
/>
58+
<VSpace size="L" />
59+
</>
1860
);
1961
}

app/graph/parse.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, test } from "vitest";
2+
import { parseGraph } from "./parse";
3+
4+
describe("valid", () => {
5+
test("single node", () => {
6+
expect(parseGraph("1")).toStrictEqual({ n: 1, edges: [] });
7+
8+
expect(
9+
parseGraph(`1
10+
1 1`),
11+
).toStrictEqual({ n: 1, edges: [{ from: 1, to: 1 }] });
12+
13+
expect(
14+
parseGraph(`1
15+
1 1
16+
1 1`),
17+
).toStrictEqual({
18+
n: 1,
19+
edges: [
20+
{ from: 1, to: 1 },
21+
{ from: 1, to: 1 },
22+
],
23+
});
24+
});
25+
26+
test("two nodes", () => {
27+
expect(parseGraph("2")).toStrictEqual({ n: 2, edges: [] });
28+
29+
expect(
30+
parseGraph(`2
31+
1 2`),
32+
).toStrictEqual({ n: 2, edges: [{ from: 1, to: 2 }] });
33+
34+
expect(
35+
parseGraph(`2
36+
1 2
37+
2 1`),
38+
).toStrictEqual({
39+
n: 2,
40+
edges: [
41+
{ from: 1, to: 2 },
42+
{ from: 2, to: 1 },
43+
],
44+
});
45+
});
46+
});
47+
48+
describe("invalid", () => {
49+
test.each([
50+
["0"],
51+
["-1"],
52+
["0.5"],
53+
["ABC"],
54+
[
55+
`4
56+
1 `,
57+
],
58+
[
59+
`4
60+
1 0.5`,
61+
],
62+
[
63+
`4
64+
-1 2`,
65+
],
66+
[
67+
`4
68+
2 7`,
69+
],
70+
[
71+
`4
72+
1 AAA`,
73+
],
74+
])('"%s"', (input) => {
75+
expect(parseGraph(input)).toBe(null);
76+
});
77+
});

app/graph/parse.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { z } from "zod";
2+
3+
type Graph = {
4+
n: number;
5+
edges: { from: number; to: number }[];
6+
};
7+
8+
const PositiveInt = z.coerce.number().int().positive();
9+
10+
export function parseGraph(input: string): Graph | null {
11+
const lines = input.trimEnd().split("\n");
12+
13+
const n = PositiveInt.safeParse(lines[0].trim());
14+
if (n.error) {
15+
return null;
16+
}
17+
18+
const NodeIndex = PositiveInt.max(n.data);
19+
const Edge = z.object({
20+
from: NodeIndex,
21+
to: NodeIndex,
22+
});
23+
const edges: Graph["edges"] = [];
24+
for (let i = 1; i < lines.length; i++) {
25+
const [from, to] = lines[i].trim().split(" ");
26+
const e = Edge.safeParse({ from, to });
27+
if (e.error) {
28+
return null;
29+
}
30+
edges.push(e.data);
31+
}
32+
33+
return { n: n.data, edges };
34+
}

0 commit comments

Comments
 (0)