Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8234e89
Initial plan
Copilot Jan 17, 2026
f26e814
Add BubbleGraph component with animated bubble visualization
Copilot Jan 17, 2026
1668596
Address code review feedback - fix timeout cleanup and state management
Copilot Jan 17, 2026
ba66983
Fix main.py
alieron Jan 17, 2026
021a509
fixed server imports
YeeShin504 Jan 17, 2026
5fcc5d1
fixed server imports
YeeShin504 Jan 17, 2026
5825486
Initial plan
Copilot Jan 17, 2026
a8116c5
Implement interactive word graph with all major UI enhancements
Copilot Jan 17, 2026
9f2f19a
Fix TypeScript and ESLint issues
Copilot Jan 17, 2026
264fac4
Address code review feedback: improve type safety and code quality
Copilot Jan 17, 2026
43e62a8
Merge pull request #4 from alieron/copilot/enhance-bubble-graph-ui
KenOKK3003 Jan 17, 2026
4d54d79
graph nodes
KenOKK3003 Jan 17, 2026
bac4d60
added similarity component
YeeShin504 Jan 17, 2026
bf01ebe
add python deps
YeeShin504 Jan 17, 2026
af43098
merge server
YeeShin504 Jan 17, 2026
2a4ba04
linked frontend and backend
KenOKK3003 Jan 17, 2026
288ae0e
Merge remote changes and resolve conflict in game.py
KenOKK3003 Jan 17, 2026
b58b603
Change colour scheme to maroon and white and fix name
farbutnear Jan 17, 2026
af5c173
Enhance UI
farbutnear Jan 17, 2026
96823af
added node library
KenOKK3003 Jan 17, 2026
14ca013
Merge pull request #5 from alieron/redesign-game-screen-ui
farbutnear Jan 17, 2026
3b3b4d4
Merge branch 'copilot/redesign-game-screen-ui' of https://github.com/…
KenOKK3003 Jan 17, 2026
9fe2946
update shortest path log
YeeShin504 Jan 18, 2026
431afd8
fix shortest path log
YeeShin504 Jan 18, 2026
e8c003e
added quit screen
YeeShin504 Jan 18, 2026
45ffc9d
add leaderboard
YeeShin504 Jan 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,447 changes: 1,271 additions & 176 deletions client/package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/d3": "^7.4.3",
"d3": "^7.9.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"reactflow": "^11.11.4",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
Expand All @@ -22,10 +23,13 @@
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions client/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
74 changes: 67 additions & 7 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,81 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import StartScreen from './components/StartScreen';
import GameScreen from './components/GameScreen';
import EndScreen from './components/EndScreen';
import QuitScreen from './components/QuitScreen';
import LeaderboardScreen from './components/LeaderboardScreen';
import './App.css';

type Screen = 'start' | 'game' | 'end';
type Screen = 'start' | 'game' | 'end' | 'quit' | 'leaderboard';

interface GameConfig {
startWord: string;
targetWord: string;
playerName: string;
gameId: string;
shortestPath?: string[];
shortestPathString?: string;
optimalDistance?: number;
}

interface LeaderboardEntry {
playerName: string;
timeSeconds: number;
moves: number;
optimalDistance: number;
score: number;
}

function App() {
const [screen, setScreen] = useState<Screen>('start');
const [gameConfig, setGameConfig] = useState<GameConfig | null>(null);
const [gameResult, setGameResult] = useState<any>(null);
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
const [leaderboardLoaded, setLeaderboardLoaded] = useState(false);

const startGame = (config: GameConfig) => {
setGameConfig(config);
setScreen('game');
};

const endGame = (result: any) => {
const endGame = async (result: any) => {
setGameResult(result);
setScreen('end');
if (result.quit) {
setScreen('quit');
} else {
// Record leaderboard entry in Redis
if (result.playerName && result.timeSeconds !== undefined && result.moves !== undefined && result.optimalDistance) {
try {
await fetch('/api/leaderboard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerName: result.playerName,
timeSeconds: result.timeSeconds,
moves: result.moves,
optimalDistance: result.optimalDistance,
}),
});
} catch (e) {
// Optionally handle error
}
}
setScreen('end');
}
};
const showLeaderboard = useCallback(async () => {
try {
const res = await fetch('/api/leaderboard');
if (res.ok) {
const data = await res.json();
setLeaderboard(data);
setLeaderboardLoaded(true);
}
} catch (e) {
// Optionally handle error
}
setScreen('leaderboard');
}, []);

