diff --git a/app/(dashboard)/enroll/[repositoryId]/graph/ContributorAvatar.tsx b/app/(dashboard)/enroll/[repositoryId]/graph/ContributorAvatar.tsx
new file mode 100644
index 0000000..3317002
--- /dev/null
+++ b/app/(dashboard)/enroll/[repositoryId]/graph/ContributorAvatar.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import Image from "next/image";
+
+import { useState } from "react";
+
+interface Contributor {
+ avatarUrl: string;
+ name: string;
+ points: number;
+ githubId: string;
+}
+
+interface Position {
+ x: number;
+ y: number;
+ size: number;
+}
+
+interface ContributorAvatarProps {
+ contributor: Contributor;
+ position: Position;
+}
+
+export function ContributorAvatar({
+ contributor,
+ position,
+}: ContributorAvatarProps) {
+ const [hasError, setHasError] = useState(false);
+
+ const handleClick = (url: string) => {
+ window.open(url, "_blank", "noopener,noreferrer");
+ };
+
+ return (
+
handleClick(`https://github.com/${contributor.githubId}`)}
+ role="link"
+ aria-label={`View ${contributor.name}'s GitHub profile`}
+ >
+ {hasError ? (
+
+ {contributor.name[0]}
+
+ ) : (
+
setHasError(true)}
+ placeholder="blur"
+ blurDataURL="/placeholder.svg?height=100&width=100"
+ />
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/app/(dashboard)/enroll/[repositoryId]/graph/page.tsx b/app/(dashboard)/enroll/[repositoryId]/graph/page.tsx
new file mode 100644
index 0000000..61882a9
--- /dev/null
+++ b/app/(dashboard)/enroll/[repositoryId]/graph/page.tsx
@@ -0,0 +1,125 @@
+import { ContributorAvatar } from "./ContributorAvatar";
+import {db} from "@/lib/db";
+
+export const dynamic = "force-dynamic";
+
+interface Contributor {
+ avatarUrl: string;
+ name: string;
+ points: number;
+ githubId: string;
+}
+
+interface Position {
+ x: number;
+ y: number;
+ size: number;
+}
+
+async function getData(repositoryId: string) {
+ const pointTransactions = await db.pointTransaction.groupBy({
+ by: ['userId'],
+ where: {
+ repositoryId,
+ },
+ _sum: {
+ points: true,
+ },
+ });
+
+ const users = await db.user.findMany({
+ where: {
+ id: {
+ in: pointTransactions.map((entry) => entry.userId),
+ },
+ },
+ select: {
+ avatarUrl: true,
+ name: true,
+ githubId: true,
+ },
+ });
+
+ return users.map((user) => ({
+ avatarUrl: user.avatarUrl || '',
+ name: user.name || '',
+ points: pointTransactions.find((entry) => entry.userId === user.id)?._sum.points || 0,
+ githubId: user.githubId.toString(),
+ })).filter((c) => c.avatarUrl && c.name && c.points > 0);
+}
+
+export default async function Component() {
+ const contributors = await getData();
+
+ // Move these calculations to server
+ const maxPoints = Math.max(...contributors.map((c) => c.points));
+
+ const minSize = 40;
+ const maxSize = 180;
+
+ const checkCollision = (positions: Position[], newPos: Position): boolean => {
+ for (const pos of positions) {
+ const dx = newPos.x - pos.x;
+ const dy = newPos.y - pos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ if (distance < (newPos.size + pos.size) / 2) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const generatePositions = (contributors: Contributor[]): Position[] => {
+ const positions: Position[] = [];
+ const containerWidth = 1600;
+ const containerHeight = 900;
+ const maxAttempts = 100;
+
+ for (const contributor of contributors) {
+ const size =
+ minSize +
+ Math.sqrt(contributor.points / maxPoints) * (maxSize - minSize);
+ let newPos: Position | null = null;
+ let attempts = 0;
+
+ while (!newPos && attempts < maxAttempts) {
+ const x = Math.random() * (containerWidth - size);
+ const y = Math.random() * (containerHeight - size);
+ const testPos = { x, y, size };
+
+ if (!checkCollision(positions, testPos)) {
+ newPos = testPos;
+ }
+
+ attempts++;
+ }
+
+ if (newPos) {
+ positions.push(newPos);
+ }
+ }
+
+ return positions;
+ };
+
+ const positions = generatePositions(contributors);
+
+ return (
+
+
+ {contributors.map((contributor, index) => {
+ const position = positions[index];
+ if (!position) return null;
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file