const restart = () => {
setGameConfig(null);
Expand All @@ -34,21 +84,31 @@ function App() {
};

return (
<div className="min-h-screen bg-gradient-to-br from-purple-500 to-pink-500">
<div className="min-h-screen bg-maroon-950">
{screen === 'start' && <StartScreen onStart={startGame} />}
{screen === 'game' && gameConfig && (
<GameScreen
startWord={gameConfig.startWord}
targetWord={gameConfig.targetWord}
playerName={gameConfig.playerName}
gameId={gameConfig.gameId}
shortestPath={gameConfig.shortestPath}
shortestPathString={gameConfig.shortestPathString}
optimalDistance={gameConfig.optimalDistance}
onComplete={endGame}
/>
)}
{screen === 'end' && gameResult && (
<EndScreen result={gameResult} onRestart={restart} />
<EndScreen result={gameResult} onRestart={restart} onShowLeaderboard={showLeaderboard} />
)}
{screen === 'leaderboard' && (
<LeaderboardScreen leaderboard={leaderboard} onRestart={restart} />
)}
{screen === 'quit' && gameResult && (
<QuitScreen result={gameResult} onRestart={restart} />
)}
</div>
);
}

export default App;
export default App;
219 changes: 219 additions & 0 deletions client/src/components/BubbleGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { useState, useEffect, useRef } from 'react';

interface SynonymWithDefinition {
word: string;
definition: string;
}

interface Props {
previousWord: string | null;
currentWord: string;
targetWord: string;
synonyms: SynonymWithDefinition[];
isLoading: boolean;
onSelectWord: (word: string) => void;
}

export default function BubbleGraph({
previousWord,
currentWord,
targetWord,
synonyms,
isLoading,
onSelectWord,
}: Props) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const clickTimeoutRef = useRef<number | undefined>(undefined);

// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
};
}, []);

const handleSynonymClick = (synonym: SynonymWithDefinition, index: number) => {
if (synonym.word === 'No synonyms found' || synonym.word === 'Error loading synonyms') {
return;
}
setSelectedIndex(index);

// Clear any existing timeout
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}

// Delay the word selection to allow animation to play
clickTimeoutRef.current = window.setTimeout(() => {
onSelectWord(synonym.word);
setSelectedIndex(null);
clickTimeoutRef.current = undefined;
}, 400);
};

// Calculate positions for synonym bubbles in an arc pattern
const getSynonymPosition = (index: number, total: number) => {
const arcWidth = 70; // Percentage of container width
const arcHeight = 30; // Vertical offset from bottom
const startAngle = -30; // Degrees
const endAngle = 30; // Degrees

const angleRange = endAngle - startAngle;
const angle = startAngle + (angleRange * index) / Math.max(total - 1, 1);
const radians = (angle * Math.PI) / 180;

const centerX = 50;
const x = centerX + Math.sin(radians) * arcWidth / 2;
const y = 70 + Math.cos(radians) * arcHeight;

return { x, y };
};

const isTargetWord = (word: string) => word.toLowerCase() === targetWord.toLowerCase();

return (
<div className="relative w-full h-[600px] flex items-center justify-center">
<svg className="absolute inset-0 w-full h-full pointer-events-none" style={{ zIndex: 0 }}>
{/* Lines from current word to synonyms */}
{!isLoading && synonyms.length > 0 && synonyms[0].word !== 'No synonyms found' && (
<>
{synonyms.map((_, index) => {
const pos = getSynonymPosition(index, synonyms.length);
const opacity = selectedIndex !== null && selectedIndex !== index ? 0 : 0.3;
return (
<line
key={`line-current-${index}`}
x1="50%"
y1="50%"
x2={`${pos.x}%`}
y2={`${pos.y}%`}
stroke="url(#gradient)"
strokeWidth="2"
opacity={opacity}
className="transition-opacity duration-300"
/>
);
})}
</>
)}

{/* Line from previous word to current word */}
{previousWord && (
<line
x1="50%"
y1="15%"
x2="50%"
y2="50%"
stroke="url(#gradient)"
strokeWidth="2"
opacity="0.3"
/>
)}

{/* Gradient definition */}
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#a855f7" />
<stop offset="100%" stopColor="#ec4899" />
</linearGradient>
</defs>
</svg>

{/* Previous Word Bubble (Small, Top) */}
{previousWord && (
<div
className="absolute transition-all duration-500"
style={{
left: '50%',
top: '5%',
transform: 'translate(-50%, 0)',
zIndex: 1,
}}
>
<div className="bg-gradient-to-r from-purple-400/50 to-pink-400/50 backdrop-blur-sm rounded-full px-6 py-3 shadow-lg">
<p className="text-white font-medium text-sm">{previousWord}</p>
</div>
</div>
)}

{/* Current Word Bubble (Large, Center) */}
<div
className="absolute transition-all duration-500"
style={{
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 2,
}}
>
<div
className={`bg-gradient-to-r ${
isTargetWord(currentWord)
? 'from-green-400 to-emerald-400'
: 'from-purple-500 to-pink-500'
} rounded-full px-12 py-8 shadow-2xl`}
>
<div className="text-center">
<p className="text-white/80 text-sm mb-1">Current Word</p>
<p className="text-white font-bold text-4xl">{currentWord}</p>
</div>
</div>
</div>

{/* Loading State */}
{isLoading && (
<div className="absolute" style={{ left: '50%', top: '75%', transform: 'translate(-50%, -50%)', zIndex: 3 }}>
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-purple-500 border-t-transparent"></div>
<p className="text-gray-600 mt-4 font-medium">Loading synonyms...</p>
</div>
</div>
)}

{/* Synonym Bubbles (Medium, Bottom, Arc Pattern) */}
{!isLoading && synonyms.length > 0 && (
<>
{synonyms.map((synonym, index) => {
const pos = getSynonymPosition(index, synonyms.length);
const isDisabled = synonym.word === 'No synonyms found' || synonym.word === 'Error loading synonyms';
const isTarget = isTargetWord(synonym.word);
const isSelected = selectedIndex === index;
const shouldHide = selectedIndex !== null && selectedIndex !== index;

return (
<div
key={index}
className={`absolute transition-all duration-300 ${
shouldHide ? 'opacity-0 scale-50' : 'opacity-100 scale-100'
} ${isSelected ? 'scale-110' : ''}`}
style={{
left: `${pos.x}%`,
top: `${pos.y}%`,
transform: 'translate(-50%, -50%)',
zIndex: 3,
}}
>
<button
onClick={() => handleSynonymClick(synonym, index)}
disabled={isDisabled}
className={`bg-gradient-to-r ${
isTarget
? 'from-green-400 to-emerald-400 ring-4 ring-green-300 animate-pulse'
: 'from-purple-400 to-pink-400'
} rounded-full px-8 py-4 shadow-xl hover:scale-110 transition-transform disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100`}
>
<div className="text-center max-w-[200px]">
<p className="text-white font-bold text-lg mb-1">{synonym.word}</p>
<p className="text-white/90 text-xs line-clamp-2">{synonym.definition}</p>
</div>
</button>
</div>
);
})}
</>
)}
</div>
);
}
Loading