Initial commit: Interactives site
Astro-based site with 40+ interactive math games, puzzles, and explorations. Includes admin dashboard, todo system, blog, theme/subcategory navigation, and embed pages for iframe embedding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
30
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Astro
|
||||
.astro/
|
||||
|
||||
# Vite
|
||||
node_modules/.vite/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Lock files (keep package-lock.json, ignore others)
|
||||
bun.lockb
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
18
LICENSE
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
Shadcnblocks.com License
|
||||
|
||||
Copyright (c) 2024 Shadcnblocks.com
|
||||
|
||||
This license grants you, the purchaser, a non-exclusive, worldwide, and perpetual right to use the Shadcnblocks.com template under the following terms:
|
||||
|
||||
✅ What You Can Do
|
||||
|
||||
- Use the template to build unlimited custom websites or digital products for yourself, your company or your clients.
|
||||
- Use the template in both personal and commercial projects.
|
||||
- Sell or transfer finished end products to clients. An “end product” must be a customized implementation requiring your own skill and effort (e.g., a launched website or app), not the template in its original or minimally modified form.
|
||||
|
||||
❌ What You Can’t Do
|
||||
|
||||
- You may not use any part of this template in a resellable template, UI kit, design system, competing product, derivative work, or similar offering.
|
||||
- You may not redistribute or publicly share the source code of this template, including uploading it to any public or open-source code repository.
|
||||
- You may not offer or provide this template through any website builder, online platform, or tool where it could be accessed or resold to end users or third parties.
|
||||
- You may not use this template as input or training data for large language models (LLMs) or AI tools to generate derivate templates and works that may resold, redistributed, or made publicly available.
|
||||
32
README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Hatch Astro Template
|
||||
|
||||
Hatch Astro Template is a premium template built by https://www.shadcnblocks.com
|
||||
|
||||
- [Demo](https://hatch-astro-template.vercel.app/)
|
||||
- [Documentation](https://docs.shadcnblocks.com/templates/getting-started)
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Astro
|
||||
- Tailwind 4
|
||||
- shadcn/ui
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com)
|
||||
28
astro.config.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// @ts-check
|
||||
import { defineConfig, fontProviders } from "astro/config";
|
||||
import mdx from "@astrojs/mdx";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
|
||||
import react from "@astrojs/react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import node from "@astrojs/node";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: "https://interactives.neeldhara.com",
|
||||
integrations: [mdx(), sitemap(), react()],
|
||||
output: "static",
|
||||
adapter: node({ mode: "standalone" }),
|
||||
|
||||
fonts: [
|
||||
{
|
||||
provider: fontProviders.fontsource(),
|
||||
name: "Imprima",
|
||||
cssVariable: "--font-imprima",
|
||||
},
|
||||
],
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
});
|
||||
28
components.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {
|
||||
"@magicui": "https://magicui.design/r/{name}",
|
||||
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
|
||||
}
|
||||
}
|
||||
28
components.json.bak
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@magicui": "https://magicui.design/r/{name}",
|
||||
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
|
||||
}
|
||||
}
|
||||
62
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import astroPlugin from "eslint-plugin-astro";
|
||||
import astroParser from "astro-eslint-parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
".astro/**",
|
||||
"src/components/ui/**",
|
||||
],
|
||||
},
|
||||
|
||||
// Lint .astro files
|
||||
{
|
||||
files: ["**/*.astro"],
|
||||
languageOptions: {
|
||||
parser: astroParser,
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: { astro: astroPlugin, "simple-import-sort": simpleImportSort },
|
||||
rules: {
|
||||
...astroPlugin.configs.recommended.rules,
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
},
|
||||
},
|
||||
|
||||
// Lint TS/JS/React files
|
||||
{
|
||||
files: ["**/*.{ts,tsx,js,jsx}"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tsPlugin,
|
||||
"simple-import-sort": simpleImportSort,
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
// Optional: relax some noisy rules if needed
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
15255
package-lock.json
generated
Normal file
80
package.json
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"name": "hatch-astro-template",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"author": "Shadcnblocks.com",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
|
||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.4",
|
||||
"@astrojs/react": "^5.0.2",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource/castoro": "^5.2.7",
|
||||
"@fontsource/imprima": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/three": "^0.183.1",
|
||||
"astro": "^6.1.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-autoplay": "^8.5.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"katex": "^0.16.45",
|
||||
"lucide-react": "^0.479.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.1.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.183.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/mdx": "^5.0.3",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.39.0",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"astro-eslint-parser": "^1.2.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.1.11"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 609 B |
10
public/favicon/favicon.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_25400_136)">
|
||||
<path d="M28.1875 33.25C32.7709 33.1566 36.4563 29.4077 36.4563 24.799C36.4563 22.5576 35.5665 20.408 33.9826 18.8232L19.9188 4.75V13.4974C19.9188 14.9603 20.4995 16.3634 21.5334 17.3979L24.8279 20.6946L24.8369 20.7034L30.361 26.2312C30.5404 26.4106 30.5404 26.7017 30.361 26.8812C30.1816 27.0607 29.8907 27.0607 29.7114 26.8812L27.8072 24.9758H10.1928L8.28868 26.8812C8.10928 27.0607 7.81842 27.0607 7.63903 26.8812C7.45963 26.7017 7.45963 26.4106 7.63902 26.2312L13.1631 20.7034L13.1721 20.6946L16.4666 17.3979C17.5005 16.3634 18.0812 14.9603 18.0812 13.4974V4.75L4.01738 18.8232C2.43355 20.408 1.54376 22.5576 1.54376 24.799C1.54376 29.4077 5.22918 33.1566 9.81254 33.25H28.1875Z" fill="#FF0A0A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_25400_136">
|
||||
<rect width="34.9125" height="28.5" fill="white" transform="translate(1.54376 4.75)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 993 B |
21
public/favicon/site.webmanifest
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "Hatch Template - Shadcnblocks.com",
|
||||
"short_name": "Hatch",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
public/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
6
public/icons/meta/role.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 11.5C14.4853 11.5 16.5 9.48528 16.5 7C16.5 4.51472 14.4853 2.5 12 2.5C9.51472 2.5 7.5 4.51472 7.5 7C7.5 9.48528 9.51472 11.5 12 11.5Z" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.25 20.25V19.25C5.25 16.4886 7.48858 14.25 10.25 14.25H13.75C16.5114 14.25 18.75 16.4886 18.75 19.25V20.25" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M19.25 8.25L21 10L19.25 11.75" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.75 8.25L3 10L4.75 11.75" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 819 B |
6
public/icons/meta/services.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="8.625" stroke="currentColor" stroke-width="1.35"/>
|
||||
<circle cx="12" cy="12" r="5.625" stroke="currentColor" stroke-width="1.35" opacity="0.45"/>
|
||||
<circle cx="12" cy="12" r="2.625" stroke="currentColor" stroke-width="1.35"/>
|
||||
<circle cx="12" cy="12" r="0.875" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
10
public/icons/meta/timeline.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12H21" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
||||
<path d="M6 8.5V15.5" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
||||
<path d="M12 6V18" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
||||
<path d="M18 9.5V14.5" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="6" cy="12" r="2" stroke="currentColor" stroke-width="1"/>
|
||||
<circle cx="12" cy="12" r="2.625" stroke="currentColor" stroke-width="1"/>
|
||||
<circle cx="18" cy="12" r="2" stroke="currentColor" stroke-width="1"/>
|
||||
<circle cx="12" cy="12" r="0.75" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 729 B |
10
public/icons/pricing/project.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 34" width="35" height="34">
|
||||
<style>
|
||||
.s0 { fill: #ff0a0a }
|
||||
.s1 { fill: none;stroke: #ffffff }
|
||||
</style>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s0" d="m2 12.25c0-6.08 4.92-11 11-11h9.5c6.08 0 11 4.92 11 11v9.5c0 6.08-4.92 11-11 11h-9.5c-6.08 0-11-4.92-11-11zm24 4.98c0.2-0.09 0.2-0.37 0-0.46l-1.75-0.79c-2.43-1.1-4.38-3.05-5.48-5.48l-0.79-1.75c-0.09-0.19-0.37-0.2-0.46 0l-0.81 1.78c-1.1 2.41-3.04 4.34-5.45 5.44l-1.76 0.8c-0.2 0.09-0.2 0.37 0 0.46l1.81 0.83c2.38 1.1 4.29 3.02 5.39 5.4l0.82 1.79c0.09 0.2 0.37 0.2 0.46 0l0.8-1.76c1.1-2.4 3.02-4.33 5.42-5.43z"/>
|
||||
<path class="s1" d="m22.5 0.75c6.35 0 11.5 5.15 11.5 11.5v9.5c0 6.35-5.15 11.5-11.5 11.5h-9.5c-6.35 0-11.5-5.15-11.5-11.5v-9.5c0-6.35 5.15-11.5 11.5-11.5zm-5.34 9.99c-1.15 2.52-3.17 4.54-5.7 5.69l-1.26 0.57 1.31 0.61c2.5 1.15 4.5 3.15 5.65 5.64l0.59 1.3 0.58-1.27c1.14-2.51 3.15-4.53 5.66-5.68l1.3-0.6-1.25-0.57c-2.54-1.15-4.57-3.18-5.72-5.72l-0.57-1.25z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,010 B |
12
public/icons/pricing/subscription.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 35" width="36" height="35">
|
||||
<style>
|
||||
.s0 { fill: #4338ca }
|
||||
.s1 { fill: none;stroke: #ffffff }
|
||||
.s2 { fill: none;stroke: #ffffff;stroke-linecap: round;stroke-linejoin: round;stroke-width: 2.8 }
|
||||
</style>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s0" d="m13 2h10c6.08 0 11 4.92 11 11v10c0 6.08-4.92 11-11 11h-10c-6.08 0-11-4.92-11-11v-10c0-6.08 4.92-11 11-11z"/>
|
||||
<path fill-rule="evenodd" class="s1" d="m13 1.5h10c6.35 0 11.5 5.15 11.5 11.5v10c0 6.35-5.15 11.5-11.5 11.5h-10c-6.35 0-11.5-5.15-11.5-11.5v-10c0-6.35 5.15-11.5 11.5-11.5z"/>
|
||||
</g>
|
||||
<path class="s2" d="m23.49 21.58c-1.47 0-2.89 0.58-3.93 1.62-1.04 1.04-1.62 2.46-1.62 3.93 0-3.07-2.21-5.55-4.94-5.55m10.49-4.94c-1.47 0-2.89 0.58-3.93 1.63-1.04 1.04-1.62 2.45-1.62 3.92 0-3.06-2.21-5.55-4.94-5.55m10.49-4.94c-1.47 0-2.89 0.59-3.93 1.63-1.04 1.04-1.62 2.45-1.62 3.93 0-3.07-2.21-5.56-4.94-5.56m4.94-3.7v7.41"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 954 B |
27
public/icons/service-details/s1.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_dd_25498_516)">
|
||||
<rect x="14" y="9" width="32" height="32" rx="11" fill="#FF0A0A"/>
|
||||
<rect x="13.5" y="8.5" width="33" height="33" rx="11.5" stroke="white"/>
|
||||
</g>
|
||||
<path d="M30 33.4C28.3432 33.4 27 29.6392 27 25C27 20.3608 28.3432 16.6 30 16.6C31.3223 16.6 32.4448 18.9953 32.8441 22.3209" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M30.0001 33.4C25.3609 33.4 21.6001 29.6392 21.6001 25C21.6001 20.3608 25.3609 16.6 30.0001 16.6C34.3494 16.6 37.9266 19.9054 38.3568 24.1411" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.6001 25H28.2001" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.8 26.2L39 28.6L35.4 29.8L34.2 33.4L31.8 26.2Z" fill="white" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<filter id="filter0_dd_25498_516" x="0" y="0" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_25498_516"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="5"/>
|
||||
<feGaussianBlur stdDeviation="6.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_25498_516" result="effect2_dropShadow_25498_516"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_25498_516" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
33
public/icons/service-details/s2.svg
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_dd_25498_67)">
|
||||
<rect x="14" y="9" width="32" height="32" rx="11" fill="#FF0A0A"/>
|
||||
<rect x="13.5" y="8.5" width="33" height="33" rx="11.5" stroke="white"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip0_25498_67)">
|
||||
<path d="M21.25 24.3901C21.25 21.3894 22.707 18.7417 24.98 17.1531C25.796 16.5647 27.0781 16.447 27.661 17.1531C28.4186 18.1533 27.0781 19.5654 27.661 20.3891C29.2345 22.3308 32.5566 18.5063 36.4614 20.3891C39.259 21.8012 38.851 25.508 38.5013 26.8613C37.4522 30.568 34.0719 33.3333 30.0505 33.3333C25.2131 33.2745 21.25 29.3324 21.25 24.3901Z" fill="white" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M36.3595 25.1042C36.8773 24.2074 36.4085 22.9674 35.3124 22.3346C34.2163 21.7017 32.908 21.9157 32.3902 22.8125C31.8725 23.7093 32.3413 24.9493 33.4374 25.5822C34.5335 26.215 35.8418 26.001 36.3595 25.1042Z" fill="#FF0A0A"/>
|
||||
<path d="M24.5833 25.4167C25.2736 25.4167 25.8333 24.8571 25.8333 24.1667C25.8333 23.4763 25.2736 22.9167 24.5833 22.9167C23.8929 22.9167 23.3333 23.4763 23.3333 24.1667C23.3333 24.8571 23.8929 25.4167 24.5833 25.4167Z" fill="#FF0A0A"/>
|
||||
<path d="M26.4583 29.5834C27.1486 29.5834 27.7083 29.0237 27.7083 28.3334C27.7083 27.643 27.1486 27.0834 26.4583 27.0834C25.7679 27.0834 25.2083 27.643 25.2083 28.3334C25.2083 29.0237 25.7679 29.5834 26.4583 29.5834Z" fill="#FF0A0A"/>
|
||||
<path d="M30.8333 31.25C31.5236 31.25 32.0833 30.6904 32.0833 30C32.0833 29.3096 31.5236 28.75 30.8333 28.75C30.1429 28.75 29.5833 29.3096 29.5833 30C29.5833 30.6904 30.1429 31.25 30.8333 31.25Z" fill="#FF0A0A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dd_25498_67" x="0" y="0" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_25498_67"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="5"/>
|
||||
<feGaussianBlur stdDeviation="6.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_25498_67" result="effect2_dropShadow_25498_67"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_25498_67" result="shape"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_25498_67">
|
||||
<rect width="20" height="20" fill="white" transform="translate(20 15)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
25
public/icons/service-details/s3.svg
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_dd_25498_148)">
|
||||
<rect x="14" y="9" width="32" height="32" rx="11" fill="#FF0A0A"/>
|
||||
<rect x="13.5" y="8.5" width="33" height="33" rx="11.5" stroke="white"/>
|
||||
</g>
|
||||
<path d="M35.75 23H33.25C32.0074 23 31 24.0074 31 25.25V30.75C31 31.9926 32.0074 33 33.25 33H35.75C36.9926 33 38 31.9926 38 30.75V25.25C38 24.0074 36.9926 23 35.75 23Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.75 18C24.2308 18 23 19.2308 23 20.75V27.375C23 27.7776 23.0906 28.1591 23.2524 28.5H22.75C22.3358 28.5 22 28.8358 22 29.25C22 29.6642 22.3358 30 22.75 30H25.625H28.75C29.1642 30 29.5 29.6642 29.5 29.25C29.5 28.8358 29.1642 28.5 28.75 28.5H25.625C25.0032 28.5 24.5 27.9968 24.5 27.375V20.75C24.5 20.0592 25.0592 19.5 25.75 19.5H34.25C34.9408 19.5 35.5 20.0592 35.5 20.75C35.5 21.1642 35.8358 21.5 36.25 21.5C36.6642 21.5 37 21.1642 37 20.75C37 19.2308 35.7692 18 34.25 18H25.75Z" fill="white"/>
|
||||
<defs>
|
||||
<filter id="filter0_dd_25498_148" x="0" y="0" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_25498_148"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="5"/>
|
||||
<feGaussianBlur stdDeviation="6.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_25498_148" result="effect2_dropShadow_25498_148"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_25498_148" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
29
public/icons/service-details/s4.svg
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_dd_25498_597)">
|
||||
<rect x="14" y="9" width="32" height="32" rx="11" fill="#FF0A0A"/>
|
||||
<rect x="13.5" y="8.5" width="33" height="33" rx="11.5" stroke="white"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip0_25498_597)">
|
||||
<path d="M24.3879 16.8589C24.0655 16.5462 24.2869 16 24.736 16H36.0609C36.337 16 36.5609 16.2239 36.5609 16.5V21.8333C36.5609 22.1095 36.337 22.3333 36.0609 22.3333H30.0317L24.3879 16.8589ZM23.5024 22.8333C23.5024 22.5572 23.7263 22.3333 24.0024 22.3333H30.0317L35.6754 27.8078C35.9978 28.1205 35.7764 28.6667 35.3273 28.6667H30.5317C30.2555 28.6667 30.0317 28.8905 30.0317 29.1667V33.8184C30.0317 34.2602 29.5006 34.4849 29.1835 34.1773L23.6543 28.814C23.5572 28.7198 23.5024 28.5903 23.5024 28.4551V22.8333Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dd_25498_597" x="0" y="0" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_25498_597"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="5"/>
|
||||
<feGaussianBlur stdDeviation="6.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_25498_597" result="effect2_dropShadow_25498_597"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_25498_597" result="shape"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_25498_597">
|
||||
<rect width="13.0625" height="19" fill="white" transform="translate(23.5 16)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
3
public/icons/socials/framer.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.54437 0H16.7089V6.66672H10.1266L3.54437 0ZM3.54437 6.66672H10.1266L16.7089 13.3333H10.1266V20L3.54437 13.3333V6.66672Z" fill="#CCCCCC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
3
public/icons/socials/instagram.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9307 1.07129C16.6911 1.07129 18.9286 3.31058 18.9286 6.06915V13.9306C18.9286 16.6909 16.6893 18.9284 13.9307 18.9284H6.06927C3.30891 18.9284 1.07141 16.6892 1.07141 13.9306V6.06915C1.07141 3.30879 3.3107 1.07129 6.06927 1.07129H13.9307ZM9.99998 5.357C7.4357 5.357 5.35713 7.43558 5.35713 9.99986C5.35713 12.5641 7.4357 14.6427 9.99998 14.6427C12.5643 14.6427 14.6428 12.5641 14.6428 9.99986C14.6428 7.43558 12.5643 5.357 9.99998 5.357ZM9.99998 6.78558C10.4221 6.78558 10.8401 6.86872 11.23 7.03025C11.62 7.19178 11.9744 7.42855 12.2728 7.72702C12.5713 8.02549 12.8081 8.37983 12.9696 8.76981C13.1311 9.15978 13.2143 9.57776 13.2143 9.99986C13.2143 10.422 13.1311 10.8399 12.9696 11.2299C12.8081 11.6199 12.5713 11.9742 12.2728 12.2727C11.9744 12.5712 11.62 12.8079 11.23 12.9695C10.8401 13.131 10.4221 13.2141 9.99998 13.2141C9.1475 13.2141 8.32994 12.8755 7.72714 12.2727C7.12435 11.6699 6.7857 10.8523 6.7857 9.99986C6.7857 9.14738 7.12435 8.32981 7.72714 7.72702C8.32994 7.12422 9.1475 6.78558 9.99998 6.78558ZM15.1786 3.57129C14.847 3.57129 14.5291 3.70299 14.2947 3.93741C14.0603 4.17183 13.9286 4.48977 13.9286 4.82129C13.9286 5.15281 14.0603 5.47075 14.2947 5.70517C14.5291 5.93959 14.847 6.07129 15.1786 6.07129C15.5101 6.07129 15.828 5.93959 16.0624 5.70517C16.2969 5.47075 16.4286 5.15281 16.4286 4.82129C16.4286 4.48977 16.2969 4.17183 16.0624 3.93741C15.828 3.70299 15.5101 3.57129 15.1786 3.57129Z" fill="#CCCCCC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
10
public/icons/socials/x.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_25444_951)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.16671 0.833496C3.28265 0.833496 2.43481 1.18469 1.80968 1.80981C1.18456 2.43493 0.833374 3.28277 0.833374 4.16683V15.8335C0.833374 16.7176 1.18456 17.5654 1.80968 18.1905C2.43481 18.8156 3.28265 19.1668 4.16671 19.1668H15.8334C16.7174 19.1668 17.5653 18.8156 18.1904 18.1905C18.8155 17.5654 19.1667 16.7176 19.1667 15.8335V4.16683C19.1667 3.28277 18.8155 2.43493 18.1904 1.80981C17.5653 1.18469 16.7174 0.833496 15.8334 0.833496H4.16671ZM3.88837 3.75016C3.79715 3.78406 3.7151 3.8388 3.64877 3.91001C3.58244 3.98122 3.53365 4.06694 3.50629 4.16034C3.47894 4.25373 3.47377 4.35223 3.4912 4.44797C3.50864 4.54372 3.54819 4.63407 3.60671 4.71183L8.28504 10.9202L3.35587 16.2077L3.31921 16.2502H5.02504L9.05004 11.9343L12.1434 16.041C12.2151 16.1361 12.3126 16.2086 12.4242 16.2502H16.1092C16.2003 16.2161 16.2822 16.1612 16.3483 16.0899C16.4145 16.0186 16.4631 15.9329 16.4903 15.8395C16.5175 15.7461 16.5225 15.6477 16.5049 15.5521C16.4874 15.4564 16.4478 15.3661 16.3892 15.2885L11.7109 9.08016L16.6809 3.75016H14.9725L10.9475 8.06683L7.85254 3.96016C7.78089 3.8648 7.68344 3.79193 7.57171 3.75016H3.88837ZM12.955 15.0402L5.35921 4.96016H7.04171L14.6367 15.0393L12.955 15.0402Z" fill="#CCCCCC"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_25444_951">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/images/about/portrait.jpg
Normal file
|
After Width: | Height: | Size: 837 KiB |
BIN
public/images/avatars/avatar-1.webp
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
public/images/avatars/avatar-2.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/images/avatars/avatar-3.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/images/avatars/avatar-4.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
10
public/images/layout/logo.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_25400_136)">
|
||||
<path d="M28.1875 33.25C32.7709 33.1566 36.4563 29.4077 36.4563 24.799C36.4563 22.5576 35.5665 20.408 33.9826 18.8232L19.9188 4.75V13.4974C19.9188 14.9603 20.4995 16.3634 21.5334 17.3979L24.8279 20.6946L24.8369 20.7034L30.361 26.2312C30.5404 26.4106 30.5404 26.7017 30.361 26.8812C30.1816 27.0607 29.8907 27.0607 29.7114 26.8812L27.8072 24.9758H10.1928L8.28868 26.8812C8.10928 27.0607 7.81842 27.0607 7.63903 26.8812C7.45963 26.7017 7.45963 26.4106 7.63902 26.2312L13.1631 20.7034L13.1721 20.6946L16.4666 17.3979C17.5005 16.3634 18.0812 14.9603 18.0812 13.4974V4.75L4.01738 18.8232C2.43355 20.408 1.54376 22.5576 1.54376 24.799C1.54376 29.4077 5.22918 33.1566 9.81254 33.25H28.1875Z" fill="#FF0A0A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_25400_136">
|
||||
<rect width="34.9125" height="28.5" fill="white" transform="translate(1.54376 4.75)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 993 B |
BIN
public/images/logos/Frame-1.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/images/logos/Frame-2.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/images/logos/Frame-3.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/images/logos/Frame-4.webp
Normal file
|
After Width: | Height: | Size: 948 B |
BIN
public/images/logos/Frame.webp
Normal file
|
After Width: | Height: | Size: 952 B |
BIN
public/images/logos/Vector.webp
Normal file
|
After Width: | Height: | Size: 876 B |
4
public/images/services/noun-half-circles-7745954.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m996 780c-0.046875-129.05-62.953-249.94-168.56-324h132.56c19.875 0 36-16.125 36-36 0-218.72-177.28-396-396-396s-396 177.28-396 396c0 19.875 16.125 36 36 36h132.56c-105.61 74.062-168.52 194.95-168.56 324 0 19.875 16.125 36 36 36h132.56c-105.61 74.062-168.52 194.95-168.56 324 0 19.875 16.125 36 36 36h720c19.875 0 36-16.125 36-36-0.046875-129.05-62.953-249.94-168.56-324h132.56c19.875 0 36-16.125 36-36zm-718.03-396c19.828-177.84 180.05-305.95 357.89-286.13 150.56 16.781 269.39 135.56 286.18 286.13zm644.06 720h-644.06c19.828-177.84 180.05-305.95 357.89-286.13 150.56 16.781 269.39 135.56 286.18 286.13zm-644.06-360c19.828-177.84 180.05-305.95 357.89-286.13 150.56 16.781 269.39 135.56 286.18 286.13z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 870 B |
4
public/images/services/noun-semicircles-8294628.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1140 564h-260.68c46.922-26.156 90-58.688 127.97-96.703 108.38-107.77 169.13-254.44 168.71-407.29 0-19.875-16.125-36-36-36h-1080c-19.875 0-36 16.125-36 36-0.42188 152.86 60.328 299.53 168.71 407.29 37.969 38.016 81.047 70.594 127.97 96.703h-260.68c-19.875 0-36 16.125-36 36 0 318.14 257.9 576 576 576 152.76 0 299.26-60.703 407.29-168.71 108.38-107.77 169.13-254.44 168.71-407.29 0-19.875-16.125-36-36-36zm-1042.7-468h1005.5c-19.781 277.64-260.86 486.71-538.5 466.92-250.18-17.766-449.16-216.79-466.97-466.92zm502.74 1008c-264.24-0.32812-483.52-204.42-502.74-468h1005.5c-19.219 263.58-238.5 467.68-502.74 468z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 779 B |
4
public/images/services/noun-sparkle-7746005.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1095.9 495.66-308.86-82.781-82.781-308.86c-15.422-57.609-74.672-91.781-132.28-76.359-37.266 9.9844-66.375 39.094-76.359 76.359l-82.734 308.86-308.86 82.781c-57.609 15.422-91.781 74.672-76.359 132.28 9.9844 37.266 39.094 66.375 76.359 76.359l308.86 82.781 82.781 308.86c15.422 57.609 74.672 91.781 132.28 76.359 37.266-9.9844 66.375-39.094 76.359-76.359l82.781-308.86 308.86-82.781c57.609-15.422 91.781-74.672 76.359-132.28-9.9844-37.266-39.094-66.375-76.359-76.359zm-18.609 139.13-328.97 88.078c-12.422 3.3281-22.125 13.031-25.453 25.453l-88.125 328.92c-5.1094 19.219-24.797 30.656-44.016 25.594-12.516-3.3281-22.266-13.078-25.594-25.594l-88.125-328.92c-3.3281-12.422-13.031-22.125-25.453-25.453l-328.92-88.125c-19.219-5.1094-30.656-24.797-25.594-44.016 3.3281-12.516 13.078-22.266 25.594-25.594l328.92-88.125c12.422-3.3281 22.125-13.031 25.453-25.453l88.125-328.92c5.1094-19.219 24.797-30.656 44.016-25.594 12.516 3.3281 22.266 13.078 25.594 25.594l88.125 328.92c3.3281 12.422 13.031 22.125 25.453 25.453l328.92 88.125c19.219 5.1094 30.656 24.797 25.594 44.016-3.2812 12.562-13.031 22.312-25.547 25.641z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
public/images/services/noun-star-7745963.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1095.9 495.71-148.22-39.703 76.734-132.89c29.812-51.656 12.141-117.7-39.516-147.56-33.422-19.312-74.578-19.312-108 0l-132.94 76.734-39.703-148.22c-15.422-57.609-74.672-91.781-132.28-76.359-37.266 9.9844-66.375 39.094-76.359 76.359l-39.656 148.22-132.94-76.734c-51.656-29.812-117.7-12.094-147.52 39.609-19.266 33.375-19.266 74.531 0 107.91l76.734 132.94-148.22 39.703c-57.609 15.422-91.781 74.672-76.359 132.28 9.9844 37.266 39.094 66.375 76.359 76.359l148.22 39.656-76.734 132.94c-29.812 51.656-12.094 117.7 39.609 147.52 33.375 19.266 74.531 19.266 107.91 0l132.94-76.734 39.703 148.22c15.422 57.609 74.672 91.781 132.28 76.359 37.266-9.9844 66.375-39.094 76.359-76.359l39.656-148.22 132.89 76.734c51.656 29.812 117.7 12.141 147.56-39.516 19.312-33.422 19.312-74.578 0-108l-76.734-132.94 148.22-39.703c57.609-15.422 91.781-74.672 76.359-132.28-9.9844-37.266-39.094-66.375-76.359-76.359zm-18.609 139.08-193.55 51.844c-19.219 5.1562-30.609 24.891-25.453 44.062 0.79688 3.0469 2.0156 6 3.6094 8.7188l100.17 173.53c9.9844 17.203 4.125 39.234-13.078 49.219-11.156 6.4688-24.938 6.4688-36.094 0l-173.48-100.27c-17.203-9.9375-39.234-4.0781-49.172 13.125-1.5938 2.7188-2.8125 5.6719-3.6094 8.7188l-51.891 193.6c-5.1562 19.219-24.891 30.562-44.109 25.406-12.375-3.3281-22.078-13.031-25.406-25.406l-51.891-193.55c-5.1562-19.219-24.891-30.609-44.062-25.453-3.0469 0.79687-6 2.0156-8.7188 3.6094l-173.53 100.17c-17.203 9.9844-39.234 4.125-49.219-13.078-6.4688-11.156-6.4688-24.938 0-36.094l100.17-173.53c9.9375-17.203 4.0781-39.234-13.125-49.172-2.7188-1.5938-5.6719-2.8125-8.7188-3.6094l-193.5-51.844c-19.219-5.1562-30.562-24.891-25.406-44.109 3.3281-12.375 13.031-22.078 25.406-25.406l193.55-51.844c19.219-5.1562 30.609-24.891 25.453-44.062-0.79688-3.0469-2.0156-6-3.6094-8.7188l-100.17-173.53c-9.9844-17.203-4.125-39.234 13.078-49.219 11.156-6.4688 24.938-6.4688 36.094 0l173.53 100.17c17.203 9.9375 39.234 4.0781 49.172-13.125 1.5938-2.7188 2.8125-5.6719 3.6094-8.7188l51.844-193.55c5.1562-19.219 24.891-30.562 44.109-25.406 12.375 3.3281 22.078 13.031 25.406 25.406l51.844 193.55c5.1562 19.219 24.891 30.609 44.062 25.453 3.0469-0.79688 6-2.0156 8.7188-3.6094l173.53-100.17c17.203-9.9844 39.234-4.125 49.219 13.078 6.4688 11.156 6.4688 24.938 0 36.094l-100.22 173.48c-9.9375 17.203-4.0781 39.234 13.125 49.172 2.7188 1.5938 5.6719 2.8125 8.7188 3.6094l193.6 51.891c19.219 5.1562 30.562 24.891 25.406 44.109-3.3281 12.375-13.031 22.078-25.406 25.406z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/images/work/cove/cover.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/images/work/cove/gallery-1.jpg
Normal file
|
After Width: | Height: | Size: 558 KiB |
BIN
public/images/work/cove/gallery-2.jpg
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
public/images/work/epoch/cover.jpg
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
public/images/work/epoch/gallery-1.jpg
Normal file
|
After Width: | Height: | Size: 938 KiB |
BIN
public/images/work/epoch/gallery-2.jpg
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
public/images/work/pace/cover.jpg
Normal file
|
After Width: | Height: | Size: 307 KiB |
BIN
public/images/work/pace/gallery-1.jpg
Normal file
|
After Width: | Height: | Size: 349 KiB |
BIN
public/images/work/pace/gallery-2.jpg
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
public/images/work/pitch/cover.jpg
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
public/images/work/pitch/gallery-1.jpg
Normal file
|
After Width: | Height: | Size: 363 KiB |
BIN
public/images/work/pitch/gallery-2.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
public/images/work/ripple/cover.jpg
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
public/images/work/ripple/gallery-1.jpg
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
public/images/work/ripple/gallery-2.jpg
Normal file
|
After Width: | Height: | Size: 532 KiB |
BIN
public/images/work/veil/cover.jpg
Normal file
|
After Width: | Height: | Size: 323 KiB |
BIN
public/images/work/veil/gallery-1.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
public/images/work/veil/gallery-2.jpg
Normal file
|
After Width: | Height: | Size: 569 KiB |
BIN
public/og-image.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
103
scripts/sync-blog.sh
Executable file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# sync-blog.sh
|
||||
#
|
||||
# Syncs blog posts from the Obsidian vault to the Astro content directory.
|
||||
#
|
||||
# Source: /Users/neeldhara/repos/nm-obsidian/public/subdomains/interactives/blog/
|
||||
# Structure: <slug>/blog.md
|
||||
#
|
||||
# Destination: src/content/blog/
|
||||
# Structure: <slug>.md
|
||||
#
|
||||
# The script:
|
||||
# 1. Scans the source directory for folders containing blog.md
|
||||
# 2. Copies each blog.md to src/content/blog/<slug>.md
|
||||
# 3. Removes any destination files whose source folder no longer exists
|
||||
# 4. Reports what changed
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/sync-blog.sh # sync
|
||||
# ./scripts/sync-blog.sh --dry-run # preview changes without writing
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OBSIDIAN_BLOG="/Users/neeldhara/repos/nm-obsidian/public/subdomains/interactives/blog"
|
||||
ASTRO_BLOG="src/content/blog"
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
DRY_RUN=true
|
||||
echo "=== DRY RUN ==="
|
||||
fi
|
||||
|
||||
# Ensure destination exists
|
||||
mkdir -p "$ASTRO_BLOG"
|
||||
|
||||
added=0
|
||||
updated=0
|
||||
removed=0
|
||||
unchanged=0
|
||||
|
||||
# --- Sync from Obsidian → Astro ---
|
||||
|
||||
if [[ ! -d "$OBSIDIAN_BLOG" ]]; then
|
||||
echo "Error: Source directory not found: $OBSIDIAN_BLOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for dir in "$OBSIDIAN_BLOG"/*/; do
|
||||
# Skip if not a directory
|
||||
[[ -d "$dir" ]] || continue
|
||||
|
||||
slug=$(basename "$dir")
|
||||
src="$dir/blog.md"
|
||||
dest="$ASTRO_BLOG/$slug.md"
|
||||
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo " SKIP $slug (no blog.md found)"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ -f "$dest" ]]; then
|
||||
# Compare contents
|
||||
if diff -q "$src" "$dest" > /dev/null 2>&1; then
|
||||
unchanged=$((unchanged + 1))
|
||||
continue
|
||||
else
|
||||
echo " UPDATE $slug"
|
||||
updated=$((updated + 1))
|
||||
fi
|
||||
else
|
||||
echo " ADD $slug"
|
||||
added=$((added + 1))
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
cp "$src" "$dest"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Remove orphaned files in Astro that no longer exist in Obsidian ---
|
||||
|
||||
for dest in "$ASTRO_BLOG"/*.md; do
|
||||
[[ -f "$dest" ]] || continue
|
||||
|
||||
filename=$(basename "$dest")
|
||||
# Skip .gitkeep or non-md files
|
||||
[[ "$filename" == ".gitkeep" ]] && continue
|
||||
|
||||
slug="${filename%.md}"
|
||||
src_dir="$OBSIDIAN_BLOG/$slug"
|
||||
|
||||
if [[ ! -d "$src_dir" ]] || [[ ! -f "$src_dir/blog.md" ]]; then
|
||||
echo " REMOVE $slug (source no longer exists)"
|
||||
removed=$((removed + 1))
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
rm "$dest"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done: $added added, $updated updated, $removed removed, $unchanged unchanged"
|
||||
104
src/components/AddIdeaForm.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Plus } from "lucide-react";
|
||||
import { isAdminLoggedIn, addIdea } from "@/lib/admin";
|
||||
|
||||
interface AddIdeaFormProps {
|
||||
defaultTheme?: string;
|
||||
defaultSubcategory?: string;
|
||||
}
|
||||
|
||||
const AddIdeaForm: React.FC<AddIdeaFormProps> = ({
|
||||
defaultTheme,
|
||||
defaultSubcategory,
|
||||
}) => {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(isAdminLoggedIn());
|
||||
}, []);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
addIdea({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
themes: defaultTheme ? [defaultTheme] : [],
|
||||
subcategory: defaultSubcategory,
|
||||
status: "idea",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
setOpen(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-dashed border-primary/30 text-primary hover:bg-primary/5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Idea
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Interactive Idea</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Title</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Stable Marriage Problem"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Description</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of the interactive..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
{defaultTheme && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Theme: <strong>{defaultTheme}</strong>
|
||||
{defaultSubcategory && (
|
||||
<> / <strong>{defaultSubcategory}</strong></>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={!title.trim()}>
|
||||
{saved ? "Saved!" : "Save Idea"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddIdeaForm;
|
||||
344
src/components/AdminBar.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Shield, StickyNote, ChevronDown, ChevronUp, Save, Check,
|
||||
Loader2, Plus, Trash2, ListTodo, MessageSquare,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
isAdminLoggedIn,
|
||||
getNote,
|
||||
setNote,
|
||||
getStatusOverride,
|
||||
setStatusOverride,
|
||||
commitStatus,
|
||||
syncFromServer,
|
||||
getTodos,
|
||||
addTodo,
|
||||
toggleTodo,
|
||||
removeTodo,
|
||||
updateTodoRemark,
|
||||
type TodoItem,
|
||||
} from "@/lib/admin";
|
||||
import type { InteractiveStatus } from "@/lib/interactives";
|
||||
|
||||
interface AdminBarProps {
|
||||
slug: string;
|
||||
currentStatus: InteractiveStatus;
|
||||
}
|
||||
|
||||
const statusOptions: { value: InteractiveStatus; label: string; color: string }[] = [
|
||||
{ value: "published", label: "Published", color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" },
|
||||
{ value: "draft", label: "Draft", color: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" },
|
||||
];
|
||||
|
||||
const TodoRow: React.FC<{
|
||||
todo: TodoItem;
|
||||
slug: string;
|
||||
onToggle: () => void;
|
||||
onRemove: () => void;
|
||||
onRemarkChange: (remark: string) => void;
|
||||
}> = ({ todo, slug, onToggle, onRemove, onRemarkChange }) => {
|
||||
const [showRemark, setShowRemark] = useState(false);
|
||||
const [remarkText, setRemarkText] = useState(todo.remark || "");
|
||||
|
||||
const handleRemarkBlur = () => {
|
||||
if (remarkText !== (todo.remark || "")) {
|
||||
onRemarkChange(remarkText);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="group/todo">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
checked={todo.done}
|
||||
onCheckedChange={onToggle}
|
||||
className="shrink-0 mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-mono text-primary/60">{todo.refId}</span>
|
||||
<span className={`text-sm ${todo.done ? "line-through text-muted-foreground" : ""}`}>
|
||||
{todo.text}
|
||||
</span>
|
||||
</div>
|
||||
{todo.done && todo.doneDate && (
|
||||
<span className="text-xs text-green-600">Done {todo.doneDate}</span>
|
||||
)}
|
||||
{todo.remark && !showRemark && (
|
||||
<p className="text-xs text-muted-foreground italic mt-0.5">{todo.remark}</p>
|
||||
)}
|
||||
{showRemark && (
|
||||
<Input
|
||||
value={remarkText}
|
||||
onChange={(e) => setRemarkText(e.target.value)}
|
||||
onBlur={handleRemarkBlur}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { handleRemarkBlur(); setShowRemark(false); } }}
|
||||
placeholder="Add a remark..."
|
||||
className="text-xs h-7 mt-1"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5 opacity-0 group-hover/todo:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={() => setShowRemark((v) => !v)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground"
|
||||
title="Add remark"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminBar: React.FC<AdminBarProps> = ({ slug, currentStatus }) => {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [status, setStatus] = useState<InteractiveStatus>(currentStatus);
|
||||
const [noteText, setNoteText] = useState("");
|
||||
const [hasOverride, setHasOverride] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [todos, setTodos] = useState<TodoItem[]>([]);
|
||||
const [newTodo, setNewTodo] = useState("");
|
||||
|
||||
const refreshTodos = () => setTodos(getTodos(slug));
|
||||
|
||||
useEffect(() => {
|
||||
const admin = isAdminLoggedIn();
|
||||
setIsAdmin(admin);
|
||||
if (admin) {
|
||||
syncFromServer().finally(() => {
|
||||
const override = getStatusOverride(slug);
|
||||
if (override) {
|
||||
setStatus(override);
|
||||
setHasOverride(true);
|
||||
}
|
||||
setNoteText(getNote(slug));
|
||||
refreshTodos();
|
||||
});
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const handleStatusChange = (newStatus: InteractiveStatus) => {
|
||||
setStatus(newStatus);
|
||||
setStatusOverride(slug, newStatus);
|
||||
setHasOverride(true);
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleNoteChange = (value: string) => {
|
||||
setNoteText(value);
|
||||
setNote(slug, value);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const success = await commitStatus(slug, status);
|
||||
setSaving(false);
|
||||
if (success) {
|
||||
setHasOverride(false);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTodo = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTodo.trim()) return;
|
||||
addTodo(slug, newTodo.trim());
|
||||
setNewTodo("");
|
||||
refreshTodos();
|
||||
};
|
||||
|
||||
const handleToggleTodo = (todoId: string) => {
|
||||
toggleTodo(slug, todoId);
|
||||
refreshTodos();
|
||||
};
|
||||
|
||||
const handleRemoveTodo = (todoId: string) => {
|
||||
removeTodo(slug, todoId);
|
||||
refreshTodos();
|
||||
};
|
||||
|
||||
const handleRemarkChange = (todoId: string, remark: string) => {
|
||||
updateTodoRemark(slug, todoId, remark);
|
||||
refreshTodos();
|
||||
};
|
||||
|
||||
const isDirty = hasOverride;
|
||||
const doneCount = todos.filter((t) => t.done).length;
|
||||
const hasTodos = todos.length > 0;
|
||||
const openTodos = todos.filter((t) => !t.done);
|
||||
const doneTodos = todos.filter((t) => t.done);
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border-2 border-dashed border-primary/15 bg-primary/[0.02]">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-primary font-medium">
|
||||
<Shield className="w-4 h-4" />
|
||||
Admin
|
||||
{isDirty && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
unsaved
|
||||
</Badge>
|
||||
)}
|
||||
{saved && (
|
||||
<Badge variant="outline" className="text-xs text-green-600 border-green-300">
|
||||
saved
|
||||
</Badge>
|
||||
)}
|
||||
{noteText && (
|
||||
<StickyNote className="w-3.5 h-3.5 text-yellow-600" />
|
||||
)}
|
||||
{hasTodos && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ListTodo className="w-3.5 h-3.5" />
|
||||
{doneCount}/{todos.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
statusOptions.find((o) => o.value === status)?.color || ""
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-4">
|
||||
{/* Status control */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||
Status
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
{statusOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleStatusChange(opt.value)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
status === opt.value
|
||||
? `${opt.color} ring-2 ring-offset-1 ring-current`
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saving}
|
||||
className="gap-1.5 h-7 text-xs ml-2"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : saved ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<Save className="w-3 h-3" />
|
||||
)}
|
||||
{saving ? "Saving..." : saved ? "Saved" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Todos */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||
Todos {hasTodos && <span className="text-muted-foreground/60">({doneCount}/{todos.length} done)</span>}
|
||||
</label>
|
||||
{openTodos.length > 0 && (
|
||||
<ul className="space-y-2 mb-2">
|
||||
{openTodos.map((todo) => (
|
||||
<TodoRow
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
slug={slug}
|
||||
onToggle={() => handleToggleTodo(todo.id)}
|
||||
onRemove={() => handleRemoveTodo(todo.id)}
|
||||
onRemarkChange={(r) => handleRemarkChange(todo.id, r)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{doneTodos.length > 0 && (
|
||||
<details className="mb-2">
|
||||
<summary className="text-xs text-muted-foreground cursor-pointer mb-1">
|
||||
{doneTodos.length} completed
|
||||
</summary>
|
||||
<ul className="space-y-2 opacity-60">
|
||||
{doneTodos.map((todo) => (
|
||||
<TodoRow
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
slug={slug}
|
||||
onToggle={() => handleToggleTodo(todo.id)}
|
||||
onRemove={() => handleRemoveTodo(todo.id)}
|
||||
onRemarkChange={(r) => handleRemarkChange(todo.id, r)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<form onSubmit={handleAddTodo} className="flex gap-2">
|
||||
<Input
|
||||
value={newTodo}
|
||||
onChange={(e) => setNewTodo(e.target.value)}
|
||||
placeholder="Add a todo... (use T1, T2 to reference others)"
|
||||
className="text-sm h-8"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8 px-2" disabled={!newTodo.trim()}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||
Notes
|
||||
</label>
|
||||
<Textarea
|
||||
value={noteText}
|
||||
onChange={(e) => handleNoteChange(e.target.value)}
|
||||
placeholder="Add private notes about this interactive..."
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBar;
|
||||
459
src/components/AdminDashboard.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Lock, Search, ExternalLink, LogOut, Trash2, StickyNote,
|
||||
Lightbulb, ListTodo, CheckCircle, Circle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
allInteractives,
|
||||
type InteractiveStatus,
|
||||
} from "@/lib/interactives";
|
||||
import {
|
||||
ADMIN_PASSWORD,
|
||||
isAdminLoggedIn,
|
||||
setAdminLoggedIn,
|
||||
getAllStatusOverrides,
|
||||
getAllNotes,
|
||||
getIdeas,
|
||||
removeIdea,
|
||||
syncFromServer,
|
||||
deleteInteractive,
|
||||
getTodos,
|
||||
type IdeaEntry,
|
||||
type TodoItem,
|
||||
} from "@/lib/admin";
|
||||
|
||||
const statusColors: Record<InteractiveStatus, string> = {
|
||||
published: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
draft: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
idea: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
};
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<InteractiveStatus | "all">("all");
|
||||
const [filterTheme, setFilterTheme] = useState<string>("all");
|
||||
const [overrides, setOverrides] = useState<Record<string, InteractiveStatus>>({});
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
const [ideas, setIdeas] = useState<IdeaEntry[]>([]);
|
||||
const [allTodos, setAllTodos] = useState<{ slug: string; title: string; todos: TodoItem[] }[]>([]);
|
||||
const [todoFilter, setTodoFilter] = useState<"open" | "done" | "all">("open");
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [interactivesPage, setInteractivesPage] = useState(1);
|
||||
const [todosPage, setTodosPage] = useState(1);
|
||||
const [ideasPage, setIdeasPage] = useState(1);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdminLoggedIn()) setAuthenticated(true);
|
||||
}, []);
|
||||
|
||||
const refreshFromStorage = () => {
|
||||
setOverrides(getAllStatusOverrides());
|
||||
setNotes(getAllNotes());
|
||||
setIdeas(getIdeas());
|
||||
// Load todos for all interactives
|
||||
const todosData = allInteractives
|
||||
.map((i) => ({ slug: i.slug, title: i.title, todos: getTodos(i.slug) }))
|
||||
.filter((t) => t.todos.length > 0);
|
||||
setAllTodos(todosData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
syncFromServer().then(refreshFromStorage).catch(refreshFromStorage);
|
||||
}
|
||||
}, [authenticated]);
|
||||
|
||||
// Reset pagination when filters change
|
||||
useEffect(() => { setInteractivesPage(1); }, [searchTerm, filterStatus, filterTheme]);
|
||||
useEffect(() => { setTodosPage(1); }, [todoFilter]);
|
||||
useEffect(() => { setIdeasPage(1); }, [searchTerm, filterStatus, filterTheme]);
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password === ADMIN_PASSWORD) {
|
||||
setAdminLoggedIn(true);
|
||||
setAuthenticated(true);
|
||||
setError(false);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setAdminLoggedIn(false);
|
||||
setAuthenticated(false);
|
||||
setPassword("");
|
||||
};
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="w-5 h-5" />
|
||||
Admin Login
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(false); }}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">Incorrect password.</p>}
|
||||
<Button type="submit" className="w-full">Sign In</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveStatus = (slug: string, sourceStatus: InteractiveStatus) =>
|
||||
overrides[slug] || sourceStatus;
|
||||
|
||||
const filteredInteractives = allInteractives.filter((i) => {
|
||||
const matchesSearch = !searchTerm || i.title.toLowerCase().includes(searchTerm.toLowerCase()) || i.slug.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const status = effectiveStatus(i.slug, i.status);
|
||||
const matchesStatus = filterStatus === "all" || status === filterStatus;
|
||||
const matchesTheme = filterTheme === "all" || i.themes.includes(filterTheme);
|
||||
return matchesSearch && matchesStatus && matchesTheme;
|
||||
});
|
||||
|
||||
const filteredIdeas = ideas.filter((idea) => {
|
||||
const matchesSearch = !searchTerm || idea.title.toLowerCase().includes(searchTerm.toLowerCase()) || idea.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = filterStatus === "all" || filterStatus === "idea";
|
||||
const matchesTheme = filterTheme === "all" || idea.themes.includes(filterTheme);
|
||||
return matchesSearch && matchesStatus && matchesTheme;
|
||||
});
|
||||
|
||||
const counts = {
|
||||
published: allInteractives.filter((i) => effectiveStatus(i.slug, i.status) === "published").length,
|
||||
draft: allInteractives.filter((i) => effectiveStatus(i.slug, i.status) === "draft").length,
|
||||
idea: allInteractives.filter((i) => effectiveStatus(i.slug, i.status) === "idea").length + ideas.length,
|
||||
};
|
||||
|
||||
const uniqueThemes = Array.from(new Set([...allInteractives.flatMap((i) => i.themes), ...ideas.flatMap((i) => i.themes)])).sort();
|
||||
const hasOverrides = Object.keys(overrides).length > 0;
|
||||
|
||||
const handleRemoveIdea = (id: string) => { removeIdea(id); refreshFromStorage(); };
|
||||
|
||||
const handleDeleteInteractive = async (slug: string) => {
|
||||
if (!confirm(`Delete "${slug}" permanently? This removes it from interactives.ts.`)) return;
|
||||
setDeleting(slug);
|
||||
await deleteInteractive(slug);
|
||||
setDeleting(null);
|
||||
// Page needs reload since allInteractives is static
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// Todos aggregation
|
||||
const totalOpenTodos = allTodos.reduce((sum, t) => sum + t.todos.filter((x) => !x.done).length, 0);
|
||||
const totalDoneTodos = allTodos.reduce((sum, t) => sum + t.todos.filter((x) => x.done).length, 0);
|
||||
|
||||
const filteredTodos = allTodos
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
todos: entry.todos.filter((t) => {
|
||||
if (todoFilter === "open") return !t.done;
|
||||
if (todoFilter === "done") return t.done;
|
||||
return true;
|
||||
}),
|
||||
}))
|
||||
.filter((entry) => entry.todos.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold font-display">Admin Dashboard</h1>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout} className="gap-2">
|
||||
<LogOut className="w-4 h-4" /> Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{(["published", "draft", "idea"] as const).map((s) => {
|
||||
const colors = { published: "text-green-600", draft: "text-yellow-600", idea: "text-blue-600" };
|
||||
const rings = { published: "ring-green-500", draft: "ring-yellow-500", idea: "ring-blue-500" };
|
||||
return (
|
||||
<Card key={s} className={`cursor-pointer transition-shadow hover:shadow-md ${filterStatus === s ? `ring-2 ${rings[s]}` : ""}`}
|
||||
onClick={() => setFilterStatus(filterStatus === s ? "all" : s)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className={`text-3xl font-bold ${colors[s]}`}>{counts[s]}</div>
|
||||
<p className="text-sm text-muted-foreground capitalize">{s}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasOverrides && (
|
||||
<Card className="border-yellow-300 bg-yellow-50 dark:bg-yellow-950 dark:border-yellow-800">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm"><strong>{Object.keys(overrides).length} pending status change(s)</strong> — use the Save button on each interactive's page to commit.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="interactives">
|
||||
<TabsList>
|
||||
<TabsTrigger value="interactives">Interactives</TabsTrigger>
|
||||
<TabsTrigger value="todos" className="gap-1.5">
|
||||
Todos
|
||||
{totalOpenTodos > 0 && (
|
||||
<Badge variant="secondary" className="text-xs ml-1">{totalOpenTodos}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ideas" className="gap-1.5">
|
||||
Ideas
|
||||
{ideas.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs ml-1">{ideas.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ─── Interactives Tab ─── */}
|
||||
<TabsContent value="interactives" className="space-y-4 mt-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input placeholder="Search..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10" />
|
||||
</div>
|
||||
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value as any)} className="rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||
<option value="all">All statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="idea">Idea</option>
|
||||
</select>
|
||||
<select value={filterTheme} onChange={(e) => setFilterTheme(e.target.value)} className="rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||
<option value="all">All themes</option>
|
||||
{uniqueThemes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {filteredInteractives.length} of {allInteractives.length}
|
||||
</p>
|
||||
|
||||
{filteredInteractives.length > 0 && (() => {
|
||||
const totalInteractivesPages = Math.ceil(filteredInteractives.length / PAGE_SIZE);
|
||||
const paginatedInteractives = filteredInteractives.slice((interactivesPage - 1) * PAGE_SIZE, interactivesPage * PAGE_SIZE);
|
||||
return (<>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">Interactive</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Theme</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Added</th>
|
||||
<th className="px-4 py-3 font-medium w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{paginatedInteractives.map((item) => {
|
||||
const status = effectiveStatus(item.slug, item.status);
|
||||
const hasNote = notes[item.slug];
|
||||
const todoCount = getTodos(item.slug).filter((t) => !t.done).length;
|
||||
return (
|
||||
<tr key={item.slug} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.slug}</p>
|
||||
</div>
|
||||
{hasNote && <StickyNote className="w-3.5 h-3.5 text-yellow-500 shrink-0" />}
|
||||
{todoCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<ListTodo className="w-3 h-3" />{todoCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.themes.map((t) => <Badge key={t} variant="outline" className="text-xs">{t}</Badge>)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColors[status]}`}>{status}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground hidden md:table-cell">{item.dateAdded}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<a href={`/i/${item.slug}`} className="text-muted-foreground hover:text-foreground" target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleDeleteInteractive(item.slug)}
|
||||
disabled={deleting === item.slug}
|
||||
className="text-muted-foreground hover:text-destructive disabled:opacity-50"
|
||||
title="Delete interactive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalInteractivesPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => setInteractivesPage(p => p - 1)} disabled={interactivesPage === 1}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {interactivesPage} of {totalInteractivesPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setInteractivesPage(p => p + 1)} disabled={interactivesPage === totalInteractivesPages}>Next</Button>
|
||||
</div>
|
||||
)}
|
||||
</>);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* ─── Todos Tab ─── */}
|
||||
<TabsContent value="todos" className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalOpenTodos} open, {totalDoneTodos} done
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["open", "done", "all"] as const).map((f) => (
|
||||
<Button key={f} variant={todoFilter === f ? "default" : "ghost"} size="sm"
|
||||
className={todoFilter === f ? "!h-8 !min-h-8 rounded-full !px-4" : ""}
|
||||
onClick={() => setTodoFilter(f)}>
|
||||
{f === "open" ? "Open" : f === "done" ? "Done" : "All"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredTodos.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
{todoFilter === "open" ? "No open todos." : todoFilter === "done" ? "No completed todos." : "No todos."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const totalTodosPages = Math.ceil(filteredTodos.length / PAGE_SIZE);
|
||||
const paginatedTodos = filteredTodos.slice((todosPage - 1) * PAGE_SIZE, todosPage * PAGE_SIZE);
|
||||
return (<>
|
||||
{paginatedTodos.map((entry) => (
|
||||
<Card key={entry.slug}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center justify-between">
|
||||
<a href={`/i/${entry.slug}`} className="hover:text-primary transition-colors">
|
||||
{entry.title}
|
||||
</a>
|
||||
<Badge variant="outline" className="text-xs font-mono">{entry.slug}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{entry.todos.map((todo) => (
|
||||
<li key={todo.id} className="flex items-start gap-2">
|
||||
{todo.done ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-mono text-primary/60">{todo.refId}</span>
|
||||
<span className={`text-sm ${todo.done ? "line-through text-muted-foreground" : ""}`}>
|
||||
{todo.text}
|
||||
</span>
|
||||
</div>
|
||||
{todo.remark && (
|
||||
<p className="text-xs text-muted-foreground italic mt-0.5">{todo.remark}</p>
|
||||
)}
|
||||
{todo.done && todo.doneDate && (
|
||||
<span className="text-xs text-green-600">Done {todo.doneDate}</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{totalTodosPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => setTodosPage(p => p - 1)} disabled={todosPage === 1}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {todosPage} of {totalTodosPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setTodosPage(p => p + 1)} disabled={todosPage === totalTodosPages}>Next</Button>
|
||||
</div>
|
||||
)}
|
||||
</>);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* ─── Ideas Tab ─── */}
|
||||
<TabsContent value="ideas" className="space-y-4 mt-4">
|
||||
{ideas.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">No ideas yet. Add ideas from theme or subcategory pages.</p>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const totalIdeasPages = Math.ceil(filteredIdeas.length / PAGE_SIZE);
|
||||
const paginatedIdeas = filteredIdeas.slice((ideasPage - 1) * PAGE_SIZE, ideasPage * PAGE_SIZE);
|
||||
return (<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{paginatedIdeas.map((idea) => (
|
||||
<Card key={idea.id} className="relative border-dashed border-blue-300 dark:border-blue-700">
|
||||
<button onClick={() => handleRemoveIdea(idea.id)} className="absolute top-3 right-3 text-muted-foreground hover:text-destructive">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base pr-6 flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4 text-blue-500 shrink-0" />
|
||||
{idea.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{idea.description && <p className="text-sm text-muted-foreground mb-2">{idea.description}</p>}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{idea.themes.map((t) => <Badge key={t} variant="outline" className="text-xs">{t}</Badge>)}
|
||||
{idea.subcategory && <Badge variant="secondary" className="text-xs">{idea.subcategory}</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Added {idea.createdAt}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{totalIdeasPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => setIdeasPage(p => p - 1)} disabled={ideasPage === 1}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {ideasPage} of {totalIdeasPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setIdeasPage(p => p + 1)} disabled={ideasPage === totalIdeasPages}>Next</Button>
|
||||
</div>
|
||||
)}
|
||||
</>);
|
||||
})()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
63
src/components/AdminDraftCards.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FileEdit } from "lucide-react";
|
||||
import { isAdminLoggedIn, syncFromServer, getAllStatusOverrides } from "@/lib/admin";
|
||||
import { allInteractives, type InteractiveStatus } from "@/lib/interactives";
|
||||
|
||||
interface AdminDraftCardsProps {
|
||||
theme?: string;
|
||||
subcategory?: string;
|
||||
}
|
||||
|
||||
const AdminDraftCards: React.FC<AdminDraftCardsProps> = ({ theme, subcategory }) => {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [drafts, setDrafts] = useState<typeof allInteractives>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const admin = isAdminLoggedIn();
|
||||
setIsAdmin(admin);
|
||||
if (admin) {
|
||||
syncFromServer().finally(() => {
|
||||
const overrides = getAllStatusOverrides();
|
||||
const filtered = allInteractives.filter((i) => {
|
||||
const effectiveStatus: InteractiveStatus = overrides[i.slug] || i.status;
|
||||
if (effectiveStatus !== "draft") return false;
|
||||
if (theme && !i.themes.includes(theme)) return false;
|
||||
if (subcategory && i.subcategory !== subcategory) return false;
|
||||
return true;
|
||||
});
|
||||
setDrafts(filtered);
|
||||
});
|
||||
}
|
||||
}, [theme, subcategory]);
|
||||
|
||||
if (!isAdmin || drafts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{drafts.map((item) => (
|
||||
<a key={item.slug} href={`/i/${item.slug}`} className="group">
|
||||
<div className="rounded-xl border-2 border-dashed border-yellow-400 dark:border-yellow-600 bg-yellow-50/50 dark:bg-yellow-950/20 p-6 h-full opacity-70">
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<FileEdit className="w-4 h-4 text-yellow-600 mt-0.5 shrink-0" />
|
||||
<h3 className="text-lg font-semibold text-yellow-800 dark:text-yellow-300 group-hover:text-yellow-600 transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||||
{item.description}
|
||||
</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-yellow-400 text-yellow-700 dark:border-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
draft
|
||||
</Badge>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDraftCards;
|
||||
183
src/components/AdminIdeaCards.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Lightbulb, Pencil, Trash2, Check, X } from "lucide-react";
|
||||
import {
|
||||
isAdminLoggedIn,
|
||||
getIdeas,
|
||||
syncFromServer,
|
||||
updateIdea,
|
||||
removeIdea,
|
||||
type IdeaEntry,
|
||||
} from "@/lib/admin";
|
||||
|
||||
interface AdminIdeaCardsProps {
|
||||
theme?: string;
|
||||
subcategory?: string;
|
||||
}
|
||||
|
||||
const IdeaCard: React.FC<{
|
||||
idea: IdeaEntry;
|
||||
onUpdate: (id: string, updates: Partial<IdeaEntry>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}> = ({ idea, onUpdate, onRemove }) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [title, setTitle] = useState(idea.title);
|
||||
const [description, setDescription] = useState(idea.description);
|
||||
const [notes, setNotes] = useState(idea.notes || "");
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && titleRef.current) {
|
||||
titleRef.current.focus();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(idea.id, { title, description, notes });
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setTitle(idea.title);
|
||||
setDescription(idea.description);
|
||||
setNotes(idea.notes || "");
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") handleCancel();
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border-2 border-dashed border-blue-400 dark:border-blue-600 bg-blue-50/50 dark:bg-blue-950/20 p-4 h-full space-y-3"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Input
|
||||
ref={titleRef}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="text-sm font-semibold"
|
||||
placeholder="Title"
|
||||
/>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="text-sm"
|
||||
placeholder="Description"
|
||||
rows={2}
|
||||
/>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="text-xs"
|
||||
placeholder="Private notes..."
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSave} className="gap-1 h-7 text-xs">
|
||||
<Check className="w-3 h-3" /> Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleCancel} className="gap-1 h-7 text-xs">
|
||||
<X className="w-3 h-3" /> Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border-2 border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-950/20 p-6 h-full opacity-70 group/idea relative">
|
||||
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover/idea:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="p-1 rounded hover:bg-blue-200 dark:hover:bg-blue-800 text-blue-500"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove(idea.id)}
|
||||
className="p-1 rounded hover:bg-red-200 dark:hover:bg-red-800 text-red-400"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<Lightbulb className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
|
||||
<h3 className="text-lg font-semibold text-blue-800 dark:text-blue-300">
|
||||
{idea.title}
|
||||
</h3>
|
||||
</div>
|
||||
{idea.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">
|
||||
{idea.description}
|
||||
</p>
|
||||
)}
|
||||
{idea.notes && (
|
||||
<p className="text-xs text-blue-500/70 italic mb-2 line-clamp-1">
|
||||
{idea.notes}
|
||||
</p>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-blue-300 text-blue-600 dark:border-blue-700 dark:text-blue-400"
|
||||
>
|
||||
idea
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminIdeaCards: React.FC<AdminIdeaCardsProps> = ({ theme, subcategory }) => {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [ideas, setIdeas] = useState<IdeaEntry[]>([]);
|
||||
|
||||
const loadIdeas = () => {
|
||||
const all = getIdeas();
|
||||
const filtered = all.filter((idea) => {
|
||||
if (theme && !idea.themes.includes(theme)) return false;
|
||||
if (subcategory && idea.subcategory !== subcategory) return false;
|
||||
return true;
|
||||
});
|
||||
setIdeas(filtered);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const admin = isAdminLoggedIn();
|
||||
setIsAdmin(admin);
|
||||
if (admin) {
|
||||
syncFromServer().finally(loadIdeas);
|
||||
}
|
||||
}, [theme, subcategory]);
|
||||
|
||||
const handleUpdate = (id: string, updates: Partial<IdeaEntry>) => {
|
||||
updateIdea(id, updates);
|
||||
loadIdeas();
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
removeIdea(id);
|
||||
loadIdeas();
|
||||
};
|
||||
|
||||
if (!isAdmin || ideas.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ideas.map((idea) => (
|
||||
<IdeaCard
|
||||
key={idea.id}
|
||||
idea={idea}
|
||||
onUpdate={handleUpdate}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminIdeaCards;
|
||||
116
src/components/BaseHead.astro
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
// Import the global.css file here so that it is included on
|
||||
// all pages through the use of <BaseHead /> component.
|
||||
import "../styles/global.css";
|
||||
import { Font } from "astro:assets";
|
||||
|
||||
import { SITE_METADATA, SITE_TITLE } from "../consts";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
const { title, description, image } = Astro.props;
|
||||
const finalTitle =
|
||||
title && title !== SITE_METADATA.title.default
|
||||
? `${title} | Hatch`
|
||||
: title || SITE_METADATA.title.default;
|
||||
const finalDescription = description || SITE_METADATA.description;
|
||||
const finalImage = image || SITE_METADATA.openGraph.images[0].url;
|
||||
const imageURL = new URL(finalImage, Astro.site);
|
||||
---
|
||||
|
||||
<!-- Global Metadata -->
|
||||
<meta charset="utf-8" />
|
||||
<Font cssVariable="--font-imprima" />
|
||||
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta
|
||||
name="robots"
|
||||
content={`${SITE_METADATA.robots.index ? "index" : "noindex"}, ${SITE_METADATA.robots.follow ? "follow" : "nofollow"}`}
|
||||
/>
|
||||
<meta name="keywords" content={SITE_METADATA.keywords.join(", ")} />
|
||||
<meta name="author" content={SITE_METADATA.authors[0].name} />
|
||||
<meta name="creator" content={SITE_METADATA.creator} />
|
||||
<meta name="publisher" content={SITE_METADATA.publisher} />
|
||||
|
||||
<!-- Theme (align with next-themes: default light, supports system) -->
|
||||
<script is:inline>
|
||||
try {
|
||||
const stored = localStorage.getItem("theme");
|
||||
let resolved = "light";
|
||||
if (stored === "dark" || stored === "light") {
|
||||
resolved = stored;
|
||||
} else if (stored === "system") {
|
||||
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||
} catch {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Favicon -->
|
||||
{
|
||||
SITE_METADATA.icons.icon.map((icon) => (
|
||||
<link rel="icon" type={icon.type} sizes={icon.sizes} href={icon.url} />
|
||||
))
|
||||
}
|
||||
{
|
||||
SITE_METADATA.icons.apple.map((icon) => (
|
||||
<link rel="apple-touch-icon" sizes={icon.sizes} href={icon.url} />
|
||||
))
|
||||
}
|
||||
{
|
||||
SITE_METADATA.icons.shortcut.map((icon) => (
|
||||
<link rel="shortcut icon" href={icon.url} />
|
||||
))
|
||||
}
|
||||
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title={SITE_TITLE}
|
||||
href={new URL("rss.xml", Astro.site)}
|
||||
/>
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{finalTitle}</title>
|
||||
<meta name="title" content={finalTitle} />
|
||||
<meta name="description" content={finalDescription} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:site_name" content={SITE_METADATA.openGraph.siteName} />
|
||||
<meta property="og:title" content={finalTitle} />
|
||||
<meta property="og:description" content={finalDescription} />
|
||||
<meta property="og:image" content={imageURL} />
|
||||
<meta
|
||||
property="og:image:width"
|
||||
content={SITE_METADATA.openGraph.images[0].width.toString()}
|
||||
/>
|
||||
<meta
|
||||
property="og:image:height"
|
||||
content={SITE_METADATA.openGraph.images[0].height.toString()}
|
||||
/>
|
||||
<meta property="og:image:alt" content={SITE_METADATA.openGraph.images[0].alt} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content={SITE_METADATA.twitter.card} />
|
||||
<meta property="twitter:url" content={canonicalURL} />
|
||||
<meta property="twitter:title" content={finalTitle} />
|
||||
<meta property="twitter:description" content={finalDescription} />
|
||||
<meta property="twitter:image" content={imageURL} />
|
||||
<meta property="twitter:creator" content={SITE_METADATA.twitter.creator} />
|
||||
43
src/components/GreenScreenWrapper.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
interface GreenScreenWrapperProps {
|
||||
children: React.ReactNode;
|
||||
mode: 'lite' | 'dark';
|
||||
}
|
||||
|
||||
const GreenScreenWrapper = ({ children, mode }: GreenScreenWrapperProps) => {
|
||||
const bgColor = 'bg-[#00ff00]'; // Pure green for chroma key
|
||||
const textColor = mode === 'lite' ? 'text-black' : 'text-white';
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${bgColor} ${textColor} p-8`}>
|
||||
<style>
|
||||
{`
|
||||
/* Override all colors for green screen mode */
|
||||
.green-screen * {
|
||||
background-color: transparent !important;
|
||||
border-color: ${mode === 'lite' ? '#000000' : '#ffffff'} !important;
|
||||
color: ${mode === 'lite' ? '#000000' : '#ffffff'} !important;
|
||||
}
|
||||
|
||||
.green-screen button {
|
||||
background-color: ${mode === 'lite' ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)'} !important;
|
||||
border: 2px solid ${mode === 'lite' ? '#000000' : '#ffffff'} !important;
|
||||
}
|
||||
|
||||
.green-screen button:hover {
|
||||
background-color: ${mode === 'lite' ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.2)'} !important;
|
||||
}
|
||||
|
||||
.green-screen input {
|
||||
background-color: ${mode === 'lite' ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.05)'} !important;
|
||||
border: 2px solid ${mode === 'lite' ? '#000000' : '#ffffff'} !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className="green-screen max-w-4xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GreenScreenWrapper;
|
||||
56
src/components/HomepageHero.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { publishedInteractives, primaryTheme } from '@/lib/interactives';
|
||||
|
||||
const HomepageHero: React.FC = () => {
|
||||
const featured = [...publishedInteractives]
|
||||
.sort((a, b) => b.dateAdded.localeCompare(a.dateAdded))
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
{/* Hero */}
|
||||
<section className="text-center space-y-6 pt-8 pb-4">
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground font-display">
|
||||
Interactives
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
||||
A collection of interactive games, puzzles, and mathematical explorations
|
||||
for enhanced learning experiences and pure joy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Recently Added */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-foreground font-display">Recently Added</h2>
|
||||
<a href="/i" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{featured.map((item) => (
|
||||
<a key={item.slug} href={`/i/${item.slug}`} className="group">
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg group-hover:text-primary transition-colors">
|
||||
{item.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{item.description}</p>
|
||||
<div className="mt-3">
|
||||
<Badge variant="outline" className="text-xs">{primaryTheme(item)}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomepageHero;
|
||||
113
src/components/InteractiveBreadcrumb.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { allThemes, type Theme } from "@/lib/interactives";
|
||||
|
||||
interface InteractiveBreadcrumbProps {
|
||||
title: string;
|
||||
themes: string[];
|
||||
subcategory?: string;
|
||||
}
|
||||
|
||||
const Chevron = () => (
|
||||
<li role="presentation" aria-hidden="true" className="[&>svg]:size-3.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</li>
|
||||
);
|
||||
|
||||
const InteractiveBreadcrumb: React.FC<InteractiveBreadcrumbProps> = ({
|
||||
title,
|
||||
themes,
|
||||
subcategory,
|
||||
}) => {
|
||||
const [activeTheme, setActiveTheme] = useState<Theme | undefined>(undefined);
|
||||
const [activeSub, setActiveSub] = useState<
|
||||
{ slug: string; title: string } | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromSlug = params.get("from");
|
||||
|
||||
let theme: Theme | undefined;
|
||||
if (fromSlug) {
|
||||
// Try to match the from param to a theme the interactive belongs to
|
||||
theme = allThemes.find(
|
||||
(t) => t.slug === fromSlug && themes.includes(t.title)
|
||||
);
|
||||
}
|
||||
// Fall back to primary theme (first in array)
|
||||
if (!theme) {
|
||||
theme = allThemes.find((t) => t.title === themes[0]);
|
||||
}
|
||||
setActiveTheme(theme);
|
||||
|
||||
// Resolve subcategory if present and theme matches
|
||||
if (subcategory && theme?.subcategories) {
|
||||
const sub = theme.subcategories.find((s) => s.slug === subcategory);
|
||||
setActiveSub(sub ? { slug: sub.slug, title: sub.title } : undefined);
|
||||
}
|
||||
}, [themes, subcategory]);
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="mb-6">
|
||||
<ol className="flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<li className="inline-flex items-center gap-1">
|
||||
<a href="/" className="transition-colors hover:text-foreground">
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
{activeTheme && (
|
||||
<>
|
||||
<Chevron />
|
||||
<li className="inline-flex items-center gap-1">
|
||||
<a
|
||||
href={`/themes/${activeTheme.slug}`}
|
||||
className="transition-colors hover:text-foreground"
|
||||
>
|
||||
{activeTheme.title}
|
||||
</a>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{activeSub && activeTheme && (
|
||||
<>
|
||||
<Chevron />
|
||||
<li className="inline-flex items-center gap-1">
|
||||
<a
|
||||
href={`/themes/${activeTheme.slug}/${activeSub.slug}`}
|
||||
className="transition-colors hover:text-foreground"
|
||||
>
|
||||
{activeSub.title}
|
||||
</a>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
<Chevron />
|
||||
<li className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="font-normal text-foreground"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractiveBreadcrumb;
|
||||
237
src/components/InteractiveGallery.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search, SortAsc, SortDesc } from 'lucide-react';
|
||||
import { publishedInteractives, primaryTheme } from '@/lib/interactives';
|
||||
|
||||
type SortField = 'title' | 'dateAdded';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
function getInitialTheme(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return new URLSearchParams(window.location.search).get('theme') || '';
|
||||
}
|
||||
|
||||
const InteractiveGallery: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedTag, setSelectedTag] = useState<string>('');
|
||||
const [selectedTheme, setSelectedTheme] = useState<string>(getInitialTheme);
|
||||
const [sortField, setSortField] = useState<SortField>('title');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [showTags, setShowTags] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
publishedInteractives.forEach((i) => i.tags.forEach((t) => tags.add(t)));
|
||||
return Array.from(tags).sort();
|
||||
}, []);
|
||||
|
||||
const allThemes = useMemo(() => {
|
||||
const themes = new Set<string>();
|
||||
publishedInteractives.forEach((i) => i.themes.forEach((t) => themes.add(t)));
|
||||
return Array.from(themes).sort();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = publishedInteractives.filter((i) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
i.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
i.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
i.tags.some((t) => t.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesTag = !selectedTag || i.tags.includes(selectedTag);
|
||||
const matchesTheme = !selectedTheme || i.themes.includes(selectedTheme);
|
||||
return matchesSearch && matchesTag && matchesTheme;
|
||||
});
|
||||
|
||||
result.sort((a, b) => {
|
||||
const aVal = a[sortField].toLowerCase();
|
||||
const bVal = b[sortField].toLowerCase();
|
||||
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [searchTerm, selectedTag, selectedTheme, sortField, sortDirection]);
|
||||
|
||||
// Reset pagination when filters/sort change
|
||||
useEffect(() => { setPage(1); }, [searchTerm, selectedTag, selectedTheme, sortField, sortDirection]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const handleSortChange = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-foreground mb-8">
|
||||
All Interactives ({publishedInteractives.length})
|
||||
</h1>
|
||||
|
||||
{/* Search and Sort */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Search interactives..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => handleSortChange('title')} className="gap-2">
|
||||
{sortField === 'title' ? (
|
||||
sortDirection === 'asc' ? <SortAsc className="w-4 h-4" /> : <SortDesc className="w-4 h-4" />
|
||||
) : (
|
||||
<SortAsc className="w-4 h-4" />
|
||||
)}
|
||||
Title
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleSortChange('dateAdded')} className="gap-2">
|
||||
{sortField === 'dateAdded' ? (
|
||||
sortDirection === 'asc' ? <SortAsc className="w-4 h-4" /> : <SortDesc className="w-4 h-4" />
|
||||
) : (
|
||||
<SortAsc className="w-4 h-4" />
|
||||
)}
|
||||
Recency
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Filter */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Badge
|
||||
variant={selectedTheme === '' ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedTheme('')}
|
||||
>
|
||||
All
|
||||
</Badge>
|
||||
{allThemes.map((theme) => (
|
||||
<Badge
|
||||
key={theme}
|
||||
variant={selectedTheme === theme ? 'default' : 'outline'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={() => setSelectedTheme(selectedTheme === theme ? '' : theme)}
|
||||
>
|
||||
{theme}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Active filters */}
|
||||
{(selectedTag || selectedTheme) && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Filtered by:</span>
|
||||
{selectedTheme && (
|
||||
<Badge variant="secondary" className="cursor-pointer" onClick={() => setSelectedTheme('')}>
|
||||
{selectedTheme} ×
|
||||
</Badge>
|
||||
)}
|
||||
{selectedTag && (
|
||||
<Badge variant="secondary" className="cursor-pointer" onClick={() => setSelectedTag('')}>
|
||||
{selectedTag} ×
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Filter */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowTags((v) => !v)}>
|
||||
{showTags ? 'Hide Tags' : 'Filter by Tags'}
|
||||
</Button>
|
||||
</div>
|
||||
{showTags && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={selectedTag === tag ? 'default' : 'outline'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={() => setSelectedTag(selectedTag === tag ? '' : tag)}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
Showing {filtered.length} of {publishedInteractives.length} interactives
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{paginated.map((interactive) => (
|
||||
<a key={interactive.slug} href={`/i/${interactive.slug}`} className="group">
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm transition-shadow hover:shadow-md h-full flex flex-col">
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors mb-2">
|
||||
{interactive.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3 flex-1">
|
||||
{interactive.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedTheme(selectedTheme === primaryTheme(interactive) ? '' : primaryTheme(interactive));
|
||||
}}
|
||||
>
|
||||
{primaryTheme(interactive)}
|
||||
</Badge>
|
||||
{interactive.tags.slice(0, 2).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-xs cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedTag(selectedTag === tag ? '' : tag);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {page} of {totalPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={page === totalPages}>Next</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">No interactives found matching your criteria.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractiveGallery;
|
||||
86
src/components/InteractiveRenderer.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React, { Suspense, lazy, type ComponentType } from "react";
|
||||
|
||||
const componentMap: Record<string, () => Promise<{ default: ComponentType<any> }>> = {
|
||||
"binary-number-game": () => import("./interactives/BinaryNumberGame"),
|
||||
"ternary-number-game": () => import("./interactives/TernaryNumberGame"),
|
||||
"balanced-ternary-game": () => import("./interactives/BalancedTernaryGame"),
|
||||
"zeckendorf-game": () => import("./interactives/ZeckendorfGame"),
|
||||
"zeckendorf-search-trick": () => import("./interactives/ZeckendorfSearchTrick"),
|
||||
"binary-search-trick": () => import("./interactives/BinarySearchTrick"),
|
||||
"ternary-search-trick": () => import("./interactives/TernarySearchTrick"),
|
||||
"game-of-sim": () => import("./interactives/GameOfSim"),
|
||||
"northcotts-game": () => import("./interactives/NorthcottsGame"),
|
||||
"nim-game": () => import("./interactives/NimGame"),
|
||||
"assisted-nim-game": () => import("./interactives/AssistedNimGame"),
|
||||
"gold-coin-game": () => import("./interactives/GoldCoinGame"),
|
||||
"subtraction-game": () => import("./interactives/SubtractionGame"),
|
||||
"craps-game": () => import("./interactives/CrapsGame"),
|
||||
"bagchal-game": () => import("./interactives/BagchalGame"),
|
||||
"guessing-game": () => import("./interactives/GuessingGame"),
|
||||
"knights-puzzle": () => import("./interactives/KnightsPuzzle"),
|
||||
"plate-swap-puzzle": () => import("./interactives/PlateSwapPuzzle"),
|
||||
"chessboard-repaint-puzzle": () => import("./interactives/ChessboardRepaintPuzzle"),
|
||||
"n-queens-puzzle": () => import("./interactives/NQueensPuzzle"),
|
||||
"sikinia-parliament-puzzle": () => import("./interactives/SikiniaParliamentPuzzle"),
|
||||
"bulgarian-solitaire": () => import("./interactives/BulgarianSolitaire"),
|
||||
"pebble-placement-game": () => import("./interactives/PebblePlacementGame"),
|
||||
"stacking-blocks": () => import("./interactives/StackingBlocks"),
|
||||
"domino-retiling-puzzle": () => import("./interactives/DominoRetilingPuzzle"),
|
||||
"asc-desc-grid-puzzle": () => import("./interactives/AscDescGridPuzzle"),
|
||||
"ladybug-clock-puzzle": () => import("./interactives/LadybugClockPuzzle"),
|
||||
"erdos-discrepancy-puzzle": () => import("./interactives/ErdosDiscrepancyPuzzle"),
|
||||
"parity-bits-game": () => import("./interactives/ParityBitsGame"),
|
||||
"rules-of-inference": () => import("./interactives/RulesOfInferencePlayground"),
|
||||
"neighbor-sum-avoidance": () => import("./interactives/NeighborSumAvoidance"),
|
||||
"burnsides-lemma": () => import("./interactives/BurnsidesLemma"),
|
||||
"cube-coloring": () => import("./interactives/CubeColoring"),
|
||||
"presents-puzzle": () => import("./interactives/PresentsPuzzle"),
|
||||
"ferrers-rogers-ramanujan": () => import("./interactives/FerrersRogersRamanujan"),
|
||||
"grid-tiling-puzzle": () => import("./interactives/GridTilingPuzzle"),
|
||||
"sunny-lines-puzzle": () => import("./interactives/SunnyLinesPuzzle"),
|
||||
"rent-division-puzzle": () => import("./interactives/RentDivisionPuzzle"),
|
||||
"eternal-domination-game": () => import("./interactives/EternalDominationGame"),
|
||||
"knights-and-knaves": () => import("./interactives/KnightsAndKnavesI"),
|
||||
"three-bank-accounts": () => import("./interactives/ThreeBankAccounts"),
|
||||
};
|
||||
|
||||
const lazyCache = new Map<string, React.LazyExoticComponent<ComponentType<any>>>();
|
||||
|
||||
function getLazyComponent(slug: string) {
|
||||
if (!lazyCache.has(slug)) {
|
||||
const loader = componentMap[slug];
|
||||
if (!loader) return null;
|
||||
lazyCache.set(slug, lazy(loader));
|
||||
}
|
||||
return lazyCache.get(slug)!;
|
||||
}
|
||||
|
||||
interface InteractiveRendererProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const InteractiveRenderer: React.FC<InteractiveRendererProps> = ({ slug }) => {
|
||||
const LazyComponent = getLazyComponent(slug);
|
||||
|
||||
if (!LazyComponent) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Interactive not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyComponent />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractiveRenderer;
|
||||
166
src/components/SocialShare.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Copy, QrCode, Code2 } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
interface SocialShareProps {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
embedUrl?: string;
|
||||
}
|
||||
|
||||
const SocialShare: React.FC<SocialShareProps> = ({ title, description, url, embedUrl }) => {
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
|
||||
const [isQrOpen, setIsQrOpen] = useState(false);
|
||||
const [isEmbedOpen, setIsEmbedOpen] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isQrOpen) {
|
||||
QRCode.toDataURL(url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#FFFFFF' },
|
||||
})
|
||||
.then(setQrCodeDataUrl)
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [url, isQrOpen]);
|
||||
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`;
|
||||
const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`;
|
||||
|
||||
const embedCode = embedUrl
|
||||
? `<iframe src="${embedUrl}" style="width:100%;height:80vh;border:none;" allowfullscreen></iframe>`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="border-t border-border pt-6 mt-8">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{/* Twitter/X */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(twitterUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Share</span>
|
||||
</Button>
|
||||
|
||||
{/* LinkedIn */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(linkedinUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Share</span>
|
||||
</Button>
|
||||
|
||||
{/* Copy Link */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(url, 'link')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{copied === 'link' ? 'Copied!' : 'Copy Link'}</span>
|
||||
</Button>
|
||||
|
||||
{/* QR Code */}
|
||||
<Dialog open={isQrOpen} onOpenChange={setIsQrOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<QrCode className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">QR Code</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<QrCode className="w-5 h-5" />
|
||||
QR Code
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{qrCodeDataUrl && (
|
||||
<Card className="p-4 bg-white">
|
||||
<CardContent className="p-0">
|
||||
<img src={qrCodeDataUrl} alt="QR Code" className="w-48 h-48" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">Scan to open this interactive</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Embed Code */}
|
||||
{embedUrl && (
|
||||
<Dialog open={isEmbedOpen} onOpenChange={setIsEmbedOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Code2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Embed</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Code2 className="w-5 h-5" />
|
||||
Embed Code
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto max-w-full">
|
||||
<code className="break-all whitespace-pre-wrap">{embedCode}</code>
|
||||
</pre>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(embedCode, 'embed')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{copied === 'embed' ? 'Copied!' : 'Copy Embed Code'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialShare;
|
||||
95
src/components/ThemeContent.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import React, { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import AdminIdeaCards from "@/components/AdminIdeaCards";
|
||||
import AdminDraftCards from "@/components/AdminDraftCards";
|
||||
import type { Interactive, Subcategory } from "@/lib/interactives";
|
||||
|
||||
interface ThemeContentProps {
|
||||
themeSlug: string;
|
||||
themeTitle: string;
|
||||
description: string;
|
||||
subcategories: Subcategory[];
|
||||
interactives: Interactive[];
|
||||
}
|
||||
|
||||
const ThemeContent: React.FC<ThemeContentProps> = ({
|
||||
themeSlug,
|
||||
themeTitle,
|
||||
description,
|
||||
subcategories,
|
||||
interactives,
|
||||
}) => {
|
||||
const [viewAll, setViewAll] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4 mb-10">
|
||||
<p className="text-lg text-muted-foreground max-w-3xl">
|
||||
{description}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setViewAll((v) => !v)}
|
||||
className="shrink-0 inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-foreground transition-colors cursor-pointer mt-1"
|
||||
>
|
||||
{viewAll ? "View Categories" : "View All"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewAll ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{interactives.map((item) => (
|
||||
<a key={item.slug} href={`/i/${item.slug}?from=${themeSlug}`} className="group">
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm transition-shadow hover:shadow-md h-full">
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||||
{item.description}
|
||||
</p>
|
||||
{item.subcategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{subcategories.find((s) => s.slug === item.subcategory)
|
||||
?.title}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
<AdminDraftCards theme={themeTitle} />
|
||||
<AdminIdeaCards theme={themeTitle} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{subcategories.map((sub) => {
|
||||
const count = interactives.filter(
|
||||
(i) => i.subcategory === sub.slug
|
||||
).length;
|
||||
return (
|
||||
<a
|
||||
key={sub.slug}
|
||||
href={`/themes/${themeSlug}/${sub.slug}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm transition-shadow hover:shadow-md h-full">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
|
||||
{sub.title}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sub.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeContent;
|
||||
39
src/components/hatch-radial-svg-pattern.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PATTERN_ID = 'hatch-radial-dots';
|
||||
|
||||
/**
|
||||
* Subtle dot grid with a radial fade (stronger toward center, soft at edges).
|
||||
*/
|
||||
export function HatchRadialSvgPattern({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-0 overflow-hidden',
|
||||
'[mask-image:radial-gradient(ellipse_72%_68%_at_50%_42%,black_18%,black_45%,transparent_100%)]',
|
||||
'[-webkit-mask-image:radial-gradient(ellipse_72%_68%_at_50%_42%,black_18%,black_45%,transparent_100%)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="text-border/50 dark:text-border/35 h-full w-full"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={PATTERN_ID}
|
||||
width={22}
|
||||
height={22}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx={2} cy={2} r={1} fill="currentColor" opacity={0.45} />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${PATTERN_ID})`} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/hatch-svg-grid-pattern.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Static SVG grid (Scalar-style) — no canvas / rAF, better for perf than animated canvas patterns.
|
||||
*/
|
||||
export function HatchSvgGridPattern({
|
||||
className,
|
||||
cellSize = 12,
|
||||
gap = 2,
|
||||
}: {
|
||||
className?: string;
|
||||
cellSize?: number;
|
||||
gap?: number;
|
||||
}) {
|
||||
const step = cellSize + gap;
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn('pointer-events-none absolute inset-0 z-0', className)}
|
||||
>
|
||||
<svg
|
||||
className="text-border/55 dark:text-border/35 h-full w-full"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={step}
|
||||
height={step}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
fill="currentColor"
|
||||
opacity={0.35}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${id})`} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
428
src/components/interactives/AscDescGridPuzzle.tsx
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { RotateCcw, Check, Shuffle, Lightbulb } from "lucide-react";
|
||||
|
||||
type Label = 'A' | 'D';
|
||||
|
||||
interface AscDescGridPuzzleProps {
|
||||
}
|
||||
|
||||
const AscDescGridPuzzle = ({}: AscDescGridPuzzleProps) => {
|
||||
const [gridSize, setGridSize] = useState(3);
|
||||
const [rowLabels, setRowLabels] = useState<Label[]>(['A', 'D', 'A']);
|
||||
const [colLabels, setColLabels] = useState<Label[]>(['A', 'D', 'A']);
|
||||
const [grid, setGrid] = useState<(number | null)[][]>(
|
||||
Array(3).fill(null).map(() => Array(3).fill(null))
|
||||
);
|
||||
const [selectedNumber, setSelectedNumber] = useState<number | null>(null);
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
errors: { row?: number; col?: number; type: string }[];
|
||||
} | null>(null);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
|
||||
// Get all numbers that have been placed
|
||||
const getPlacedNumbers = useCallback((): Set<number> => {
|
||||
const placed = new Set<number>();
|
||||
for (let r = 0; r < gridSize; r++) {
|
||||
for (let c = 0; c < gridSize; c++) {
|
||||
if (grid[r]?.[c] !== null && grid[r]?.[c] !== undefined) {
|
||||
placed.add(grid[r][c] as number);
|
||||
}
|
||||
}
|
||||
}
|
||||
return placed;
|
||||
}, [grid, gridSize]);
|
||||
|
||||
// Reset the puzzle
|
||||
const resetPuzzle = useCallback(() => {
|
||||
setGrid(Array(gridSize).fill(null).map(() => Array(gridSize).fill(null)));
|
||||
setSelectedNumber(null);
|
||||
setValidationResult(null);
|
||||
setShowHint(false);
|
||||
}, [gridSize]);
|
||||
|
||||
// Change grid size
|
||||
const handleGridSizeChange = (value: number[]) => {
|
||||
const newSize = value[0];
|
||||
setGridSize(newSize);
|
||||
setRowLabels(Array(newSize).fill('A').map((_, i) => (i % 2 === 0 ? 'A' : 'D') as Label));
|
||||
setColLabels(Array(newSize).fill('A').map((_, i) => (i % 2 === 0 ? 'A' : 'D') as Label));
|
||||
setGrid(Array(newSize).fill(null).map(() => Array(newSize).fill(null)));
|
||||
setSelectedNumber(null);
|
||||
setValidationResult(null);
|
||||
setShowHint(false);
|
||||
};
|
||||
|
||||
// Toggle row label
|
||||
const toggleRowLabel = (index: number) => {
|
||||
setRowLabels(prev => {
|
||||
const newLabels = [...prev];
|
||||
newLabels[index] = newLabels[index] === 'A' ? 'D' : 'A';
|
||||
return newLabels;
|
||||
});
|
||||
setValidationResult(null);
|
||||
};
|
||||
|
||||
// Toggle column label
|
||||
const toggleColLabel = (index: number) => {
|
||||
setColLabels(prev => {
|
||||
const newLabels = [...prev];
|
||||
newLabels[index] = newLabels[index] === 'A' ? 'D' : 'A';
|
||||
return newLabels;
|
||||
});
|
||||
setValidationResult(null);
|
||||
};
|
||||
|
||||
// Randomize labels
|
||||
const randomizeLabels = () => {
|
||||
setRowLabels(Array(gridSize).fill(null).map(() => (Math.random() > 0.5 ? 'A' : 'D') as Label));
|
||||
setColLabels(Array(gridSize).fill(null).map(() => (Math.random() > 0.5 ? 'A' : 'D') as Label));
|
||||
resetPuzzle();
|
||||
};
|
||||
|
||||
// Handle cell click
|
||||
const handleCellClick = (row: number, col: number) => {
|
||||
if (selectedNumber === null) {
|
||||
// If clicking on a cell with a number, remove it
|
||||
if (grid[row][col] !== null) {
|
||||
setGrid(prev => {
|
||||
const newGrid = prev.map(r => [...r]);
|
||||
newGrid[row][col] = null;
|
||||
return newGrid;
|
||||
});
|
||||
setValidationResult(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Place the selected number
|
||||
setGrid(prev => {
|
||||
const newGrid = prev.map(r => [...r]);
|
||||
// Remove the number from any previous position
|
||||
for (let r = 0; r < gridSize; r++) {
|
||||
for (let c = 0; c < gridSize; c++) {
|
||||
if (newGrid[r][c] === selectedNumber) {
|
||||
newGrid[r][c] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
newGrid[row][col] = selectedNumber;
|
||||
return newGrid;
|
||||
});
|
||||
setSelectedNumber(null);
|
||||
setValidationResult(null);
|
||||
};
|
||||
|
||||
// Check if puzzle is complete and valid
|
||||
const validatePuzzle = () => {
|
||||
const errors: { row?: number; col?: number; type: string }[] = [];
|
||||
const totalNumbers = gridSize * gridSize;
|
||||
const placed = getPlacedNumbers();
|
||||
|
||||
// Check if all numbers are placed
|
||||
if (placed.size !== totalNumbers) {
|
||||
setValidationResult({
|
||||
isValid: false,
|
||||
message: `Place all numbers from 1 to ${totalNumbers} first.`,
|
||||
errors: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check rows
|
||||
for (let r = 0; r < gridSize; r++) {
|
||||
const row = grid[r];
|
||||
const label = rowLabels[r];
|
||||
|
||||
for (let c = 0; c < gridSize - 1; c++) {
|
||||
const current = row[c] as number;
|
||||
const next = row[c + 1] as number;
|
||||
|
||||
if (label === 'A' && current >= next) {
|
||||
errors.push({ row: r, type: 'row' });
|
||||
break;
|
||||
}
|
||||
if (label === 'D' && current <= next) {
|
||||
errors.push({ row: r, type: 'row' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check columns
|
||||
for (let c = 0; c < gridSize; c++) {
|
||||
const label = colLabels[c];
|
||||
|
||||
for (let r = 0; r < gridSize - 1; r++) {
|
||||
const current = grid[r][c] as number;
|
||||
const next = grid[r + 1][c] as number;
|
||||
|
||||
if (label === 'A' && current >= next) {
|
||||
errors.push({ col: c, type: 'col' });
|
||||
break;
|
||||
}
|
||||
if (label === 'D' && current <= next) {
|
||||
errors.push({ col: c, type: 'col' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
setValidationResult({
|
||||
isValid: true,
|
||||
message: "🎉 Congratulations! The puzzle is solved correctly!",
|
||||
errors: []
|
||||
});
|
||||
} else {
|
||||
setValidationResult({
|
||||
isValid: false,
|
||||
message: `Found ${errors.length} constraint violation(s). Check highlighted rows/columns.`,
|
||||
errors
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Check for circular constraint (unsolvable pattern)
|
||||
const checkSolvability = (): { solvable: boolean; reason?: string } => {
|
||||
// Check for the circular constraint pattern described in the paper
|
||||
// If rows i < j have labels D,A (or A,D) and columns p < q have labels A,D (or D,A),
|
||||
// then we have a circular constraint making it unsolvable
|
||||
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
for (let j = i + 1; j < gridSize; j++) {
|
||||
// Check if rows i and j have opposite labels
|
||||
if ((rowLabels[i] === 'D' && rowLabels[j] === 'A') ||
|
||||
(rowLabels[i] === 'A' && rowLabels[j] === 'D')) {
|
||||
|
||||
for (let p = 0; p < gridSize; p++) {
|
||||
for (let q = p + 1; q < gridSize; q++) {
|
||||
// Check if columns p and q have opposite labels in the "wrong" way
|
||||
const rowPattern = rowLabels[i] === 'D' ? 'DA' : 'AD';
|
||||
const colPattern = colLabels[p] === 'A' ? 'AD' : 'DA';
|
||||
|
||||
// The circular constraint occurs when:
|
||||
// rows i,j are D,A and columns p,q are A,D (or vice versa pattern)
|
||||
if ((rowLabels[i] === 'D' && rowLabels[j] === 'A' &&
|
||||
colLabels[p] === 'A' && colLabels[q] === 'D') ||
|
||||
(rowLabels[i] === 'A' && rowLabels[j] === 'D' &&
|
||||
colLabels[p] === 'D' && colLabels[q] === 'A')) {
|
||||
return {
|
||||
solvable: false,
|
||||
reason: `Circular constraint detected: rows ${i+1},${j+1} (${rowLabels[i]},${rowLabels[j]}) and columns ${p+1},${q+1} (${colLabels[p]},${colLabels[q]}) create an impossible configuration.`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { solvable: true };
|
||||
};
|
||||
|
||||
const solvabilityCheck = checkSolvability();
|
||||
const placedNumbers = getPlacedNumbers();
|
||||
const totalNumbers = gridSize * gridSize;
|
||||
|
||||
const hasRowError = (row: number) =>
|
||||
validationResult?.errors.some(e => e.row === row && e.type === 'row');
|
||||
|
||||
const hasColError = (col: number) =>
|
||||
validationResult?.errors.some(e => e.col === col && e.type === 'col');
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto p-4 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Ascending-Descending Grid Puzzle
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Fill the grid with numbers 1 to n² such that rows labeled <Badge variant="secondary">A</Badge> are
|
||||
in <strong>ascending</strong> order (left→right) and rows labeled <Badge variant="outline">D</Badge> are
|
||||
in <strong>descending</strong> order. The same applies to columns (top→bottom).
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Click on row/column labels to toggle between A and D. Click a number, then click a cell to place it.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
Grid Size: {gridSize} × {gridSize}
|
||||
</label>
|
||||
<Slider
|
||||
value={[gridSize]}
|
||||
onValueChange={handleGridSizeChange}
|
||||
min={2}
|
||||
max={5}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={randomizeLabels} variant="outline" size="sm">
|
||||
<Shuffle className="w-4 h-4 mr-1" /> Randomize
|
||||
</Button>
|
||||
<Button onClick={resetPuzzle} variant="outline" size="sm">
|
||||
<RotateCcw className="w-4 h-4 mr-1" /> Reset
|
||||
</Button>
|
||||
<Button onClick={() => setShowHint(!showHint)} variant="outline" size="sm">
|
||||
<Lightbulb className="w-4 h-4 mr-1" /> {showHint ? 'Hide' : 'Show'} Hint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHint && (
|
||||
<div className="p-3 bg-muted rounded-lg text-sm">
|
||||
<strong>Hint:</strong> Some configurations are unsolvable! A circular constraint occurs when
|
||||
rows i {"<"} j have labels D,A (in that order) while columns p {"<"} q have labels A,D (in that order),
|
||||
or vice versa. Try to avoid these patterns.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!solvabilityCheck.solvable && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
|
||||
⚠️ {solvabilityCheck.reason}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Number Palette */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="mb-2 text-sm font-medium">
|
||||
Select a number to place ({placedNumbers.size}/{totalNumbers} placed):
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: totalNumbers }, (_, i) => i + 1).map(num => {
|
||||
const isPlaced = placedNumbers.has(num);
|
||||
const isSelected = selectedNumber === num;
|
||||
return (
|
||||
<Button
|
||||
key={num}
|
||||
variant={isSelected ? "default" : isPlaced ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
className={`w-10 h-10 ${isPlaced && !isSelected ? 'opacity-40' : ''}`}
|
||||
onClick={() => setSelectedNumber(isSelected ? null : num)}
|
||||
>
|
||||
{num}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex justify-center">
|
||||
<div className="inline-block">
|
||||
{/* Column labels */}
|
||||
<div className="flex">
|
||||
<div className="w-10 h-10" /> {/* Empty corner */}
|
||||
{colLabels.map((label, c) => (
|
||||
<button
|
||||
key={`col-${c}`}
|
||||
onClick={() => toggleColLabel(c)}
|
||||
className={`w-12 h-10 flex items-center justify-center font-bold text-lg cursor-pointer transition-colors rounded-t
|
||||
${hasColError(c) ? 'bg-destructive/20 text-destructive' : 'hover:bg-muted'}
|
||||
${label === 'A' ? 'text-green-600 dark:text-green-400' : 'text-blue-600 dark:text-blue-400'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid rows */}
|
||||
{Array.from({ length: gridSize }, (_, r) => (
|
||||
<div key={`row-${r}`} className="flex">
|
||||
{/* Row label */}
|
||||
<button
|
||||
onClick={() => toggleRowLabel(r)}
|
||||
className={`w-10 h-12 flex items-center justify-center font-bold text-lg cursor-pointer transition-colors rounded-l
|
||||
${hasRowError(r) ? 'bg-destructive/20 text-destructive' : 'hover:bg-muted'}
|
||||
${rowLabels[r] === 'A' ? 'text-green-600 dark:text-green-400' : 'text-blue-600 dark:text-blue-400'}`}
|
||||
>
|
||||
{rowLabels[r]}
|
||||
</button>
|
||||
|
||||
{/* Grid cells */}
|
||||
{Array.from({ length: gridSize }, (_, c) => {
|
||||
const value = grid[r]?.[c];
|
||||
const hasError = hasRowError(r) || hasColError(c);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`cell-${r}-${c}`}
|
||||
onClick={() => handleCellClick(r, c)}
|
||||
className={`w-12 h-12 border border-border flex items-center justify-center text-lg font-semibold
|
||||
transition-all cursor-pointer
|
||||
${value !== null ? 'bg-primary/10' : 'bg-background hover:bg-muted'}
|
||||
${hasError && validationResult ? 'bg-destructive/10' : ''}
|
||||
${selectedNumber !== null ? 'hover:bg-primary/20' : ''}
|
||||
`}
|
||||
>
|
||||
{value !== null ? value : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validate Button */}
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={validatePuzzle} size="lg" disabled={placedNumbers.size !== totalNumbers}>
|
||||
<Check className="w-5 h-5 mr-2" /> Check Solution
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{validationResult && (
|
||||
<Card className={validationResult.isValid ? 'border-green-500' : 'border-destructive'}>
|
||||
<CardContent className="pt-6">
|
||||
<p className={`text-center font-medium ${validationResult.isValid ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}>
|
||||
{validationResult.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">How to Play</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>The grid has row labels on the left and column labels on top</li>
|
||||
<li><Badge variant="secondary" className="mx-1">A</Badge> means ascending order (numbers increase left→right or top→bottom)</li>
|
||||
<li><Badge variant="outline" className="mx-1">D</Badge> means descending order (numbers decrease left→right or top→bottom)</li>
|
||||
<li>Click on labels to toggle between A and D</li>
|
||||
<li>Click a number from the palette, then click a cell to place it</li>
|
||||
<li>Click a placed number in the grid to remove it</li>
|
||||
<li>Fill all cells with numbers 1 to n² satisfying all constraints</li>
|
||||
<li>Some label configurations are unsolvable — the puzzle will warn you!</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AscDescGridPuzzle;
|
||||
392
src/components/interactives/AssistedNimGame.tsx
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
|
||||
const SKY_BLUE = '#e0f2fe';
|
||||
const MAROON = '#800000';
|
||||
|
||||
function getRandomInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
const MIN_HEAPS = 2;
|
||||
const MAX_HEAPS = 10;
|
||||
const MIN_STONES = 1;
|
||||
const MAX_STONES = 31; // Extended to 31 to include power-of-16 box
|
||||
|
||||
type Mode = 'random' | 'user';
|
||||
|
||||
type Heap = number;
|
||||
|
||||
type Player = 0 | 1;
|
||||
|
||||
const heapColors = [
|
||||
'#fbbf24', '#60a5fa', '#34d399', '#f472b6', '#f87171', '#a78bfa', '#facc15', '#38bdf8', '#4ade80', '#f472b6',
|
||||
'#f87171', '#a78bfa', '#fbbf24', '#60a5fa', '#34d399', '#f472b6', '#f87171', '#a78bfa', '#facc15', '#38bdf8',
|
||||
];
|
||||
|
||||
// Function to break down a number into its binary representation (powers of 2)
|
||||
const getBinaryRepresentation = (num: number): number[] => {
|
||||
const powers: number[] = [];
|
||||
let remaining = num;
|
||||
let power = 1;
|
||||
|
||||
while (remaining > 0) {
|
||||
if (remaining & 1) {
|
||||
powers.push(power);
|
||||
}
|
||||
remaining >>= 1;
|
||||
power <<= 1;
|
||||
}
|
||||
|
||||
return powers;
|
||||
};
|
||||
|
||||
// Function to calculate which powers of 2 appear an odd number of times across all heaps
|
||||
const getOddPowers = (heaps: number[]): Set<number> => {
|
||||
const powerCounts = new Map<number, number>();
|
||||
|
||||
heaps.forEach(heap => {
|
||||
const powers = getBinaryRepresentation(heap);
|
||||
powers.forEach(power => {
|
||||
powerCounts.set(power, (powerCounts.get(power) || 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
const oddPowers = new Set<number>();
|
||||
powerCounts.forEach((count, power) => {
|
||||
if (count % 2 === 1) {
|
||||
oddPowers.add(power);
|
||||
}
|
||||
});
|
||||
|
||||
return oddPowers;
|
||||
};
|
||||
|
||||
interface AssistedNimGameProps {
|
||||
}
|
||||
|
||||
const AssistedNimGame: React.FC<AssistedNimGameProps> = () => {
|
||||
const [mode, setMode] = useState<Mode | null>(null);
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
const [userHeapCount, setUserHeapCount] = useState(3);
|
||||
const [userHeapSizes, setUserHeapSizes] = useState<number[]>([3, 3, 3]);
|
||||
const [heaps, setHeaps] = useState<Heap[]>([]);
|
||||
const [currentPlayer, setCurrentPlayer] = useState<Player>(0);
|
||||
const [winner, setWinner] = useState<Player | null>(null);
|
||||
const [heapsGroupWidth, setHeapsGroupWidth] = useState<string>('0px');
|
||||
const initialHeapsRef = useRef<number[]>([]);
|
||||
|
||||
// Calculate the maximum number of powers of 2 needed for any heap
|
||||
const getMaxPowers = (heaps: number[]): number => {
|
||||
if (heaps.length === 0) return 0;
|
||||
// Since we cap at 31, we need 5 powers: 1, 2, 4, 8, 16
|
||||
return 5;
|
||||
};
|
||||
|
||||
// Calculate width based on maximum powers of 2
|
||||
React.useEffect(() => {
|
||||
if (mode !== null && heaps.length > 0) {
|
||||
const maxPowers = getMaxPowers(heaps);
|
||||
const gapWidth = 0.5; // Gap between power groups
|
||||
const labelWidth = 2; // Width for heap size label
|
||||
|
||||
// Calculate total width accounting for different box sizes
|
||||
let totalBoxWidth = 0;
|
||||
for (let i = 0; i < maxPowers; i++) {
|
||||
const power = 1 << i; // 1, 2, 4, 8, 16
|
||||
if (power === 16) {
|
||||
totalBoxWidth += 12; // Power-of-16 box is 12rem wide
|
||||
} else if (power === 8) {
|
||||
totalBoxWidth += 8; // Power-of-8 box is 8rem wide
|
||||
} else {
|
||||
totalBoxWidth += 4; // Other boxes are 4rem wide
|
||||
}
|
||||
}
|
||||
|
||||
const totalWidthRem = labelWidth + totalBoxWidth + (maxPowers - 1) * gapWidth;
|
||||
setHeapsGroupWidth(`${totalWidthRem}rem`);
|
||||
}
|
||||
}, [mode, heaps.length]);
|
||||
|
||||
// Store initial heaps for reset
|
||||
React.useEffect(() => {
|
||||
if (mode !== null && heaps.length > 0) {
|
||||
initialHeapsRef.current = [...heaps];
|
||||
}
|
||||
}, [mode, heaps.length]);
|
||||
|
||||
// Start a new game
|
||||
const startGame = (selectedMode: Mode) => {
|
||||
setMode(selectedMode);
|
||||
setWinner(null);
|
||||
setCurrentPlayer(0);
|
||||
if (selectedMode === 'random') {
|
||||
const k = getRandomInt(MIN_HEAPS, MAX_HEAPS);
|
||||
const randomHeaps = Array.from({ length: k }, () => getRandomInt(MIN_STONES, MAX_STONES));
|
||||
setHeaps(randomHeaps);
|
||||
} else {
|
||||
setShowSetup(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle user-defined setup
|
||||
const handleUserSetupConfirm = () => {
|
||||
setHeaps([...userHeapSizes]);
|
||||
setShowSetup(false);
|
||||
setWinner(null);
|
||||
setCurrentPlayer(0);
|
||||
};
|
||||
|
||||
// Handle stone click: remove all stones from clicked index to the end in that heap
|
||||
const handleStoneClick = (heapIdx: number, stoneIdx: number) => {
|
||||
if (winner !== null) return;
|
||||
if (heaps[heapIdx] === 0) return;
|
||||
const newHeaps = [...heaps];
|
||||
newHeaps[heapIdx] = stoneIdx;
|
||||
setHeaps(newHeaps);
|
||||
|
||||
// Check for win
|
||||
if (newHeaps.every(heap => heap === 0)) {
|
||||
setWinner(currentPlayer);
|
||||
} else {
|
||||
setCurrentPlayer(currentPlayer === 0 ? 1 : 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
setWinner(null);
|
||||
setCurrentPlayer(0);
|
||||
setHeaps([...initialHeapsRef.current]);
|
||||
};
|
||||
|
||||
const handleResetGame = () => {
|
||||
setWinner(null);
|
||||
setCurrentPlayer(0);
|
||||
setHeaps([...initialHeapsRef.current]);
|
||||
};
|
||||
|
||||
const handleNewGame = () => {
|
||||
setMode(null);
|
||||
setHeaps([]);
|
||||
setWinner(null);
|
||||
setCurrentPlayer(0);
|
||||
};
|
||||
|
||||
const handleHeapCountChange = (val: number) => {
|
||||
setUserHeapCount(val);
|
||||
setUserHeapSizes(Array.from({ length: val }, () => 3));
|
||||
};
|
||||
|
||||
const handleHeapSizeChange = (idx: number, val: number) => {
|
||||
const newSizes = [...userHeapSizes];
|
||||
newSizes[idx] = val;
|
||||
setUserHeapSizes(newSizes);
|
||||
};
|
||||
|
||||
// Calculate which powers of 2 appear an odd number of times
|
||||
const oddPowers = getOddPowers(heaps);
|
||||
|
||||
// Render a heap broken down into powers of 2
|
||||
const renderHeap = (heap: number, idx: number) => {
|
||||
const powers = getBinaryRepresentation(heap);
|
||||
const maxPowers = getMaxPowers(heaps);
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex flex-row items-center gap-2">
|
||||
{/* Heap size label */}
|
||||
<span className="font-mono text-sm text-muted-foreground min-w-[2ch] text-right">{heap}</span>
|
||||
|
||||
{/* Powers of 2 groups - horizontal layout with fixed widths */}
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length: maxPowers }, (_, powerIdx) => {
|
||||
const power = 1 << powerIdx; // 2^powerIdx (1, 2, 4, 8, 16)
|
||||
const hasPower = powers.includes(power);
|
||||
const isOdd = oddPowers.has(power);
|
||||
const isPower8 = power === 8; // Check if this is the power-of-8 box
|
||||
const isPower16 = power === 16; // Check if this is the power-of-16 box
|
||||
|
||||
return (
|
||||
<div
|
||||
key={powerIdx}
|
||||
className={`flex flex-col gap-1 p-2 rounded border-2 ${
|
||||
hasPower
|
||||
? (isOdd ? 'border-red-500 bg-red-50' : 'border-green-500 bg-green-50')
|
||||
: 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
style={{
|
||||
minWidth: isPower16 ? '12rem' : (isPower8 ? '8rem' : '4rem'),
|
||||
width: isPower16 ? '12rem' : (isPower8 ? '8rem' : '4rem')
|
||||
}}
|
||||
>
|
||||
{/* Stones for this power - single row layout */}
|
||||
{hasPower ? (
|
||||
<div className="flex flex-row gap-0.5 justify-center">
|
||||
{Array.from({ length: power }, (_, stoneIdx) => (
|
||||
<span
|
||||
key={stoneIdx}
|
||||
className="inline-block w-2 h-2 rounded-full cursor-pointer border border-gray-400 hover:border-black transition-all"
|
||||
style={{
|
||||
background: heapColors[idx % heapColors.length],
|
||||
opacity: winner !== null ? 0.5 : 1
|
||||
}}
|
||||
onClick={() => {
|
||||
// Calculate the actual stone index in the heap
|
||||
const actualIndex = powers
|
||||
.filter(p => p < power)
|
||||
.reduce((sum, p) => sum + p, 0) + stoneIdx;
|
||||
handleStoneClick(idx, actualIndex);
|
||||
}}
|
||||
title={`Power of 2: ${power}, Stone ${stoneIdx + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-2 h-2" /> // Fixed empty space
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Dynamic backdrop color based on current player
|
||||
const backdropColor = winner === null ? (currentPlayer === 0 ? SKY_BLUE : MAROON) : '#f0f0f0';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold text-center">Assisted Game of Nim</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Visualize the XOR strategy! Heaps are broken down into powers of 2. Red borders show powers that appear an odd number of times - these are your winning moves!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Game Controls */}
|
||||
{mode === null && (
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<Button onClick={() => startGame('random')}>Random Instance</Button>
|
||||
<Button onClick={() => startGame('user')}>User-Defined Instance</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode !== null && (
|
||||
<>
|
||||
{/* Current Turn Indicator and Reset Button */}
|
||||
<div className="text-center flex flex-col items-center gap-2">
|
||||
{winner === null ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Current Turn:</p>
|
||||
<span className={`inline-block text-lg px-4 py-2 rounded font-semibold ${currentPlayer === 0 ? 'bg-sky-200 text-sky-800' : 'bg-red-200 text-red-800'}`}>
|
||||
Player {currentPlayer + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button variant="outline" size="sm" onClick={handleResetGame}>Reset Game</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleNewGame}>New Game</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<span className="inline-block text-lg px-4 py-2 rounded font-semibold bg-green-200 text-green-800">Winner: Player {winner + 1}!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Heaps Display */}
|
||||
<div className="w-full flex justify-center">
|
||||
<div className="flex flex-col items-start gap-4 my-4 mx-auto" style={{ width: heapsGroupWidth }}>
|
||||
{heaps.map((heap, idx) => renderHeap(heap, idx))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategy Explanation */}
|
||||
<div className="text-center space-y-2 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-sm font-medium text-blue-800">XOR Strategy Visualization</p>
|
||||
<p className="text-xs text-blue-600">
|
||||
Red borders = Powers of 2 that appear an odd number of times (your winning moves)<br/>
|
||||
Green borders = Powers of 2 that appear an even number of times (balanced)
|
||||
</p>
|
||||
<div className="mt-3 pt-2 border-t border-blue-200">
|
||||
<p className="text-xs text-blue-500">
|
||||
Strategy based on a well-known analysis, in particular the intuition explored by {' '}
|
||||
<a
|
||||
href="https://jdh.hamkins.org/win-at-nim-the-secret-mathematical-strategy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-blue-700"
|
||||
>
|
||||
Joel David Hamkins
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play Again Button */}
|
||||
{winner !== null && (
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={handlePlayAgain}>Play Again</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-center space-y-2 p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click any stone to remove it and all stones to its right. The visual breakdown shows powers of 2 -
|
||||
red borders indicate winning moves in the XOR strategy!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User-defined setup modal */}
|
||||
<Dialog open={showSetup} onOpenChange={setShowSetup}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Define Your Game</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Number of heaps: {userHeapCount}</label>
|
||||
<Slider
|
||||
value={[userHeapCount]}
|
||||
onValueChange={(value) => handleHeapCountChange(value[0])}
|
||||
min={MIN_HEAPS}
|
||||
max={MAX_HEAPS}
|
||||
step={1}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Heap sizes:</label>
|
||||
{userHeapSizes.map((size, idx) => (
|
||||
<div key={idx}>
|
||||
<label className="text-sm">Heap {idx + 1}: {size}</label>
|
||||
<Slider
|
||||
value={[size]}
|
||||
onValueChange={(value) => handleHeapSizeChange(idx, value[0])}
|
||||
min={MIN_STONES}
|
||||
max={MAX_STONES}
|
||||
step={1}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={handleUserSetupConfirm} className="w-full">Start Game</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistedNimGame;
|
||||
759
src/components/interactives/BagchalGame.tsx
Normal file
|
|
@ -0,0 +1,759 @@
|
|||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface BagchalGameProps {
|
||||
}
|
||||
|
||||
type Phase = 'setup' | 'tiger-placement' | 'goat-placement' | 'playing';
|
||||
type GameMode = '2-player' | 'vs-computer';
|
||||
type ComputerSide = 'tiger' | 'goats';
|
||||
type Turn = 'tiger' | 'goats';
|
||||
|
||||
interface Position {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
phase: Phase;
|
||||
tigerPosition: Position | null;
|
||||
goatPositions: Position[];
|
||||
goatsPlaced: number;
|
||||
goatsToPlace: number;
|
||||
turn: Turn;
|
||||
winner: 'tiger' | 'goats' | null;
|
||||
selectedGoat: number | null; // index of selected goat for moving
|
||||
}
|
||||
|
||||
const BagchalGame: React.FC<BagchalGameProps> = () => {
|
||||
// Grid parameters
|
||||
const [rows, setRows] = useState(2);
|
||||
const [cols, setCols] = useState(6);
|
||||
const [numGoats, setNumGoats] = useState(4);
|
||||
|
||||
// Game mode
|
||||
const [gameMode, setGameMode] = useState<GameMode>('2-player');
|
||||
const [computerSide, setComputerSide] = useState<ComputerSide>('tiger');
|
||||
|
||||
// Game state
|
||||
const [gameState, setGameState] = useState<GameState>({
|
||||
phase: 'setup',
|
||||
tigerPosition: null,
|
||||
goatPositions: [],
|
||||
goatsPlaced: 0,
|
||||
goatsToPlace: numGoats,
|
||||
turn: 'tiger',
|
||||
winner: null,
|
||||
selectedGoat: null,
|
||||
});
|
||||
|
||||
const maxGoats = rows * cols - 1;
|
||||
|
||||
// Reset game when parameters change
|
||||
const resetGame = useCallback(() => {
|
||||
setGameState({
|
||||
phase: 'setup',
|
||||
tigerPosition: null,
|
||||
goatPositions: [],
|
||||
goatsPlaced: 0,
|
||||
goatsToPlace: numGoats,
|
||||
turn: 'tiger',
|
||||
winner: null,
|
||||
selectedGoat: null,
|
||||
});
|
||||
}, [numGoats]);
|
||||
|
||||
// Start the game
|
||||
const startGame = () => {
|
||||
setGameState({
|
||||
phase: 'tiger-placement',
|
||||
tigerPosition: null,
|
||||
goatPositions: [],
|
||||
goatsPlaced: 0,
|
||||
goatsToPlace: numGoats,
|
||||
turn: 'tiger',
|
||||
winner: null,
|
||||
selectedGoat: null,
|
||||
});
|
||||
};
|
||||
|
||||
// Check if a position is occupied
|
||||
const isOccupied = (row: number, col: number): boolean => {
|
||||
if (gameState.tigerPosition?.row === row && gameState.tigerPosition?.col === col) {
|
||||
return true;
|
||||
}
|
||||
return gameState.goatPositions.some(g => g.row === row && g.col === col);
|
||||
};
|
||||
|
||||
// Get adjacent positions
|
||||
const getAdjacentPositions = (pos: Position): Position[] => {
|
||||
const adjacent: Position[] = [];
|
||||
const directions = [
|
||||
[-1, 0], [1, 0], [0, -1], [0, 1], // orthogonal
|
||||
];
|
||||
|
||||
for (const [dr, dc] of directions) {
|
||||
const newRow = pos.row + dr;
|
||||
const newCol = pos.col + dc;
|
||||
if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols) {
|
||||
adjacent.push({ row: newRow, col: newCol });
|
||||
}
|
||||
}
|
||||
return adjacent;
|
||||
};
|
||||
|
||||
// Get valid tiger moves (adjacent empty positions)
|
||||
const getValidTigerMoves = (tigerPos: Position): Position[] => {
|
||||
return getAdjacentPositions(tigerPos).filter(
|
||||
pos => !isOccupied(pos.row, pos.col)
|
||||
);
|
||||
};
|
||||
|
||||
// Check if tiger can capture a goat (jump over it to empty space)
|
||||
const getTigerCaptures = (tigerPos: Position): { jumpTo: Position; capturedGoat: Position }[] => {
|
||||
const captures: { jumpTo: Position; capturedGoat: Position }[] = [];
|
||||
const directions = [
|
||||
[-1, 0], [1, 0], [0, -1], [0, 1],
|
||||
];
|
||||
|
||||
for (const [dr, dc] of directions) {
|
||||
const goatRow = tigerPos.row + dr;
|
||||
const goatCol = tigerPos.col + dc;
|
||||
const landRow = tigerPos.row + 2 * dr;
|
||||
const landCol = tigerPos.col + 2 * dc;
|
||||
|
||||
// Check if there's a goat adjacent and empty space beyond
|
||||
if (goatRow >= 0 && goatRow < rows && goatCol >= 0 && goatCol < cols &&
|
||||
landRow >= 0 && landRow < rows && landCol >= 0 && landCol < cols) {
|
||||
const hasGoat = gameState.goatPositions.some(g => g.row === goatRow && g.col === goatCol);
|
||||
const landingEmpty = !isOccupied(landRow, landCol);
|
||||
|
||||
if (hasGoat && landingEmpty) {
|
||||
captures.push({
|
||||
jumpTo: { row: landRow, col: landCol },
|
||||
capturedGoat: { row: goatRow, col: goatCol }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return captures;
|
||||
};
|
||||
|
||||
// Check if tiger is trapped (no valid moves) with given goat positions
|
||||
const isTigerTrappedWithGoats = (tigerPos: Position, goats: Position[]): boolean => {
|
||||
// Check for valid moves (adjacent empty positions)
|
||||
const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]];
|
||||
|
||||
for (const [dr, dc] of directions) {
|
||||
const newRow = tigerPos.row + dr;
|
||||
const newCol = tigerPos.col + dc;
|
||||
|
||||
if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols) {
|
||||
const isTiger = tigerPos.row === newRow && tigerPos.col === newCol;
|
||||
const isGoat = goats.some(g => g.row === newRow && g.col === newCol);
|
||||
|
||||
if (!isTiger && !isGoat) {
|
||||
// There's an empty adjacent cell - tiger can move
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for captures (jump over goat to empty space)
|
||||
for (const [dr, dc] of directions) {
|
||||
const goatRow = tigerPos.row + dr;
|
||||
const goatCol = tigerPos.col + dc;
|
||||
const landRow = tigerPos.row + 2 * dr;
|
||||
const landCol = tigerPos.col + 2 * dc;
|
||||
|
||||
if (goatRow >= 0 && goatRow < rows && goatCol >= 0 && goatCol < cols &&
|
||||
landRow >= 0 && landRow < rows && landCol >= 0 && landCol < cols) {
|
||||
const hasGoat = goats.some(g => g.row === goatRow && g.col === goatCol);
|
||||
const landingIsTiger = tigerPos.row === landRow && tigerPos.col === landCol;
|
||||
const landingIsGoat = goats.some(g => g.row === landRow && g.col === landCol);
|
||||
|
||||
if (hasGoat && !landingIsTiger && !landingIsGoat) {
|
||||
// Tiger can capture - not trapped
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Legacy function for backward compatibility
|
||||
const isTigerTrapped = (tigerPos: Position): boolean => {
|
||||
return isTigerTrappedWithGoats(tigerPos, gameState.goatPositions);
|
||||
};
|
||||
|
||||
// Computer strategy for goats on 2xn board with 4 goats
|
||||
const getGoatComputerMove = (): { type: 'place' | 'move'; position?: Position; fromIndex?: number; toPosition?: Position } | null => {
|
||||
const tiger = gameState.tigerPosition;
|
||||
if (!tiger) return null;
|
||||
|
||||
if (gameState.phase === 'goat-placement') {
|
||||
// Strategy: Place goats to surround the tiger
|
||||
// For 2xn board, place goats to block tiger's movement
|
||||
const adjacentEmpty = getAdjacentPositions(tiger).filter(pos => !isOccupied(pos.row, pos.col));
|
||||
|
||||
// Prioritize positions that help trap the tiger
|
||||
// First, block escape routes
|
||||
if (adjacentEmpty.length > 0) {
|
||||
// Pick the position that leaves tiger with fewest options
|
||||
let bestPos = adjacentEmpty[0];
|
||||
let minOptions = Infinity;
|
||||
|
||||
for (const pos of adjacentEmpty) {
|
||||
// Simulate placing goat here
|
||||
const simulatedGoats = [...gameState.goatPositions, pos];
|
||||
const tigerOptions = getAdjacentPositions(tiger).filter(
|
||||
p => !simulatedGoats.some(g => g.row === p.row && g.col === p.col) &&
|
||||
!(tiger.row === p.row && tiger.col === p.col)
|
||||
);
|
||||
|
||||
if (tigerOptions.length < minOptions) {
|
||||
minOptions = tigerOptions.length;
|
||||
bestPos = pos;
|
||||
}
|
||||
}
|
||||
return { type: 'place', position: bestPos };
|
||||
}
|
||||
|
||||
// If can't place adjacent, find any empty spot
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (!isOccupied(r, c)) {
|
||||
return { type: 'place', position: { row: r, col: c } };
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (gameState.phase === 'playing') {
|
||||
// Move goats to trap tiger
|
||||
for (let i = 0; i < gameState.goatPositions.length; i++) {
|
||||
const goat = gameState.goatPositions[i];
|
||||
const goatMoves = getAdjacentPositions(goat).filter(pos => !isOccupied(pos.row, pos.col));
|
||||
|
||||
for (const move of goatMoves) {
|
||||
// Check if this move helps trap the tiger
|
||||
const simulatedGoats = gameState.goatPositions.map((g, idx) =>
|
||||
idx === i ? move : g
|
||||
);
|
||||
|
||||
// Avoid moving into capture position
|
||||
const wouldBeCaptured = getTigerCapturesWithGoats(tiger, simulatedGoats).length > 0;
|
||||
if (!wouldBeCaptured) {
|
||||
const adjacentToTiger = getAdjacentPositions(tiger);
|
||||
const isAdjacentToTiger = adjacentToTiger.some(p => p.row === move.row && p.col === move.col);
|
||||
|
||||
if (isAdjacentToTiger) {
|
||||
return { type: 'move', fromIndex: i, toPosition: move };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: move any goat that can move
|
||||
for (let i = 0; i < gameState.goatPositions.length; i++) {
|
||||
const goat = gameState.goatPositions[i];
|
||||
const goatMoves = getAdjacentPositions(goat).filter(pos => !isOccupied(pos.row, pos.col));
|
||||
if (goatMoves.length > 0) {
|
||||
return { type: 'move', fromIndex: i, toPosition: goatMoves[0] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper for computer strategy
|
||||
const getTigerCapturesWithGoats = (tigerPos: Position, goats: Position[]): Position[] => {
|
||||
const captures: Position[] = [];
|
||||
const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]];
|
||||
|
||||
for (const [dr, dc] of directions) {
|
||||
const goatRow = tigerPos.row + dr;
|
||||
const goatCol = tigerPos.col + dc;
|
||||
const landRow = tigerPos.row + 2 * dr;
|
||||
const landCol = tigerPos.col + 2 * dc;
|
||||
|
||||
if (goatRow >= 0 && goatRow < rows && goatCol >= 0 && goatCol < cols &&
|
||||
landRow >= 0 && landRow < rows && landCol >= 0 && landCol < cols) {
|
||||
const hasGoat = goats.some(g => g.row === goatRow && g.col === goatCol);
|
||||
const tigerThere = tigerPos.row === landRow && tigerPos.col === landCol;
|
||||
const goatThere = goats.some(g => g.row === landRow && g.col === landCol);
|
||||
|
||||
if (hasGoat && !tigerThere && !goatThere) {
|
||||
captures.push({ row: goatRow, col: goatCol });
|
||||
}
|
||||
}
|
||||
}
|
||||
return captures;
|
||||
};
|
||||
|
||||
// Computer move for tiger
|
||||
const getTigerComputerMove = (): Position | null => {
|
||||
const tiger = gameState.tigerPosition;
|
||||
if (!tiger) return null;
|
||||
|
||||
// Check for captures first
|
||||
const captures = getTigerCaptures(tiger);
|
||||
if (captures.length > 0) {
|
||||
return captures[0].jumpTo;
|
||||
}
|
||||
|
||||
// Otherwise move to any valid position
|
||||
const moves = getValidTigerMoves(tiger);
|
||||
if (moves.length > 0) {
|
||||
// Prefer moves that might set up captures
|
||||
return moves[Math.floor(Math.random() * moves.length)];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle cell click
|
||||
const handleCellClick = (row: number, col: number) => {
|
||||
if (gameState.winner) return;
|
||||
|
||||
const clickedPos = { row, col };
|
||||
const isComputerTurn = gameMode === 'vs-computer' &&
|
||||
((gameState.turn === 'tiger' && computerSide === 'tiger') ||
|
||||
(gameState.turn === 'goats' && computerSide === 'goats'));
|
||||
|
||||
if (isComputerTurn) return;
|
||||
|
||||
if (gameState.phase === 'tiger-placement') {
|
||||
// Place tiger
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
tigerPosition: clickedPos,
|
||||
phase: 'goat-placement',
|
||||
turn: 'goats',
|
||||
}));
|
||||
} else if (gameState.phase === 'goat-placement') {
|
||||
if (gameState.turn === 'goats') {
|
||||
// Place a goat
|
||||
if (isOccupied(row, col)) return;
|
||||
|
||||
const newGoatPositions = [...gameState.goatPositions, clickedPos];
|
||||
const newGoatsPlaced = gameState.goatsPlaced + 1;
|
||||
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
goatPositions: newGoatPositions,
|
||||
goatsPlaced: newGoatsPlaced,
|
||||
turn: 'tiger',
|
||||
}));
|
||||
} else if (gameState.turn === 'tiger') {
|
||||
// Tiger moves during goat placement
|
||||
if (!gameState.tigerPosition) return;
|
||||
|
||||
const validMoves = getValidTigerMoves(gameState.tigerPosition);
|
||||
const captures = getTigerCaptures(gameState.tigerPosition);
|
||||
|
||||
// Check for capture
|
||||
const capture = captures.find(c => c.jumpTo.row === row && c.jumpTo.col === col);
|
||||
if (capture) {
|
||||
// Tiger captures a goat - tiger wins!
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
tigerPosition: capture.jumpTo,
|
||||
goatPositions: prev.goatPositions.filter(
|
||||
g => !(g.row === capture.capturedGoat.row && g.col === capture.capturedGoat.col)
|
||||
),
|
||||
winner: 'tiger',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular move
|
||||
const isValidMove = validMoves.some(m => m.row === row && m.col === col);
|
||||
if (!isValidMove) return;
|
||||
|
||||
const newPhase = gameState.goatsPlaced >= gameState.goatsToPlace - 1 ? 'playing' : 'goat-placement';
|
||||
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
tigerPosition: clickedPos,
|
||||
phase: prev.goatsPlaced >= prev.goatsToPlace ? 'playing' : 'goat-placement',
|
||||
turn: 'goats',
|
||||
}));
|
||||
}
|
||||
} else if (gameState.phase === 'playing') {
|
||||
if (gameState.turn === 'tiger') {
|
||||
if (!gameState.tigerPosition) return;
|
||||
|
||||
const validMoves = getValidTigerMoves(gameState.tigerPosition);
|
||||
const captures = getTigerCaptures(gameState.tigerPosition);
|
||||
|
||||
// Check for capture
|
||||
const capture = captures.find(c => c.jumpTo.row === row && c.jumpTo.col === col);
|
||||
if (capture) {
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
tigerPosition: capture.jumpTo,
|
||||
goatPositions: prev.goatPositions.filter(
|
||||
g => !(g.row === capture.capturedGoat.row && g.col === capture.capturedGoat.col)
|
||||
),
|
||||
winner: 'tiger',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const isValidMove = validMoves.some(m => m.row === row && m.col === col);
|
||||
if (!isValidMove) return;
|
||||
|
||||
setGameState(prev => {
|
||||
const newState = {
|
||||
...prev,
|
||||
tigerPosition: clickedPos,
|
||||
turn: 'goats' as Turn,
|
||||
};
|
||||
|
||||
// Check if goats win (tiger trapped after this move)
|
||||
if (isTigerTrapped(clickedPos)) {
|
||||
return { ...newState, winner: 'goats' };
|
||||
}
|
||||
|
||||
return newState;
|
||||
});
|
||||
} else {
|
||||
// Goat turn - select or move a goat
|
||||
const clickedGoatIndex = gameState.goatPositions.findIndex(
|
||||
g => g.row === row && g.col === col
|
||||
);
|
||||
|
||||
if (clickedGoatIndex !== -1) {
|
||||
// Select this goat
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
selectedGoat: clickedGoatIndex,
|
||||
}));
|
||||
} else if (gameState.selectedGoat !== null) {
|
||||
// Try to move selected goat
|
||||
const selectedGoatPos = gameState.goatPositions[gameState.selectedGoat];
|
||||
const validMoves = getAdjacentPositions(selectedGoatPos).filter(
|
||||
pos => !isOccupied(pos.row, pos.col)
|
||||
);
|
||||
|
||||
const isValidMove = validMoves.some(m => m.row === row && m.col === col);
|
||||
if (!isValidMove) return;
|
||||
|
||||
const newGoatPositions = gameState.goatPositions.map((g, i) =>
|
||||
i === gameState.selectedGoat ? clickedPos : g
|
||||
);
|
||||
|
||||
setGameState(prev => {
|
||||
const newState = {
|
||||
...prev,
|
||||
goatPositions: newGoatPositions,
|
||||
selectedGoat: null,
|
||||
turn: 'tiger' as Turn,
|
||||
};
|
||||
|
||||
// Check if tiger is trapped with the NEW goat positions
|
||||
if (prev.tigerPosition && isTigerTrappedWithGoats(prev.tigerPosition, newGoatPositions)) {
|
||||
return { ...newState, winner: 'goats' };
|
||||
}
|
||||
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Computer makes a move
|
||||
useEffect(() => {
|
||||
if (gameState.winner) return;
|
||||
if (gameMode !== 'vs-computer') return;
|
||||
if (gameState.phase === 'setup') return;
|
||||
|
||||
const isComputerTurn =
|
||||
(gameState.turn === 'tiger' && computerSide === 'tiger') ||
|
||||
(gameState.turn === 'goats' && computerSide === 'goats');
|
||||
|
||||
if (!isComputerTurn) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (gameState.turn === 'tiger' && gameState.tigerPosition) {
|
||||
const move = getTigerComputerMove();
|
||||
if (move) {
|
||||
// Check if it's a capture
|
||||
const captures = getTigerCaptures(gameState.tigerPosition);
|
||||
const capture = captures.find(c => c.jumpTo.row === move.row && c.jumpTo.col === move.col);
|
||||
|
||||
if (capture) {
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
tigerPosition: capture.jumpTo,
|
||||
goatPositions: prev.goatPositions.filter(
|
||||
g => !(g.row === capture.capturedGoat.row && g.col === capture.capturedGoat.col)
|
||||
),
|
||||
winner: 'tiger',
|
||||
}));
|
||||
} else {
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
tigerPosition: move,
|
||||
phase: prev.goatsPlaced >= prev.goatsToPlace ? 'playing' : prev.phase,
|
||||
turn: 'goats',
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (gameState.turn === 'goats') {
|
||||
const goatMove = getGoatComputerMove();
|
||||
if (goatMove) {
|
||||
if (goatMove.type === 'place' && goatMove.position) {
|
||||
const newGoatPositions = [...gameState.goatPositions, goatMove.position];
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
goatPositions: newGoatPositions,
|
||||
goatsPlaced: prev.goatsPlaced + 1,
|
||||
turn: 'tiger',
|
||||
}));
|
||||
} else if (goatMove.type === 'move' && goatMove.fromIndex !== undefined && goatMove.toPosition) {
|
||||
const newGoatPositions = gameState.goatPositions.map((g, i) =>
|
||||
i === goatMove.fromIndex ? goatMove.toPosition! : g
|
||||
);
|
||||
|
||||
setGameState(prev => {
|
||||
const newState = {
|
||||
...prev,
|
||||
goatPositions: newGoatPositions,
|
||||
turn: 'tiger' as Turn,
|
||||
};
|
||||
|
||||
// Check if tiger is trapped with the NEW goat positions
|
||||
if (prev.tigerPosition && isTigerTrappedWithGoats(prev.tigerPosition, newGoatPositions)) {
|
||||
return { ...newState, winner: 'goats' };
|
||||
}
|
||||
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [gameState, gameMode, computerSide]);
|
||||
|
||||
// Check for goat placement phase completion
|
||||
useEffect(() => {
|
||||
if (gameState.phase === 'goat-placement' &&
|
||||
gameState.goatsPlaced >= gameState.goatsToPlace &&
|
||||
gameState.turn === 'goats') {
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
phase: 'playing',
|
||||
}));
|
||||
}
|
||||
}, [gameState.goatsPlaced, gameState.goatsToPlace, gameState.phase, gameState.turn]);
|
||||
|
||||
// Render grid cell
|
||||
const renderCell = (row: number, col: number) => {
|
||||
const isTiger = gameState.tigerPosition?.row === row && gameState.tigerPosition?.col === col;
|
||||
const goatIndex = gameState.goatPositions.findIndex(g => g.row === row && g.col === col);
|
||||
const isGoat = goatIndex !== -1;
|
||||
const isSelected = gameState.selectedGoat === goatIndex;
|
||||
|
||||
const validTigerMoves = gameState.tigerPosition ? getValidTigerMoves(gameState.tigerPosition) : [];
|
||||
const tigerCaptures = gameState.tigerPosition ? getTigerCaptures(gameState.tigerPosition) : [];
|
||||
const isValidTigerMove = gameState.turn === 'tiger' && gameState.phase !== 'setup' && gameState.phase !== 'tiger-placement' &&
|
||||
(validTigerMoves.some(m => m.row === row && m.col === col) ||
|
||||
tigerCaptures.some(c => c.jumpTo.row === row && c.jumpTo.col === col));
|
||||
|
||||
const selectedGoatPos = gameState.selectedGoat !== null ? gameState.goatPositions[gameState.selectedGoat] : null;
|
||||
const validGoatMoves = selectedGoatPos ? getAdjacentPositions(selectedGoatPos).filter(p => !isOccupied(p.row, p.col)) : [];
|
||||
const isValidGoatMove = gameState.turn === 'goats' && gameState.phase === 'playing' &&
|
||||
validGoatMoves.some(m => m.row === row && m.col === col);
|
||||
|
||||
const canPlaceGoat = gameState.phase === 'goat-placement' && gameState.turn === 'goats' && !isOccupied(row, col);
|
||||
const isCaptureTarget = tigerCaptures.some(c => c.capturedGoat.row === row && c.capturedGoat.col === col);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${row}-${col}`}
|
||||
onClick={() => handleCellClick(row, col)}
|
||||
className={`
|
||||
w-16 h-16 border-2 border-border rounded-lg flex items-center justify-center text-3xl
|
||||
cursor-pointer transition-all duration-200
|
||||
${isSelected ? 'ring-4 ring-primary bg-primary/20' : ''}
|
||||
${isValidTigerMove ? 'bg-orange-200 dark:bg-orange-900/30' : ''}
|
||||
${isValidGoatMove ? 'bg-green-200 dark:bg-green-900/30' : ''}
|
||||
${canPlaceGoat ? 'bg-blue-100 dark:bg-blue-900/20 hover:bg-blue-200 dark:hover:bg-blue-900/40' : ''}
|
||||
${isCaptureTarget && gameState.turn === 'tiger' ? 'bg-red-200 dark:bg-red-900/30' : ''}
|
||||
${!isTiger && !isGoat && !isValidTigerMove && !isValidGoatMove && !canPlaceGoat ? 'hover:bg-muted' : ''}
|
||||
`}
|
||||
>
|
||||
{isTiger && '🐯'}
|
||||
{isGoat && '🐐'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-4xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Bagchal (Tiger and Goats)</CardTitle>
|
||||
<CardDescription>
|
||||
A strategic game where the tiger tries to capture goats, while goats try to trap the tiger.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{gameState.phase === 'setup' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Grid size controls */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-3">
|
||||
<Label>Rows (m): {rows}</Label>
|
||||
<Slider
|
||||
value={[rows]}
|
||||
onValueChange={(v) => { setRows(v[0]); setNumGoats(Math.min(numGoats, v[0] * cols - 1)); }}
|
||||
min={2}
|
||||
max={10}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label>Columns (n): {cols}</Label>
|
||||
<Slider
|
||||
value={[cols]}
|
||||
onValueChange={(v) => { setCols(v[0]); setNumGoats(Math.min(numGoats, rows * v[0] - 1)); }}
|
||||
min={2}
|
||||
max={10}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number of goats */}
|
||||
<div className="space-y-3">
|
||||
<Label>Number of Goats (k): {numGoats}</Label>
|
||||
<Slider
|
||||
value={[numGoats]}
|
||||
onValueChange={(v) => setNumGoats(v[0])}
|
||||
min={1}
|
||||
max={maxGoats}
|
||||
step={1}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Max: {maxGoats} goats for {rows}×{cols} grid</p>
|
||||
</div>
|
||||
|
||||
{/* Game mode */}
|
||||
<div className="space-y-3">
|
||||
<Label>Game Mode</Label>
|
||||
<RadioGroup value={gameMode} onValueChange={(v) => setGameMode(v as GameMode)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="2-player" id="2-player" />
|
||||
<Label htmlFor="2-player">2 Player</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="vs-computer" id="vs-computer" />
|
||||
<Label htmlFor="vs-computer">Play vs Computer</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Computer side selection */}
|
||||
{gameMode === 'vs-computer' && (
|
||||
<div className="space-y-3">
|
||||
<Label>You play as:</Label>
|
||||
<RadioGroup value={computerSide === 'tiger' ? 'goats' : 'tiger'} onValueChange={(v) => setComputerSide(v === 'tiger' ? 'goats' : 'tiger')}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="tiger" id="play-tiger" />
|
||||
<Label htmlFor="play-tiger">🐯 Tiger</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="goats" id="play-goats" />
|
||||
<Label htmlFor="play-goats">🐐 Goats</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={startGame} size="lg" className="w-full">
|
||||
Start Game
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Game status */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant={gameState.turn === 'tiger' ? 'default' : 'secondary'} className="text-lg px-3 py-1">
|
||||
{gameState.turn === 'tiger' ? '🐯 Tiger\'s Turn' : '🐐 Goats\' Turn'}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-lg px-3 py-1">
|
||||
Phase: {gameState.phase === 'tiger-placement' ? 'Place Tiger' :
|
||||
gameState.phase === 'goat-placement' ? `Place Goats (${gameState.goatsPlaced}/${gameState.goatsToPlace})` :
|
||||
'Playing'}
|
||||
</Badge>
|
||||
{gameState.winner && (
|
||||
<Badge variant="destructive" className="text-lg px-3 py-1">
|
||||
{gameState.winner === 'tiger' ? '🐯 Tiger Wins!' : '🐐 Goats Win!'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{gameState.phase === 'tiger-placement' && 'Click on any cell to place the tiger.'}
|
||||
{gameState.phase === 'goat-placement' && gameState.turn === 'goats' && 'Click on an empty cell to place a goat.'}
|
||||
{gameState.phase === 'goat-placement' && gameState.turn === 'tiger' && 'Tiger: Click on an adjacent empty cell to move.'}
|
||||
{gameState.phase === 'playing' && gameState.turn === 'tiger' && 'Tiger: Click on an adjacent cell to move, or jump over a goat to capture.'}
|
||||
{gameState.phase === 'playing' && gameState.turn === 'goats' && 'Click a goat to select it, then click an adjacent empty cell to move.'}
|
||||
</div>
|
||||
|
||||
{/* Game grid */}
|
||||
<div className="flex justify-center overflow-x-auto">
|
||||
<div
|
||||
className="grid gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{Array.from({ length: rows }, (_, row) =>
|
||||
Array.from({ length: cols }, (_, col) => renderCell(row, col))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-orange-200 dark:bg-orange-900/30 rounded"></div>
|
||||
<span>Valid tiger move</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-green-200 dark:bg-green-900/30 rounded"></div>
|
||||
<span>Valid goat move</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-red-200 dark:bg-red-900/30 rounded"></div>
|
||||
<span>Capturable goat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={resetGame} variant="outline">
|
||||
Reset Game
|
||||
</Button>
|
||||
<Button onClick={() => setGameState(prev => ({ ...prev, phase: 'setup' }))} variant="secondary">
|
||||
Change Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BagchalGame;
|
||||
317
src/components/interactives/BalancedTernaryGame.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RangeSlider } from '@/components/ui/range-slider';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Plus, Minus } from 'lucide-react';
|
||||
|
||||
const POWERS_OF_THREE = [2187, 729, 243, 81, 27, 9, 3, 1]; // 3^7 to 3^0
|
||||
|
||||
interface BalancedTernaryGameProps {
|
||||
}
|
||||
|
||||
const BalancedTernaryGame: React.FC<BalancedTernaryGameProps> = () => {
|
||||
const [targetNumber, setTargetNumber] = useState<number>(0);
|
||||
const [cardValues, setCardValues] = useState<number[]>(new Array(8).fill(0)); // -1, 0, or 1 for each position
|
||||
const [currentSum, setCurrentSum] = useState<number>(0);
|
||||
const [timer, setTimer] = useState<number>(0);
|
||||
const [isGameActive, setIsGameActive] = useState<boolean>(false);
|
||||
const [isGameWon, setIsGameWon] = useState<boolean>(false);
|
||||
const [hasGameStarted, setHasGameStarted] = useState<boolean>(false);
|
||||
const [highScore, setHighScore] = useState<number | null>(null);
|
||||
const [targetRange, setTargetRange] = useState<[number, number]>([-1000, 1000]);
|
||||
|
||||
// Load high score from localStorage on component mount
|
||||
useEffect(() => {
|
||||
const savedHighScore = localStorage.getItem('balanced-ternary-game-high-score');
|
||||
if (savedHighScore) {
|
||||
setHighScore(parseInt(savedHighScore));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start new game
|
||||
const startNewGame = useCallback(() => {
|
||||
// Generate a random number within the selected range
|
||||
const [min, max] = targetRange;
|
||||
let result = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
// Make sure we don't get 0 as target
|
||||
if (result === 0) {
|
||||
result = Math.random() < 0.5 ? min : max;
|
||||
}
|
||||
|
||||
setTargetNumber(result);
|
||||
setCardValues(new Array(8).fill(0));
|
||||
setCurrentSum(0);
|
||||
setTimer(0);
|
||||
setIsGameActive(true);
|
||||
setIsGameWon(false);
|
||||
setHasGameStarted(true);
|
||||
}, [targetRange]);
|
||||
|
||||
// Start game for the first time or reset game
|
||||
const startGame = () => {
|
||||
if (hasGameStarted) {
|
||||
// Reset to allow range selection
|
||||
setIsGameActive(false);
|
||||
setHasGameStarted(false);
|
||||
setIsGameWon(false);
|
||||
setTimer(0);
|
||||
setCurrentSum(0);
|
||||
setCardValues(new Array(8).fill(0));
|
||||
} else {
|
||||
startNewGame();
|
||||
}
|
||||
};
|
||||
|
||||
// Timer effect
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isGameActive && !isGameWon) {
|
||||
interval = setInterval(() => {
|
||||
setTimer(prev => prev + 0.1);
|
||||
}, 100);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isGameActive, isGameWon]);
|
||||
|
||||
// Check for win condition
|
||||
useEffect(() => {
|
||||
if (currentSum === targetNumber && isGameActive) {
|
||||
setIsGameActive(false);
|
||||
setIsGameWon(true);
|
||||
|
||||
// Update high score
|
||||
const currentTime = Math.round(timer * 10) / 10;
|
||||
if (highScore === null || currentTime < highScore) {
|
||||
setHighScore(currentTime);
|
||||
localStorage.setItem('balanced-ternary-game-high-score', currentTime.toString());
|
||||
}
|
||||
}
|
||||
}, [currentSum, targetNumber, isGameActive, timer, highScore]);
|
||||
|
||||
// Calculate current sum
|
||||
useEffect(() => {
|
||||
const sum = cardValues.reduce((acc, value, index) => {
|
||||
return acc + value * POWERS_OF_THREE[index];
|
||||
}, 0);
|
||||
setCurrentSum(sum);
|
||||
}, [cardValues]);
|
||||
|
||||
const incrementCard = (index: number) => {
|
||||
if (!isGameActive || isGameWon) return;
|
||||
|
||||
setCardValues(prev => {
|
||||
const newValues = [...prev];
|
||||
newValues[index] = Math.min(1, newValues[index] + 1);
|
||||
return newValues;
|
||||
});
|
||||
};
|
||||
|
||||
const decrementCard = (index: number) => {
|
||||
if (!isGameActive || isGameWon) return;
|
||||
|
||||
setCardValues(prev => {
|
||||
const newValues = [...prev];
|
||||
newValues[index] = Math.max(-1, newValues[index] - 1);
|
||||
return newValues;
|
||||
});
|
||||
};
|
||||
|
||||
// Convert number to balanced ternary string for display
|
||||
const toBalancedTernary = (num: number) => {
|
||||
if (num === 0) return '0';
|
||||
|
||||
let result = '';
|
||||
let n = Math.abs(num);
|
||||
const isNegative = num < 0;
|
||||
|
||||
while (n > 0) {
|
||||
const remainder = n % 3;
|
||||
n = Math.floor(n / 3);
|
||||
|
||||
if (remainder === 0) {
|
||||
result = '0' + result;
|
||||
} else if (remainder === 1) {
|
||||
result = '1' + result;
|
||||
} else { // remainder === 2
|
||||
result = 'T' + result; // T represents -1
|
||||
n += 1; // carry over
|
||||
}
|
||||
}
|
||||
|
||||
return isNegative ? result.split('').map(d => d === '1' ? 'T' : d === 'T' ? '1' : d).join('') : result;
|
||||
};
|
||||
|
||||
const getCardColor = (value: number) => {
|
||||
if (value === -1) return 'bg-gradient-to-br from-red-100 to-red-200 border-red-300';
|
||||
if (value === 0) return 'bg-gradient-to-br from-gray-100 to-gray-200 border-gray-300';
|
||||
if (value === 1) return 'bg-gradient-to-br from-green-100 to-green-200 border-green-300';
|
||||
return '';
|
||||
};
|
||||
|
||||
const getTextColor = (value: number) => {
|
||||
if (value === -1) return 'text-red-800';
|
||||
if (value === 0) return 'text-gray-500';
|
||||
if (value === 1) return 'text-green-800';
|
||||
return '';
|
||||
};
|
||||
|
||||
const getValueDisplay = (value: number) => {
|
||||
if (value === -1) return 'T';
|
||||
if (value === 0) return '0';
|
||||
if (value === 1) return '1';
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold text-foreground">Balanced Ternary Representation</h2>
|
||||
<p className="text-muted-foreground">Use + and - buttons to add or subtract powers of three. Digits can be T (-1), 0, or 1.</p>
|
||||
{highScore !== null && (
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
Best Time: {highScore}s
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Range Selection */}
|
||||
{!isGameActive && (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<Label className="text-lg font-semibold">Target Number Range</Label>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
||||
<span>Min: {targetRange[0]}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Max: {targetRange[1]}</span>
|
||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<RangeSlider
|
||||
value={targetRange}
|
||||
onValueChange={(value) => {
|
||||
// Snap to multiples of 10 if close enough
|
||||
const snappedValue = value.map(v => {
|
||||
const remainder = Math.abs(v) % 10;
|
||||
if (remainder <= 2) return v - Math.sign(v) * remainder;
|
||||
if (remainder >= 8) return v + Math.sign(v) * (10 - remainder);
|
||||
return v;
|
||||
});
|
||||
setTargetRange(snappedValue as [number, number]);
|
||||
}}
|
||||
min={-3280}
|
||||
max={3280}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose the range for target numbers. Range: -{POWERS_OF_THREE.reduce((sum, power) => sum + power, 0)} to +{POWERS_OF_THREE.reduce((sum, power) => sum + power, 0)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cards representing powers of three */}
|
||||
<div className="grid grid-cols-8 gap-3">
|
||||
{POWERS_OF_THREE.map((power, index) => (
|
||||
<div key={power} className="flex flex-col items-center space-y-2">
|
||||
{/* Power value */}
|
||||
<span className="text-xs text-muted-foreground font-medium">{power}</span>
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-full"
|
||||
onClick={() => incrementCard(index)}
|
||||
disabled={!isGameActive || isGameWon || cardValues[index] >= 1}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Card
|
||||
className={`aspect-square w-full ${getCardColor(cardValues[index])} shadow-md`}
|
||||
>
|
||||
<CardContent className="p-0 h-full flex flex-col items-center justify-center">
|
||||
<span className={`text-2xl font-bold ${getTextColor(cardValues[index])}`}>
|
||||
{getValueDisplay(cardValues[index])}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${getTextColor(cardValues[index])}`}>
|
||||
{cardValues[index] !== 0 && `${cardValues[index] === 1 ? '+' : '-'}${power}`}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Subtract button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-full"
|
||||
onClick={() => decrementCard(index)}
|
||||
disabled={!isGameActive || isGameWon || cardValues[index] <= -1}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground font-medium">3^{7-index}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sum display boxes */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="bg-secondary">
|
||||
<CardContent className="p-6 text-center">
|
||||
<h3 className="text-lg font-semibold text-muted-foreground mb-2">Current Sum</h3>
|
||||
<div className={`text-4xl font-bold ${
|
||||
Math.abs(currentSum - targetNumber) > Math.abs(targetNumber) ? "text-red-600" : "text-foreground"
|
||||
}`}>
|
||||
{currentSum}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-primary/10 border-primary/20">
|
||||
<CardContent className="p-6 text-center">
|
||||
<h3 className="text-lg font-semibold text-muted-foreground mb-2">Target Sum</h3>
|
||||
<div className={`text-4xl font-bold ${isGameWon ? "text-green-600" : "text-primary"}`}>
|
||||
{!hasGameStarted ? "🎯" : isGameWon ? toBalancedTernary(targetNumber) : targetNumber}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Timer and controls */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-2xl font-mono text-foreground">
|
||||
Time: {Math.round(timer * 10) / 10}s
|
||||
</div>
|
||||
|
||||
{isGameWon && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xl font-bold text-green-600">Congratulations! 🎉</div>
|
||||
<div className="text-lg text-muted-foreground">
|
||||
Your score: {Math.round(timer * 10) / 10} seconds
|
||||
</div>
|
||||
{highScore === Math.round(timer * 10) / 10 && (
|
||||
<Badge variant="default" className="bg-yellow-500 text-yellow-900">
|
||||
New High Score! 🏆
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={!isGameActive ? startNewGame : startGame} size="lg" className="px-8">
|
||||
{!hasGameStarted ? 'Start Game' : isGameWon ? 'Play Again' : 'New Game'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BalancedTernaryGame;
|
||||
175
src/components/interactives/BinaryNumberGame.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
const POWERS_OF_TWO = [128, 64, 32, 16, 8, 4, 2, 1];
|
||||
|
||||
interface BinaryNumberGameProps {
|
||||
}
|
||||
|
||||
const BinaryNumberGame: React.FC<BinaryNumberGameProps> = () => {
|
||||
const [targetNumber, setTargetNumber] = useState<number>(0);
|
||||
const [flippedCards, setFlippedCards] = useState<boolean[]>(new Array(8).fill(false));
|
||||
const [currentSum, setCurrentSum] = useState<number>(0);
|
||||
const [timer, setTimer] = useState<number>(0);
|
||||
const [isGameActive, setIsGameActive] = useState<boolean>(false);
|
||||
const [isGameWon, setIsGameWon] = useState<boolean>(false);
|
||||
const [hasGameStarted, setHasGameStarted] = useState<boolean>(false);
|
||||
const [highScore, setHighScore] = useState<number | null>(null);
|
||||
|
||||
// Load high score from localStorage on component mount
|
||||
useEffect(() => {
|
||||
const savedHighScore = localStorage.getItem('binary-game-high-score');
|
||||
if (savedHighScore) {
|
||||
setHighScore(parseInt(savedHighScore));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start new game
|
||||
const startNewGame = useCallback(() => {
|
||||
const newTarget = Math.floor(Math.random() * 257); // 0-256
|
||||
setTargetNumber(newTarget);
|
||||
setFlippedCards(new Array(8).fill(false));
|
||||
setCurrentSum(0);
|
||||
setTimer(0);
|
||||
setIsGameActive(true);
|
||||
setIsGameWon(false);
|
||||
setHasGameStarted(true);
|
||||
}, []);
|
||||
|
||||
// Start game for the first time
|
||||
const startGame = () => {
|
||||
startNewGame();
|
||||
};
|
||||
|
||||
// Timer effect
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isGameActive && !isGameWon) {
|
||||
interval = setInterval(() => {
|
||||
setTimer(prev => prev + 0.1);
|
||||
}, 100);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isGameActive, isGameWon]);
|
||||
|
||||
// Check for win condition
|
||||
useEffect(() => {
|
||||
if (currentSum === targetNumber && isGameActive) {
|
||||
setIsGameActive(false);
|
||||
setIsGameWon(true);
|
||||
|
||||
// Update high score
|
||||
const currentTime = Math.round(timer * 10) / 10;
|
||||
if (highScore === null || currentTime < highScore) {
|
||||
setHighScore(currentTime);
|
||||
localStorage.setItem('binary-game-high-score', currentTime.toString());
|
||||
}
|
||||
}
|
||||
}, [currentSum, targetNumber, isGameActive, timer, highScore]);
|
||||
|
||||
// Calculate current sum
|
||||
useEffect(() => {
|
||||
const sum = flippedCards.reduce((acc, isFlipped, index) => {
|
||||
return isFlipped ? acc + POWERS_OF_TWO[index] : acc;
|
||||
}, 0);
|
||||
setCurrentSum(sum);
|
||||
}, [flippedCards]);
|
||||
|
||||
const toggleCard = (index: number) => {
|
||||
if (!isGameActive || isGameWon) return;
|
||||
|
||||
setFlippedCards(prev => {
|
||||
const newFlipped = [...prev];
|
||||
newFlipped[index] = !newFlipped[index];
|
||||
return newFlipped;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold text-foreground">Binary Number Representation</h2>
|
||||
<p className="text-muted-foreground">Click the cards to represent the target number as a sum of powers of two!</p>
|
||||
{highScore !== null && (
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
Best Time: {highScore}s
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards representing powers of two */}
|
||||
<div className="grid grid-cols-8 gap-3">
|
||||
{POWERS_OF_TWO.map((power, index) => (
|
||||
<div key={power} className="flex flex-col items-center space-y-2">
|
||||
<Card
|
||||
className={`cursor-pointer transition-all duration-200 hover:scale-105 aspect-square w-full ${
|
||||
flippedCards[index]
|
||||
? 'bg-blue-100 border-blue-300 shadow-md'
|
||||
: 'bg-gradient-to-br from-pink-100 to-purple-100 border-pink-200'
|
||||
}`}
|
||||
onClick={() => toggleCard(index)}
|
||||
>
|
||||
<CardContent className="p-0 h-full flex items-center justify-center">
|
||||
{flippedCards[index] ? (
|
||||
<span className="text-2xl font-bold text-blue-800">{power}</span>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-pink-200 via-purple-200 to-indigo-200 rounded-lg opacity-80 flex items-center justify-center">
|
||||
<div className="w-8 h-8 bg-white rounded-full opacity-60"></div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<span className="text-xs text-muted-foreground font-medium">{power}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sum display boxes */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="bg-secondary">
|
||||
<CardContent className="p-6 text-center">
|
||||
<h3 className="text-lg font-semibold text-muted-foreground mb-2">Current Sum</h3>
|
||||
<div className={`text-4xl font-bold ${currentSum > targetNumber ? "text-red-600" : "text-foreground"}`}>{currentSum}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-primary/10 border-primary/20">
|
||||
<CardContent className="p-6 text-center">
|
||||
<h3 className="text-lg font-semibold text-muted-foreground mb-2">Target Sum</h3>
|
||||
<div className={`text-4xl font-bold ${isGameWon ? "text-red-800" : "text-primary"}`}>
|
||||
{!hasGameStarted ? "🎯" : isGameWon ? targetNumber.toString(2) : targetNumber}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Timer and controls */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-2xl font-mono text-foreground">
|
||||
Time: {Math.round(timer * 10) / 10}s
|
||||
</div>
|
||||
|
||||
{isGameWon && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xl font-bold text-green-600">Congratulations! 🎉</div>
|
||||
<div className="text-lg text-muted-foreground">
|
||||
Your score: {Math.round(timer * 10) / 10} seconds
|
||||
</div>
|
||||
{highScore === Math.round(timer * 10) / 10 && (
|
||||
<Badge variant="default" className="bg-yellow-500 text-yellow-900">
|
||||
New High Score! 🏆
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={hasGameStarted ? startNewGame : startGame} size="lg" className="px-8">
|
||||
{!hasGameStarted ? 'Start Game' : isGameWon ? 'Play Again' : 'New Game'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BinaryNumberGame;
|
||||
354
src/components/interactives/BinarySearchTrick.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { HelpCircle, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface BinarySearchTrickProps {
|
||||
}
|
||||
|
||||
const BinarySearchTrick = ({}: BinarySearchTrickProps) => {
|
||||
const [cardStates, setCardStates] = useState<Record<number, 'unselected' | 'present' | 'absent'>>({});
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [isOneByOne, setIsOneByOne] = useState(false);
|
||||
const [isRandomized, setIsRandomized] = useState(true);
|
||||
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
||||
const [cards, setCards] = useState<Array<{id: number; title: string; numbers: number[]}>>([]);
|
||||
|
||||
// Function to shuffle array
|
||||
const shuffleArray = <T,>(array: T[]): T[] => {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
// Generate cards with optional randomization
|
||||
const generateCards = () => {
|
||||
const baseCards = [
|
||||
{
|
||||
id: 16,
|
||||
title: "Card 16",
|
||||
numbers: [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "Card 8",
|
||||
numbers: [8, 9, 10, 11, 12, 13, 14, 15, 24, 25, 26, 27, 28, 29, 30, 31]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Card 4",
|
||||
numbers: [4, 5, 6, 7, 12, 13, 14, 15, 20, 21, 22, 23, 28, 29, 30, 31]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Card 2",
|
||||
numbers: [2, 3, 6, 7, 10, 11, 14, 15, 18, 19, 22, 23, 26, 27, 30, 31]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: "Card 1",
|
||||
numbers: [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31]
|
||||
}
|
||||
];
|
||||
|
||||
// Apply randomization based on toggle state
|
||||
return baseCards.map(card => ({
|
||||
...card,
|
||||
numbers: isRandomized ? shuffleArray(card.numbers) : card.numbers
|
||||
}));
|
||||
};
|
||||
|
||||
// Initialize cards on component mount
|
||||
useEffect(() => {
|
||||
setCards(generateCards());
|
||||
}, [isRandomized]);
|
||||
|
||||
// Handle mode toggle
|
||||
const handleModeToggle = (checked: boolean) => {
|
||||
setIsOneByOne(checked);
|
||||
setCurrentCardIndex(0);
|
||||
setCardStates({});
|
||||
setShowResult(false);
|
||||
};
|
||||
|
||||
// Handle randomization toggle
|
||||
const handleRandomizationToggle = (checked: boolean) => {
|
||||
setIsRandomized(checked);
|
||||
setCardStates({});
|
||||
setShowResult(false);
|
||||
setCurrentCardIndex(0);
|
||||
};
|
||||
|
||||
// Navigation functions for one-by-one mode
|
||||
const goToPreviousCard = () => {
|
||||
setCurrentCardIndex(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const goToNextCard = () => {
|
||||
setCurrentCardIndex(prev => Math.min(cards.length - 1, prev + 1));
|
||||
};
|
||||
|
||||
const handleCardSelection = (cardId: number, state: 'present' | 'absent') => {
|
||||
setCardStates(prev => ({
|
||||
...prev,
|
||||
[cardId]: state
|
||||
}));
|
||||
setShowResult(false);
|
||||
};
|
||||
|
||||
const calculateResult = () => {
|
||||
const sum = Object.entries(cardStates)
|
||||
.filter(([_, state]) => state === 'present')
|
||||
.reduce((acc, [cardId, _]) => acc + parseInt(cardId), 0);
|
||||
setResult(sum);
|
||||
setShowResult(true);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setCardStates({});
|
||||
setResult(null);
|
||||
setShowResult(false);
|
||||
setCurrentCardIndex(0);
|
||||
setCards(generateCards()); // Regenerate with current randomization setting
|
||||
};
|
||||
|
||||
// Check if at least one card has been selected (present or absent)
|
||||
const hasAnySelection = Object.values(cardStates).some(state => state !== 'unselected');
|
||||
|
||||
// Check if all cards have been committed to in one-by-one mode
|
||||
const allCardsCommitted = isOneByOne
|
||||
? cards.every(card => cardStates[card.id] && cardStates[card.id] !== 'unselected')
|
||||
: hasAnySelection;
|
||||
|
||||
// Get cards to display based on mode
|
||||
const getDisplayCards = () => {
|
||||
if (isOneByOne) {
|
||||
return cards.length > 0 ? [cards[currentCardIndex]] : [];
|
||||
}
|
||||
return cards;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
<Card className="border-primary/20">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
|
||||
Binary Search Magic Trick
|
||||
</CardTitle>
|
||||
<div className="flex justify-center mt-4">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<HelpCircle className="w-4 h-4 mr-2" />
|
||||
How it works
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Binary Numbers Magic Trick Explained</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4 text-left">
|
||||
<h3 className="font-semibold text-lg">How to perform the trick:</h3>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>Ask someone to pick a secret number between 1 and 31</li>
|
||||
<li>Show them the 5 cards below and ask them to tell you which cards contain their number</li>
|
||||
<li>Add up the first numbers (16, 8, 4, 2, 1) from the cards they selected</li>
|
||||
<li>The sum will be their secret number!</li>
|
||||
</ol>
|
||||
|
||||
<h3 className="font-semibold text-lg mt-6">Why does it work?</h3>
|
||||
<p>
|
||||
This trick is based on binary number representation. Every number from 1 to 31 can be expressed as a sum of powers of 2: 16, 8, 4, 2, and 1.
|
||||
</p>
|
||||
<p>
|
||||
Each card contains all numbers that have a "1" in a specific binary position:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
||||
<li><strong>Card 16:</strong> Numbers with 1 in the 16's place (binary position 4)</li>
|
||||
<li><strong>Card 8:</strong> Numbers with 1 in the 8's place (binary position 3)</li>
|
||||
<li><strong>Card 4:</strong> Numbers with 1 in the 4's place (binary position 2)</li>
|
||||
<li><strong>Card 2:</strong> Numbers with 1 in the 2's place (binary position 1)</li>
|
||||
<li><strong>Card 1:</strong> Numbers with 1 in the 1's place (binary position 0)</li>
|
||||
</ul>
|
||||
|
||||
<div className="bg-muted p-4 rounded-lg mt-4">
|
||||
<p className="font-semibold">Example:</p>
|
||||
<p>The number 19 in binary is 10011, which means 16 + 2 + 1 = 19</p>
|
||||
<p>So 19 appears on cards 16, 2, and 1, but not on cards 8 or 4.</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
When you add up the first numbers from the selected cards, you're reconstructing the binary representation of their secret number!
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Think of a number between 1 and 31, then select all the cards that contain your number:
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 pt-4">
|
||||
<Label htmlFor="mode-toggle" className="text-sm font-medium">
|
||||
All at once
|
||||
</Label>
|
||||
<Switch
|
||||
id="mode-toggle"
|
||||
checked={isOneByOne}
|
||||
onCheckedChange={handleModeToggle}
|
||||
/>
|
||||
<Label htmlFor="mode-toggle" className="text-sm font-medium">
|
||||
One by one
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 pt-4">
|
||||
<Label htmlFor="randomization-toggle" className="text-sm font-medium">
|
||||
Randomize numbers
|
||||
</Label>
|
||||
<Switch
|
||||
id="randomization-toggle"
|
||||
checked={isRandomized}
|
||||
onCheckedChange={handleRandomizationToggle}
|
||||
/>
|
||||
<Label htmlFor="randomization-toggle" className="text-sm font-medium">
|
||||
Ordered numbers
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation controls for one-by-one mode */}
|
||||
{isOneByOne && cards.length > 0 && (
|
||||
<div className="flex items-center justify-center gap-4 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToPreviousCard}
|
||||
disabled={currentCardIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-muted-foreground px-4">
|
||||
Card {currentCardIndex + 1} of {cards.length}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToNextCard}
|
||||
disabled={currentCardIndex === cards.length - 1}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`grid gap-4 ${isOneByOne ? 'grid-cols-1 justify-items-center' : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'}`}>
|
||||
{getDisplayCards().map((card) => {
|
||||
const cardState = cardStates[card.id] || 'unselected';
|
||||
return (
|
||||
<Card
|
||||
key={card.id}
|
||||
className={`transition-all duration-500 hover:shadow-lg animate-fade-in ${
|
||||
cardState === 'present'
|
||||
? 'ring-2 ring-green-500 bg-green-50'
|
||||
: cardState === 'absent'
|
||||
? 'ring-2 ring-red-500 bg-red-50'
|
||||
: 'hover:bg-muted/50'
|
||||
} ${isOneByOne ? 'max-w-md w-full' : ''}`}
|
||||
>
|
||||
<CardHeader className={`${isOneByOne ? 'pb-4' : 'pb-2'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className={`font-bold text-primary ${isOneByOne ? 'text-3xl' : 'text-xl'}`}>
|
||||
{card.id}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={cardState === 'present' ? 'default' : 'outline'}
|
||||
onClick={() => handleCardSelection(card.id, 'present')}
|
||||
>
|
||||
✅
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={cardState === 'absent' ? 'destructive' : 'outline'}
|
||||
onClick={() => handleCardSelection(card.id, 'absent')}
|
||||
>
|
||||
❌
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`grid grid-cols-4 gap-1 ${isOneByOne ? 'text-lg gap-2' : 'text-sm'}`}>
|
||||
{card.numbers.map((num) => (
|
||||
<div
|
||||
key={num}
|
||||
className={`text-center bg-muted/30 rounded border ${isOneByOne ? 'p-3' : 'p-1'}`}
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={calculateResult}
|
||||
disabled={!allCardsCommitted}
|
||||
size="lg"
|
||||
className="min-w-32"
|
||||
>
|
||||
Reveal Number
|
||||
</Button>
|
||||
<Button
|
||||
onClick={reset}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showResult && result !== null && (
|
||||
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 animate-scale-in">
|
||||
<CardContent className="p-6 text-center">
|
||||
<h3 className="text-2xl font-bold text-green-800 mb-2">
|
||||
Your number is: {result}
|
||||
</h3>
|
||||
<p className="text-green-600">
|
||||
{result === 0 ? "Please select at least one card!" : "Amazing, right? ✨"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BinarySearchTrick;
|
||||
571
src/components/interactives/BulgarianSolitaire.tsx
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface BulgarianSolitaireProps {
|
||||
}
|
||||
|
||||
interface Box {
|
||||
id: number;
|
||||
columnIndex: number | null; // null means it's in the container
|
||||
}
|
||||
|
||||
const BulgarianSolitaire: React.FC<BulgarianSolitaireProps> = () => {
|
||||
const [n, setN] = useState(15);
|
||||
const [boxes, setBoxes] = useState<Box[]>([]);
|
||||
const [columns, setColumns] = useState<number[][]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [draggedBox, setDraggedBox] = useState<number | null>(null);
|
||||
const [step, setStep] = useState(0);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [highlightedBoxes, setHighlightedBoxes] = useState<Set<number>>(new Set());
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [animationPhase, setAnimationPhase] = useState<'idle' | 'fade-to-red' | 'red-pause' | 'moving' | 'fade-from-red' | 'sorting'>('idle');
|
||||
const [lastUsedColumn, setLastUsedColumn] = useState<number | null>(null);
|
||||
const [selectedBoxForMove, setSelectedBoxForMove] = useState<number | null>(null);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
const pauseRef = useRef(false);
|
||||
|
||||
const totalBoxes = n;
|
||||
|
||||
// Initialize boxes and columns when n changes
|
||||
useEffect(() => {
|
||||
const newBoxes: Box[] = [];
|
||||
for (let i = 0; i < totalBoxes; i++) {
|
||||
newBoxes.push({ id: i, columnIndex: null });
|
||||
}
|
||||
setBoxes(newBoxes);
|
||||
setColumns(Array.from({ length: totalBoxes }, () => []));
|
||||
setStep(0);
|
||||
setIsComplete(false);
|
||||
}, [n, totalBoxes]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, boxId: number) => {
|
||||
setDraggedBox(boxId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, columnIndex: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedBox === null) return;
|
||||
|
||||
const box = boxes.find(b => b.id === draggedBox);
|
||||
if (!box || box.columnIndex === columnIndex) return;
|
||||
|
||||
// Remove from previous location
|
||||
if (box.columnIndex !== null) {
|
||||
setColumns(prev => prev.map((col, idx) =>
|
||||
idx === box.columnIndex ? col.filter(id => id !== draggedBox) : col
|
||||
));
|
||||
}
|
||||
|
||||
// Add to new column
|
||||
setColumns(prev => prev.map((col, idx) =>
|
||||
idx === columnIndex ? [...col, draggedBox] : col
|
||||
));
|
||||
|
||||
// Update box location
|
||||
setBoxes(prev => prev.map(b =>
|
||||
b.id === draggedBox ? { ...b, columnIndex } : b
|
||||
));
|
||||
|
||||
// Track last used column
|
||||
setLastUsedColumn(columnIndex);
|
||||
setDraggedBox(null);
|
||||
};
|
||||
|
||||
const handleDropToContainer = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (draggedBox === null) return;
|
||||
|
||||
const box = boxes.find(b => b.id === draggedBox);
|
||||
if (!box || box.columnIndex === null) return;
|
||||
|
||||
// Remove from column
|
||||
setColumns(prev => prev.map((col, idx) =>
|
||||
idx === box.columnIndex ? col.filter(id => id !== draggedBox) : col
|
||||
));
|
||||
|
||||
// Return to container
|
||||
setBoxes(prev => prev.map(b =>
|
||||
b.id === draggedBox ? { ...b, columnIndex: null } : b
|
||||
));
|
||||
|
||||
setDraggedBox(null);
|
||||
};
|
||||
|
||||
const handleBoxClick = (boxId: number) => {
|
||||
const box = boxes.find(b => b.id === boxId);
|
||||
if (!box || box.columnIndex !== null || isPlaying) return;
|
||||
|
||||
// Select box for moving
|
||||
setSelectedBoxForMove(boxId);
|
||||
};
|
||||
|
||||
const handleColumnClick = (e: React.MouseEvent, columnIndex: number) => {
|
||||
e.stopPropagation();
|
||||
if (selectedBoxForMove === null || isPlaying) return;
|
||||
|
||||
// Move selected box to this column
|
||||
setColumns(prev => prev.map((col, idx) =>
|
||||
idx === columnIndex ? [...col, selectedBoxForMove] : col
|
||||
));
|
||||
|
||||
// Update box location
|
||||
setBoxes(prev => prev.map(b =>
|
||||
b.id === selectedBoxForMove ? { ...b, columnIndex } : b
|
||||
));
|
||||
|
||||
// Track last used column and clear selection
|
||||
setLastUsedColumn(columnIndex);
|
||||
setSelectedBoxForMove(null);
|
||||
};
|
||||
|
||||
const handleDoubleClick = (boxId: number) => {
|
||||
const box = boxes.find(b => b.id === boxId);
|
||||
if (!box) return;
|
||||
|
||||
// If box is in container, move to last used column
|
||||
if (box.columnIndex === null) {
|
||||
if (lastUsedColumn !== null) {
|
||||
// Add to last used column
|
||||
setColumns(prev => prev.map((col, idx) =>
|
||||
idx === lastUsedColumn ? [...col, boxId] : col
|
||||
));
|
||||
|
||||
// Update box location
|
||||
setBoxes(prev => prev.map(b =>
|
||||
b.id === boxId ? { ...b, columnIndex: lastUsedColumn } : b
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If box is in a column, remove from current column
|
||||
setColumns(prev => prev.map((col, idx) =>
|
||||
idx === box.columnIndex ? col.filter(id => id !== boxId) : col
|
||||
));
|
||||
|
||||
// Move to last used column or container if no last used column
|
||||
if (lastUsedColumn !== null && lastUsedColumn !== box.columnIndex) {
|
||||
// Add to last used column
|
||||
setColumns(prev => prev.map((col, idx) =>
|
||||
idx === lastUsedColumn ? [...col, boxId] : col
|
||||
));
|
||||
|
||||
// Update box location
|
||||
setBoxes(prev => prev.map(b =>
|
||||
b.id === boxId ? { ...b, columnIndex: lastUsedColumn } : b
|
||||
));
|
||||
} else {
|
||||
// Return to container if no last used column or same as current
|
||||
setBoxes(prev => prev.map(b =>
|
||||
b.id === boxId ? { ...b, columnIndex: null } : b
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const waitForUnpause = async () => {
|
||||
while (pauseRef.current) {
|
||||
await sleep(100);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
pauseRef.current = true;
|
||||
setIsPaused(true);
|
||||
};
|
||||
|
||||
const handleResume = () => {
|
||||
pauseRef.current = false;
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
const isTriangularConfiguration = (cols: number[][]) => {
|
||||
const nonEmptyCols = cols.filter(col => col.length > 0).sort((a, b) => a.length - b.length);
|
||||
|
||||
// Check if n is a triangular number and if columns form the staircase pattern
|
||||
// Find k such that k*(k+1)/2 = n
|
||||
const k = Math.floor((-1 + Math.sqrt(1 + 8 * n)) / 2);
|
||||
if (k * (k + 1) / 2 !== n) return false; // n is not a triangular number
|
||||
|
||||
if (nonEmptyCols.length !== k) return false;
|
||||
|
||||
for (let i = 0; i < k; i++) {
|
||||
if (nonEmptyCols[i].length !== i + 1) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const simulateBulgarianSolitaire = async () => {
|
||||
if (boxes.filter(b => b.columnIndex === null).length > 0) {
|
||||
toast.error("Please place all boxes in columns before playing!");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPlaying(true);
|
||||
setStep(0);
|
||||
let currentColumns = [...columns];
|
||||
let stepCount = 0;
|
||||
|
||||
while (!isTriangularConfiguration(currentColumns) && stepCount < 50) {
|
||||
stepCount++;
|
||||
setStep(stepCount);
|
||||
|
||||
// Highlight top boxes that will be moved
|
||||
const topBoxes = new Set<number>();
|
||||
currentColumns.forEach(col => {
|
||||
if (col.length > 0) {
|
||||
topBoxes.add(col[col.length - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
setHighlightedBoxes(topBoxes);
|
||||
|
||||
// Phase 1: Fade to red
|
||||
setAnimationPhase('fade-to-red');
|
||||
setIsAnimating(true);
|
||||
await sleep(500); // Fade to red animation
|
||||
await waitForUnpause();
|
||||
|
||||
// Phase 2: Red pause
|
||||
setAnimationPhase('red-pause');
|
||||
await sleep(1000); // Pause for 1 second with red blocks
|
||||
await waitForUnpause();
|
||||
|
||||
// Phase 3: Move blocks
|
||||
setAnimationPhase('moving');
|
||||
|
||||
// Create new pile from non-empty columns
|
||||
const newPile: number[] = [];
|
||||
const updatedColumns = currentColumns.map(col => {
|
||||
if (col.length > 0) {
|
||||
const boxToMove = col[col.length - 1]; // Take from top
|
||||
newPile.push(boxToMove);
|
||||
return col.slice(0, -1); // Remove from column
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
// Add the new pile
|
||||
const emptyColumnIndex = updatedColumns.findIndex(col => col.length === 0);
|
||||
if (emptyColumnIndex !== -1) {
|
||||
updatedColumns[emptyColumnIndex] = newPile;
|
||||
} else {
|
||||
// This shouldn't happen in a properly configured game
|
||||
console.error("No empty column found for new pile");
|
||||
break;
|
||||
}
|
||||
|
||||
currentColumns = updatedColumns;
|
||||
setColumns(currentColumns);
|
||||
|
||||
// Update box positions
|
||||
setBoxes(prev => prev.map(box => {
|
||||
for (let i = 0; i < currentColumns.length; i++) {
|
||||
if (currentColumns[i].includes(box.id)) {
|
||||
return { ...box, columnIndex: i };
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}));
|
||||
|
||||
await sleep(500); // Show move
|
||||
await waitForUnpause();
|
||||
|
||||
// Phase 4: Sort columns while boxes are still red
|
||||
setAnimationPhase('sorting');
|
||||
|
||||
// Sort columns by height (tallest to left) with smooth transition
|
||||
const columnData = currentColumns.map((col, index) => ({ col, originalIndex: index }))
|
||||
.filter(item => item.col.length > 0)
|
||||
.sort((a, b) => b.col.length - a.col.length);
|
||||
|
||||
const sortedColumns = Array.from({ length: updatedColumns.length }, () => []);
|
||||
columnData.forEach((item, index) => {
|
||||
sortedColumns[index] = item.col;
|
||||
});
|
||||
|
||||
currentColumns = sortedColumns;
|
||||
setColumns(currentColumns);
|
||||
|
||||
// Update box positions
|
||||
setBoxes(prev => prev.map(box => {
|
||||
for (let i = 0; i < currentColumns.length; i++) {
|
||||
if (currentColumns[i].includes(box.id)) {
|
||||
return { ...box, columnIndex: i };
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}));
|
||||
|
||||
await sleep(800); // Wait for sorting animation to complete
|
||||
await waitForUnpause();
|
||||
|
||||
// Phase 5: Fade from red to normal
|
||||
setAnimationPhase('fade-from-red');
|
||||
await sleep(500); // Fade from red animation
|
||||
await waitForUnpause();
|
||||
|
||||
// Clear highlighting and animation
|
||||
setHighlightedBoxes(new Set());
|
||||
setIsAnimating(false);
|
||||
setAnimationPhase('idle');
|
||||
|
||||
await waitForUnpause(); // Check for pause again
|
||||
}
|
||||
|
||||
setIsComplete(isTriangularConfiguration(currentColumns));
|
||||
setIsPlaying(false);
|
||||
|
||||
if (isTriangularConfiguration(currentColumns)) {
|
||||
toast.success(`Reached triangular configuration in ${stepCount} steps!`);
|
||||
} else {
|
||||
toast.error("Maximum steps reached without convergence");
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
pauseRef.current = false;
|
||||
setIsPaused(false);
|
||||
setIsPlaying(false);
|
||||
setDraggedBox(null);
|
||||
setLastUsedColumn(null);
|
||||
setSelectedBoxForMove(null);
|
||||
|
||||
const newBoxes: Box[] = [];
|
||||
for (let i = 0; i < totalBoxes; i++) {
|
||||
newBoxes.push({ id: i, columnIndex: null });
|
||||
}
|
||||
setBoxes(newBoxes);
|
||||
setColumns(Array.from({ length: totalBoxes }, () => []));
|
||||
setStep(0);
|
||||
setIsComplete(false);
|
||||
setHighlightedBoxes(new Set());
|
||||
setIsAnimating(false);
|
||||
setAnimationPhase('idle');
|
||||
};
|
||||
|
||||
const randomStart = () => {
|
||||
// Reset first
|
||||
reset();
|
||||
|
||||
// Randomly distribute all boxes into columns
|
||||
const newBoxes: Box[] = [];
|
||||
const newColumns = Array.from({ length: totalBoxes }, () => []);
|
||||
|
||||
for (let i = 0; i < totalBoxes; i++) {
|
||||
// Pick a random column index (ensure we use at least some columns)
|
||||
const columnIndex = Math.floor(Math.random() * Math.min(totalBoxes, 8)); // Limit to 8 columns for better visualization
|
||||
newBoxes.push({ id: i, columnIndex });
|
||||
newColumns[columnIndex].push(i);
|
||||
}
|
||||
|
||||
setBoxes(newBoxes);
|
||||
setColumns(newColumns);
|
||||
};
|
||||
|
||||
const containerBoxes = boxes.filter(box => box.columnIndex === null);
|
||||
|
||||
const handleBackgroundClick = () => {
|
||||
if (selectedBoxForMove !== null) {
|
||||
setSelectedBoxForMove(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-8" onClick={handleBackgroundClick}>
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-foreground">Bulgarian Solitaire</h1>
|
||||
<p className="text-muted-foreground text-base max-w-3xl mx-auto">
|
||||
Choose a number n, then drag the {totalBoxes} boxes into columns. <br />
|
||||
Note that one column can hold multiple boxes. <br />
|
||||
Click "Play" to simulate the Bulgarian Solitaire process: <br />
|
||||
here one box is taken from each non-empty column to form a new column.
|
||||
<br />
|
||||
<span className="border-t border-muted-foreground/30 block w-16 mx-auto my-4"></span>
|
||||
<br />
|
||||
Will this process always converge?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Total boxes = {n}</label>
|
||||
<Slider
|
||||
value={[n]}
|
||||
onValueChange={(value) => setN(value[0])}
|
||||
min={2}
|
||||
max={45}
|
||||
step={1}
|
||||
className="w-32"
|
||||
disabled={isPlaying}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={reset} variant="outline" disabled={isPlaying && !isPaused}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={randomStart} variant="default" disabled={isPlaying && !isPaused}>
|
||||
Random Start
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Container */}
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="border-2 border-dashed border-muted-foreground rounded-lg p-4 min-h-[120px] bg-muted/20"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDropToContainer}
|
||||
>
|
||||
<div className="text-center text-sm text-muted-foreground mb-2">Container</div>
|
||||
<div className="flex flex-wrap gap-2 justify-center max-w-md">
|
||||
{containerBoxes.map((box) => (
|
||||
<div
|
||||
key={box.id}
|
||||
draggable={!isPlaying}
|
||||
onDragStart={(e) => handleDragStart(e, box.id)}
|
||||
onDoubleClick={() => handleDoubleClick(box.id)}
|
||||
onClick={() => handleBoxClick(box.id)}
|
||||
className={`w-8 h-8 bg-primary rounded cursor-pointer hover:bg-primary/80 transition-colors ${
|
||||
selectedBoxForMove === box.id ? 'ring-2 ring-blue-500 ring-offset-2' : ''
|
||||
}`}
|
||||
>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columns */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">Columns</h3>
|
||||
{step > 0 && (
|
||||
<p className="text-sm text-muted-foreground">Step: {step}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-1 flex-wrap max-w-full mx-auto">
|
||||
{columns.map((column, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onClick={(e) => handleColumnClick(e, index)}
|
||||
className={`border rounded-lg min-h-[100px] w-12 p-1 bg-background/50 flex flex-col-reverse gap-1 transition-all duration-700 ease-in-out ${
|
||||
selectedBoxForMove !== null ? 'border-blue-500 border-2 cursor-pointer hover:bg-blue-50' : 'border-muted-foreground'
|
||||
} ${animationPhase === 'sorting' ? 'transform-gpu' : ''}`}
|
||||
style={{ flexBasis: `calc(${100/15}% - 0.25rem)`, minWidth: '48px' }}
|
||||
>
|
||||
<div className="text-xs text-center text-muted-foreground mb-1 transform rotate-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
{column.map((boxId, boxIndex) => (
|
||||
<div
|
||||
key={boxId}
|
||||
draggable={!isPlaying}
|
||||
onDragStart={(e) => handleDragStart(e, boxId)}
|
||||
onDoubleClick={() => handleDoubleClick(boxId)}
|
||||
className={`w-8 h-8 rounded cursor-move transition-all duration-300 flex items-center justify-center text-xs font-medium mx-auto ${
|
||||
highlightedBoxes.has(boxId) && animationPhase === 'fade-to-red'
|
||||
? 'animate-fade-to-red shadow-lg text-white'
|
||||
: highlightedBoxes.has(boxId) && (animationPhase === 'red-pause' || animationPhase === 'moving' || animationPhase === 'sorting')
|
||||
? 'bg-red-500 text-white shadow-lg'
|
||||
: highlightedBoxes.has(boxId) && animationPhase === 'fade-from-red'
|
||||
? 'animate-fade-from-red shadow-lg'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/80'
|
||||
}`}
|
||||
title="Drag to move or double-click to move to last-used column"
|
||||
>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play Button */}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={isPlaying ? (isPaused ? handleResume : handlePause) : simulateBulgarianSolitaire}
|
||||
disabled={!isPlaying && containerBoxes.length > 0}
|
||||
size="lg"
|
||||
className="px-12"
|
||||
>
|
||||
{isPlaying ? (isPaused ? 'Continue' : 'Pause') : 'Play'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
{isComplete && (
|
||||
<div className="text-center p-4 bg-success/10 border border-success/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold text-success">Triangular Configuration Reached! 🎉</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The Bulgarian Solitaire process converged to the stable triangular configuration in {step} steps.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-6 space-y-4">
|
||||
<button
|
||||
onClick={() => setShowInstructions(!showInstructions)}
|
||||
className="text-lg font-semibold text-primary hover:underline cursor-pointer text-left"
|
||||
>
|
||||
Click here for instructions.
|
||||
</button>
|
||||
{showInstructions && (
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Choose a value for n (2-45) using the slider</li>
|
||||
<li>Drag the {totalBoxes} boxes from the container into any of the columns</li>
|
||||
<li>Drag boxes between columns to rearrange them as needed</li>
|
||||
<li>Drag boxes back to the container or double-click any box in a column to move it to the last-used column</li>
|
||||
<li>Once all boxes are placed in columns, click "Play" to start the simulation</li>
|
||||
<li>Watch as the algorithm takes one box from each non-empty column to form a new column</li>
|
||||
<li>Spoiler: For triangular numbers, the process continues until reaching the staircase configuration: columns of sizes 1, 2, 3, ..., n.</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Credits */}
|
||||
<div className="bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 rounded-lg p-6 space-y-4 flex flex-col justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Find out more about Bulgarian Solitaire on{" "}
|
||||
<a href="https://en.wikipedia.org/wiki/Bulgarian_solitaire" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
Wikipedia
|
||||
</a>
|
||||
. I learned of this game from{" "}
|
||||
<a href="https://sites.google.com/view/kumarakash/home" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
Akash Kumar
|
||||
</a>
|
||||
, who very kindly walked me through the argument found in{" "}
|
||||
<a href="https://math.dartmouth.edu/~pw/" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
Peter Winkler's
|
||||
</a>
|
||||
{" "}beautiful puzzle book,{" "}
|
||||
<a href="https://math.dartmouth.edu/news-resources/electronic/puzzlebook/book/book.pdf" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
available here
|
||||
</a>
|
||||
. The game was introduced by{" "}
|
||||
<a href="https://en.wikipedia.org/wiki/Martin_Gardner" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
Martin Gardner
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulgarianSolitaire;
|
||||
349
src/components/interactives/BurnsidesLemma.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RotateCw, Palette } from 'lucide-react';
|
||||
|
||||
interface BurnsidesLemmaProps {
|
||||
shareUrl?: string;
|
||||
}
|
||||
|
||||
interface Necklace {
|
||||
colors: number[];
|
||||
id: string;
|
||||
}
|
||||
|
||||
const BurnsidesLemma = ({ shareUrl }: BurnsidesLemmaProps) => {
|
||||
const [numBeads, setNumBeads] = useState(6);
|
||||
const [numColors, setNumColors] = useState(3);
|
||||
const [allNecklaces, setAllNecklaces] = useState<Necklace[]>([]);
|
||||
const [equivalenceClasses, setEquivalenceClasses] = useState<Necklace[][]>([]);
|
||||
const [showFormula, setShowFormula] = useState(false);
|
||||
|
||||
const colorPalette = [
|
||||
'hsl(var(--primary))',
|
||||
'hsl(var(--accent))',
|
||||
'hsl(220, 90%, 56%)',
|
||||
'hsl(142, 76%, 36%)',
|
||||
'hsl(38, 92%, 50%)',
|
||||
'hsl(280, 70%, 60%)',
|
||||
];
|
||||
|
||||
// Generate all possible colorings
|
||||
const generateAllColorings = (beads: number, colors: number): Necklace[] => {
|
||||
const total = Math.pow(colors, beads);
|
||||
const necklaces: Necklace[] = [];
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const coloring: number[] = [];
|
||||
let num = i;
|
||||
for (let j = 0; j < beads; j++) {
|
||||
coloring.push(num % colors);
|
||||
num = Math.floor(num / colors);
|
||||
}
|
||||
necklaces.push({
|
||||
colors: coloring,
|
||||
id: coloring.join(',')
|
||||
});
|
||||
}
|
||||
|
||||
return necklaces;
|
||||
};
|
||||
|
||||
// Rotate a necklace by k positions
|
||||
const rotate = (colors: number[], k: number): number[] => {
|
||||
const n = colors.length;
|
||||
const rotated = [...colors];
|
||||
return rotated.slice(k % n).concat(rotated.slice(0, k % n));
|
||||
};
|
||||
|
||||
// Get canonical form (lexicographically smallest rotation)
|
||||
const getCanonical = (colors: number[]): string => {
|
||||
const n = colors.length;
|
||||
let minRotation = colors;
|
||||
|
||||
for (let i = 1; i < n; i++) {
|
||||
const rotated = rotate(colors, i);
|
||||
if (rotated.join(',') < minRotation.join(',')) {
|
||||
minRotation = rotated;
|
||||
}
|
||||
}
|
||||
|
||||
return minRotation.join(',');
|
||||
};
|
||||
|
||||
// Group necklaces into equivalence classes
|
||||
const groupByEquivalence = (necklaces: Necklace[]): Necklace[][] => {
|
||||
const canonicalMap = new Map<string, Necklace[]>();
|
||||
|
||||
for (const necklace of necklaces) {
|
||||
const canonical = getCanonical(necklace.colors);
|
||||
if (!canonicalMap.has(canonical)) {
|
||||
canonicalMap.set(canonical, []);
|
||||
}
|
||||
canonicalMap.get(canonical)!.push(necklace);
|
||||
}
|
||||
|
||||
return Array.from(canonicalMap.values());
|
||||
};
|
||||
|
||||
// Calculate using Burnside's Lemma
|
||||
const calculateBurnsideLemma = (): number => {
|
||||
let sum = 0;
|
||||
const n = numBeads;
|
||||
const k = numColors;
|
||||
|
||||
// For each rotation (including identity)
|
||||
for (let r = 0; r < n; r++) {
|
||||
// Count colorings fixed by this rotation
|
||||
const gcd = getGCD(n, r);
|
||||
sum += Math.pow(k, gcd);
|
||||
}
|
||||
|
||||
return sum / n;
|
||||
};
|
||||
|
||||
const getGCD = (a: number, b: number): number => {
|
||||
return b === 0 ? a : getGCD(b, a % b);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const necklaces = generateAllColorings(numBeads, numColors);
|
||||
setAllNecklaces(necklaces);
|
||||
const classes = groupByEquivalence(necklaces);
|
||||
setEquivalenceClasses(classes);
|
||||
}, [numBeads, numColors]);
|
||||
|
||||
const renderNecklace = (necklace: Necklace, size: 'small' | 'medium' = 'medium') => {
|
||||
const radius = size === 'small' ? 30 : 50;
|
||||
const beadRadius = size === 'small' ? 8 : 12;
|
||||
const svgSize = size === 'small' ? 80 : 130;
|
||||
const center = svgSize / 2;
|
||||
|
||||
return (
|
||||
<svg width={svgSize} height={svgSize} className="mx-auto">
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
{necklace.colors.map((color, i) => {
|
||||
const angle = (2 * Math.PI * i) / necklace.colors.length - Math.PI / 2;
|
||||
const x = center + radius * Math.cos(angle);
|
||||
const y = center + radius * Math.sin(angle);
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={beadRadius}
|
||||
fill={colorPalette[color]}
|
||||
stroke="hsl(var(--foreground))"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const burnsideResult = calculateBurnsideLemma();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<RotateCw className="w-6 h-6" />
|
||||
Burnside's Lemma: Necklace Colorings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
How many distinct necklaces can you make with {numBeads} beads using {numColors} colors?
|
||||
Two necklaces are the same if one can be rotated to look like the other.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Controls */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Number of Beads</label>
|
||||
<Badge variant="secondary">{numBeads}</Badge>
|
||||
</div>
|
||||
<Slider
|
||||
value={[numBeads]}
|
||||
onValueChange={(value) => setNumBeads(value[0])}
|
||||
min={3}
|
||||
max={8}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Number of Colors
|
||||
</label>
|
||||
<Badge variant="secondary">{numColors}</Badge>
|
||||
</div>
|
||||
<Slider
|
||||
value={[numColors]}
|
||||
onValueChange={(value) => setNumColors(value[0])}
|
||||
min={2}
|
||||
max={4}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-surface/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold text-primary">{allNecklaces.length}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Total Colorings</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">({numColors}^{numBeads})</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold text-accent">{equivalenceClasses.length}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Distinct Necklaces</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">(under rotation)</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold text-foreground">{burnsideResult}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Burnside's Formula</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">matches exactly!</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Formula Explanation */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => setShowFormula(!showFormula)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{showFormula ? 'Hide' : 'Show'} Burnside's Lemma Formula
|
||||
</Button>
|
||||
|
||||
{showFormula && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Burnside's Lemma</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The number of distinct objects under group action equals the average number of objects fixed by each symmetry.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-background p-4 rounded-lg space-y-3">
|
||||
<div className="text-center font-mono text-sm">
|
||||
|X/G| = (1/|G|) × Σ |Fix(g)|
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div>• |X/G| = number of distinct necklaces</div>
|
||||
<div>• |G| = number of rotations = {numBeads}</div>
|
||||
<div>• |Fix(g)| = colorings unchanged by rotation g</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-sm">For rotation by k positions:</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A coloring is fixed if beads repeat every gcd(n,k) positions.
|
||||
Number of such colorings = {numColors}^gcd(n,k)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-background p-3 rounded-lg">
|
||||
<div className="text-xs font-mono space-y-1">
|
||||
{Array.from({ length: numBeads }, (_, i) => {
|
||||
const gcd = getGCD(numBeads, i);
|
||||
return (
|
||||
<div key={i}>
|
||||
Rotation by {i}: {numColors}^{gcd} = {Math.pow(numColors, gcd)} fixed colorings
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="border-t border-border pt-1 mt-2">
|
||||
Sum = {Array.from({ length: numBeads }, (_, i) => Math.pow(numColors, getGCD(numBeads, i))).reduce((a, b) => a + b, 0)}
|
||||
</div>
|
||||
<div className="font-semibold">
|
||||
Result = {Array.from({ length: numBeads }, (_, i) => Math.pow(numColors, getGCD(numBeads, i))).reduce((a, b) => a + b, 0)} / {numBeads} = {burnsideResult}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Equivalence Classes Display */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
All {equivalenceClasses.length} Distinct Necklaces
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each card shows one representative from each equivalence class.
|
||||
Necklaces in the same class can be rotated to match each other.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{equivalenceClasses.length <= 20 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{equivalenceClasses.map((eqClass, idx) => (
|
||||
<Card key={idx} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-4 pb-2">
|
||||
{renderNecklace(eqClass[0], 'medium')}
|
||||
<div className="text-center mt-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Class {idx + 1}
|
||||
</Badge>
|
||||
{eqClass.length > 1 && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{eqClass.length} rotations
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{equivalenceClasses.length > 20 && (
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<p className="text-muted-foreground">
|
||||
Too many distinct necklaces to display ({equivalenceClasses.length} total).
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Try reducing the number of beads or colors to see all distinct necklaces.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BurnsidesLemma;
|
||||
426
src/components/interactives/ChessboardRepaintPuzzle.tsx
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { RefreshCw, RotateCcw, BookOpen, Trophy, CheckCircle, ExternalLink } from 'lucide-react';
|
||||
|
||||
const ChessboardRepaintPuzzle = () => {
|
||||
// Initialize 8x8 chessboard with alternating colors
|
||||
const initializeBoard = (): boolean[][] => {
|
||||
const board: boolean[][] = [];
|
||||
for (let row = 0; row < 8; row++) {
|
||||
board[row] = [];
|
||||
for (let col = 0; col < 8; col++) {
|
||||
// true = black, false = white
|
||||
board[row][col] = (row + col) % 2 === 0;
|
||||
}
|
||||
}
|
||||
return board;
|
||||
};
|
||||
|
||||
const [board, setBoard] = useState<boolean[][]>(initializeBoard);
|
||||
const [moves, setMoves] = useState(0);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [showVictory, setShowVictory] = useState(false);
|
||||
|
||||
const resetPuzzle = () => {
|
||||
setBoard(initializeBoard());
|
||||
setMoves(0);
|
||||
setIsComplete(false);
|
||||
setShowVictory(false);
|
||||
};
|
||||
|
||||
const countBlackSquares = (board: boolean[][]): number => {
|
||||
return board.flat().filter(square => square).length;
|
||||
};
|
||||
|
||||
const repaintRow = (rowIndex: number) => {
|
||||
const newBoard = board.map(row => [...row]);
|
||||
for (let col = 0; col < 8; col++) {
|
||||
newBoard[rowIndex][col] = !newBoard[rowIndex][col];
|
||||
}
|
||||
setBoard(newBoard);
|
||||
setMoves(moves + 1);
|
||||
|
||||
const blackCount = countBlackSquares(newBoard);
|
||||
if (blackCount === 1) {
|
||||
setIsComplete(true);
|
||||
setShowVictory(true);
|
||||
}
|
||||
};
|
||||
|
||||
const repaintColumn = (colIndex: number) => {
|
||||
const newBoard = board.map(row => [...row]);
|
||||
for (let row = 0; row < 8; row++) {
|
||||
newBoard[row][colIndex] = !newBoard[row][colIndex];
|
||||
}
|
||||
setBoard(newBoard);
|
||||
setMoves(moves + 1);
|
||||
|
||||
const blackCount = countBlackSquares(newBoard);
|
||||
if (blackCount === 1) {
|
||||
setIsComplete(true);
|
||||
setShowVictory(true);
|
||||
}
|
||||
};
|
||||
|
||||
const repaint2x2Square = (startRow: number, startCol: number) => {
|
||||
const newBoard = board.map(row => [...row]);
|
||||
for (let row = startRow; row < startRow + 2 && row < 8; row++) {
|
||||
for (let col = startCol; col < startCol + 2 && col < 8; col++) {
|
||||
newBoard[row][col] = !newBoard[row][col];
|
||||
}
|
||||
}
|
||||
setBoard(newBoard);
|
||||
setMoves(moves + 1);
|
||||
|
||||
const blackCount = countBlackSquares(newBoard);
|
||||
if (blackCount === 1) {
|
||||
setIsComplete(true);
|
||||
setShowVictory(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSquareClick = (row: number, col: number) => {
|
||||
if (isComplete) return;
|
||||
|
||||
// Special case for bottom-right corner
|
||||
if (row === 7 && col === 7) {
|
||||
if (bottomRightMode === 'row') {
|
||||
repaintRow(7);
|
||||
} else {
|
||||
repaintColumn(7);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If clicking on the rightmost column, repaint the entire row
|
||||
if (col === 7) {
|
||||
repaintRow(row);
|
||||
return;
|
||||
}
|
||||
|
||||
// If clicking on the bottom row, repaint the entire column
|
||||
if (row === 7) {
|
||||
repaintColumn(col);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, repaint the 2x2 square with this square as top-left
|
||||
repaint2x2Square(row, col);
|
||||
};
|
||||
|
||||
const handleBottomRightHover = () => {
|
||||
// Clear any existing timer
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer);
|
||||
}
|
||||
|
||||
// Set a new timer to switch mode after 3 seconds
|
||||
const timer = setTimeout(() => {
|
||||
setBottomRightMode(prev => prev === 'row' ? 'column' : 'row');
|
||||
}, 3000);
|
||||
|
||||
setHoverTimer(timer);
|
||||
};
|
||||
|
||||
const handleBottomRightLeave = () => {
|
||||
// Clear the timer when leaving the square
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer);
|
||||
setHoverTimer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const [hoveredSquare, setHoveredSquare] = useState<{row: number, col: number} | null>(null);
|
||||
const [bottomRightMode, setBottomRightMode] = useState<'row' | 'column'>('row');
|
||||
const [hoverTimer, setHoverTimer] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getAffectedSquares = (row: number, col: number) => {
|
||||
const squares: {row: number, col: number}[] = [];
|
||||
|
||||
// Special case for bottom-right corner
|
||||
if (row === 7 && col === 7) {
|
||||
if (bottomRightMode === 'row') {
|
||||
for (let c = 0; c < 8; c++) {
|
||||
squares.push({row: 7, col: c});
|
||||
}
|
||||
} else {
|
||||
for (let r = 0; r < 8; r++) {
|
||||
squares.push({row: r, col: 7});
|
||||
}
|
||||
}
|
||||
return squares;
|
||||
}
|
||||
|
||||
// If clicking on the rightmost column, repaint the entire row
|
||||
if (col === 7) {
|
||||
for (let c = 0; c < 8; c++) {
|
||||
squares.push({row, col: c});
|
||||
}
|
||||
return squares;
|
||||
}
|
||||
|
||||
// If clicking on the bottom row, repaint the entire column
|
||||
if (row === 7) {
|
||||
for (let r = 0; r < 8; r++) {
|
||||
squares.push({row: r, col});
|
||||
}
|
||||
return squares;
|
||||
}
|
||||
|
||||
// Otherwise, repaint the 2x2 square with this square as top-left
|
||||
for (let r = row; r < row + 2 && r < 8; r++) {
|
||||
for (let c = col; c < col + 2 && c < 8; c++) {
|
||||
squares.push({row: r, col: c});
|
||||
}
|
||||
}
|
||||
return squares;
|
||||
};
|
||||
|
||||
const getBoundingBox = (squares: {row: number, col: number}[]) => {
|
||||
if (squares.length === 0) return null;
|
||||
|
||||
const minRow = Math.min(...squares.map(s => s.row));
|
||||
const maxRow = Math.max(...squares.map(s => s.row));
|
||||
const minCol = Math.min(...squares.map(s => s.col));
|
||||
const maxCol = Math.max(...squares.map(s => s.col));
|
||||
|
||||
return { minRow, maxRow, minCol, maxCol };
|
||||
};
|
||||
|
||||
const renderSquare = (row: number, col: number) => {
|
||||
const isBlack = board[row][col];
|
||||
const isBottomRow = row === 7;
|
||||
const isRightmostCol = col === 7;
|
||||
const isBottomRight = row === 7 && col === 7;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${row}-${col}`}
|
||||
onClick={() => handleSquareClick(row, col)}
|
||||
onMouseEnter={() => {
|
||||
setHoveredSquare({row, col});
|
||||
if (isBottomRight) {
|
||||
handleBottomRightHover();
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredSquare(null);
|
||||
if (isBottomRight) {
|
||||
handleBottomRightLeave();
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
w-12 h-12 border border-gray-300 flex items-center justify-center cursor-pointer
|
||||
transition-all duration-200 hover:scale-105
|
||||
${isBlack ? 'bg-gray-800' : 'bg-gray-100'}
|
||||
`}
|
||||
title={isBottomRight ? `Hover for 3s to switch mode (current: ${bottomRightMode})` : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="text-center pb-6">
|
||||
<CardTitle className="flex items-center justify-center gap-3 text-3xl">
|
||||
<RefreshCw className="w-8 h-8 text-blue-500" />
|
||||
Chessboard Repaint Puzzle
|
||||
<RefreshCw className="w-8 h-8 text-blue-500" />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg max-w-2xl mx-auto">
|
||||
Repaint rows, columns, or 2×2 squares to achieve exactly one black square!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
{/* Game Controls */}
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
<Button onClick={() => setShowRules(true)} variant="outline" size="lg" className="px-8">
|
||||
<BookOpen className="w-4 h-4 mr-2" />
|
||||
Rules
|
||||
</Button>
|
||||
<Button onClick={resetPuzzle} variant="outline" size="lg" className="px-8">
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reset Puzzle
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-lg">Moves:</span>
|
||||
<Badge className="text-lg px-4 py-2 bg-blue-600 text-white">
|
||||
{moves}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-lg">Black Squares:</span>
|
||||
<Badge className={`text-lg px-4 py-2 ${countBlackSquares(board) === 1 ? 'bg-green-600' : 'bg-gray-600'} text-white`}>
|
||||
{countBlackSquares(board)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chessboard */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
{/* Chessboard grid */}
|
||||
<div className="grid grid-cols-8 gap-0 border-2 border-gray-400">
|
||||
{board.map((row, rowIndex) =>
|
||||
row.map((square, colIndex) => renderSquare(rowIndex, colIndex))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bounding rectangle overlay */}
|
||||
{hoveredSquare && (() => {
|
||||
const boundingBox = getBoundingBox(getAffectedSquares(hoveredSquare.row, hoveredSquare.col));
|
||||
if (!boundingBox) return null;
|
||||
|
||||
const squareSize = 48; // w-12 = 48px
|
||||
const left = boundingBox.minCol * squareSize;
|
||||
const top = boundingBox.minRow * squareSize;
|
||||
const width = (boundingBox.maxCol - boundingBox.minCol + 1) * squareSize;
|
||||
const height = (boundingBox.maxRow - boundingBox.minRow + 1) * squareSize;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none border-2 border-red-500 bg-red-500 bg-opacity-10"
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-center mt-8">
|
||||
<div className="inline-block bg-gray-50 px-6 py-3 rounded-lg border">
|
||||
<p className="text-sm text-gray-700 font-medium">
|
||||
{isComplete ? (
|
||||
"🎉 Congratulations! You achieved exactly one black square!"
|
||||
) : (
|
||||
"Click any square to repaint the 2×2 region with that square as top-left. Click rightmost column to repaint entire row, or bottom row to repaint entire column. Bottom-right corner switches between row/column mode on 3s hover. Hover to preview affected squares!"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-3 mt-8">
|
||||
<h3 className="font-semibold text-lg text-center">Legend</h3>
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-gray-100 border border-gray-300"></div>
|
||||
<span>White square</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-gray-800 border border-gray-300"></div>
|
||||
<span>Black square</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-red-500"></div>
|
||||
<span>Hover to preview affected squares</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rules Dialog */}
|
||||
<Dialog open={showRules} onOpenChange={setShowRules}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
Chessboard Repaint Puzzle Rules
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-lg">Objective</h4>
|
||||
<p className="text-gray-700">You start with a standard 8×8 chessboard with alternating black and white squares. Repaint the chessboard to achieve exactly one black square.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-lg">How to Play</h4>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-4 text-gray-700">
|
||||
<li><strong>Repaint a 2×2 square:</strong> Click any square to flip all four squares in the 2×2 region with that square as the top-left corner.</li>
|
||||
<li><strong>Repaint a row:</strong> Click any square in the rightmost column to flip all squares in that entire row.</li>
|
||||
<li><strong>Repaint a column:</strong> Click any square in the bottom row to flip all squares in that entire column.</li>
|
||||
<li><strong>Bottom-right corner:</strong> Hover for 3 seconds to switch between row and column mode.</li>
|
||||
<li><strong>Preview:</strong> Hover over any square to see which squares will be affected.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-lg">Rules</h4>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4 text-gray-700">
|
||||
<li>You can repaint any row, any column, or any 2×2 square; each click counts as one move.</li>
|
||||
<li>The puzzle is solved when exactly one square is black.</li>
|
||||
<li>Try to solve it in as few moves as possible!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-lg">Strategy Tips</h4>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4 text-gray-700">
|
||||
<li>Think about parity - how do different operations affect the total number of black squares?</li>
|
||||
<li>Consider the effect of each operation on the overall pattern</li>
|
||||
<li>Remember that flipping a row or column affects 8 squares, while flipping a 2×2 affects 4 squares</li>
|
||||
<li>Look for patterns in how the operations interact with each other</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Victory Dialog */}
|
||||
<Dialog open={showVictory} onOpenChange={setShowVictory}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-center gap-2">
|
||||
<Trophy className="w-6 h-6 text-yellow-500" />
|
||||
Puzzle Solved!
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<CheckCircle className="w-10 h-10 text-green-500" />
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
Congratulations!
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 text-lg">
|
||||
You solved the puzzle in {moves} moves!
|
||||
</p>
|
||||
{moves > 5 && (
|
||||
<p className="text-blue-600 font-medium">
|
||||
Can you solve it in fewer moves?
|
||||
</p>
|
||||
)}
|
||||
<Button onClick={resetPuzzle} className="w-full" size="lg">
|
||||
Play Again
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Attribution */}
|
||||
<div className="pt-4 border-t border-outline">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
⚠️ Spoiler: This solution to this puzzle leverages a parity-based invariant.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Share Section */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChessboardRepaintPuzzle;
|
||||
562
src/components/interactives/CrapsGame.tsx
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Dice1, Dice2, Dice3, Dice4, Dice5, Dice6, RotateCcw, Settings, Play } from 'lucide-react';
|
||||
interface CrapsGameProps {
|
||||
}
|
||||
const getDiceIcon = (value: number) => {
|
||||
switch (value) {
|
||||
case 1:
|
||||
return <Dice1 className="w-12 h-12" />;
|
||||
case 2:
|
||||
return <Dice2 className="w-12 h-12" />;
|
||||
case 3:
|
||||
return <Dice3 className="w-12 h-12" />;
|
||||
case 4:
|
||||
return <Dice4 className="w-12 h-12" />;
|
||||
case 5:
|
||||
return <Dice5 className="w-12 h-12" />;
|
||||
case 6:
|
||||
return <Dice6 className="w-12 h-12" />;
|
||||
default:
|
||||
return <Dice1 className="w-12 h-12" />;
|
||||
}
|
||||
};
|
||||
const CrapsGame: React.FC<CrapsGameProps> = () => {
|
||||
const [dice1, setDice1] = useState<number | null>(null);
|
||||
const [dice2, setDice2] = useState<number | null>(null);
|
||||
const [isRolling, setIsRolling] = useState(false);
|
||||
const [gameResult, setGameResult] = useState<'win' | 'lose' | 'continue' | null>(null);
|
||||
const [rollCount, setRollCount] = useState(0);
|
||||
|
||||
// Custom rules state
|
||||
const [customWinSums, setCustomWinSums] = useState<number[]>([7, 11]);
|
||||
const [customLoseSums, setCustomLoseSums] = useState<number[]>([2, 3, 12]);
|
||||
const [isCustomRules, setIsCustomRules] = useState(false);
|
||||
const [isRulesModalOpen, setIsRulesModalOpen] = useState(false);
|
||||
|
||||
// Simulation state
|
||||
const [simulationRounds, setSimulationRounds] = useState([10]);
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
const [slowMode, setSlowMode] = useState(false);
|
||||
const [currentSimulationGame, setCurrentSimulationGame] = useState(0);
|
||||
const [simulationResults, setSimulationResults] = useState<{
|
||||
wins: number;
|
||||
losses: number;
|
||||
averageTurns: number;
|
||||
totalGames: number;
|
||||
} | null>(null);
|
||||
const [liveSimulationStats, setLiveSimulationStats] = useState<{
|
||||
wins: number;
|
||||
losses: number;
|
||||
totalTurns: number;
|
||||
completedGames: number;
|
||||
} | null>(null);
|
||||
const rollDice = () => {
|
||||
setIsRolling(true);
|
||||
setGameResult(null);
|
||||
|
||||
// Simulate rolling animation
|
||||
setTimeout(() => {
|
||||
const newDice1 = Math.floor(Math.random() * 6) + 1;
|
||||
const newDice2 = Math.floor(Math.random() * 6) + 1;
|
||||
const sum = newDice1 + newDice2;
|
||||
setDice1(newDice1);
|
||||
setDice2(newDice2);
|
||||
setRollCount(prev => prev + 1);
|
||||
|
||||
// Determine game result based on current rules
|
||||
const winSums = isCustomRules ? customWinSums : [7, 11];
|
||||
const loseSums = isCustomRules ? customLoseSums : [2, 3, 12];
|
||||
if (loseSums.includes(sum)) {
|
||||
setGameResult('lose');
|
||||
} else if (winSums.includes(sum)) {
|
||||
setGameResult('win');
|
||||
} else {
|
||||
setGameResult('continue');
|
||||
}
|
||||
setIsRolling(false);
|
||||
}, 1000);
|
||||
};
|
||||
const simulateGame = () => {
|
||||
const winSums = isCustomRules ? customWinSums : [7, 11];
|
||||
const loseSums = isCustomRules ? customLoseSums : [2, 3, 12];
|
||||
let turns = 1;
|
||||
let point: number | null = null;
|
||||
while (true) {
|
||||
const die1 = Math.floor(Math.random() * 6) + 1;
|
||||
const die2 = Math.floor(Math.random() * 6) + 1;
|
||||
const sum = die1 + die2;
|
||||
if (point === null) {
|
||||
// First roll (come out roll)
|
||||
if (loseSums.includes(sum)) {
|
||||
return {
|
||||
result: 'lose',
|
||||
turns,
|
||||
die1,
|
||||
die2,
|
||||
sum
|
||||
};
|
||||
} else if (winSums.includes(sum)) {
|
||||
return {
|
||||
result: 'win',
|
||||
turns,
|
||||
die1,
|
||||
die2,
|
||||
sum
|
||||
};
|
||||
} else {
|
||||
point = sum;
|
||||
turns++;
|
||||
}
|
||||
} else {
|
||||
// Subsequent rolls
|
||||
if (sum === point) {
|
||||
return {
|
||||
result: 'win',
|
||||
turns,
|
||||
die1,
|
||||
die2,
|
||||
sum
|
||||
};
|
||||
} else if (sum === 7) {
|
||||
return {
|
||||
result: 'lose',
|
||||
turns,
|
||||
die1,
|
||||
die2,
|
||||
sum
|
||||
};
|
||||
} else {
|
||||
turns++;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const simulateGameSlow = async (gameNumber: number) => {
|
||||
const winSums = isCustomRules ? customWinSums : [7, 11];
|
||||
const loseSums = isCustomRules ? customLoseSums : [2, 3, 12];
|
||||
let turns = 1;
|
||||
let point: number | null = null;
|
||||
while (true) {
|
||||
// Show rolling animation
|
||||
setIsRolling(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
const die1 = Math.floor(Math.random() * 6) + 1;
|
||||
const die2 = Math.floor(Math.random() * 6) + 1;
|
||||
const sum = die1 + die2;
|
||||
|
||||
// Update dice display
|
||||
setDice1(die1);
|
||||
setDice2(die2);
|
||||
setIsRolling(false);
|
||||
|
||||
// Wait to show the result
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
if (point === null) {
|
||||
// First roll (come out roll)
|
||||
if (loseSums.includes(sum)) {
|
||||
setGameResult('lose');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return {
|
||||
result: 'lose',
|
||||
turns
|
||||
};
|
||||
} else if (winSums.includes(sum)) {
|
||||
setGameResult('win');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return {
|
||||
result: 'win',
|
||||
turns
|
||||
};
|
||||
} else {
|
||||
setGameResult('continue');
|
||||
point = sum;
|
||||
turns++;
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
}
|
||||
} else {
|
||||
// Subsequent rolls
|
||||
if (sum === point) {
|
||||
setGameResult('win');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return {
|
||||
result: 'win',
|
||||
turns
|
||||
};
|
||||
} else if (sum === 7) {
|
||||
setGameResult('lose');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return {
|
||||
result: 'lose',
|
||||
turns
|
||||
};
|
||||
} else {
|
||||
setGameResult('continue');
|
||||
turns++;
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const runSimulation = async () => {
|
||||
setIsSimulating(true);
|
||||
setCurrentSimulationGame(0);
|
||||
setLiveSimulationStats(null);
|
||||
const numGames = simulationRounds[0];
|
||||
let wins = 0;
|
||||
let losses = 0;
|
||||
let totalTurns = 0;
|
||||
if (slowMode) {
|
||||
// Initialize live stats for slow mode
|
||||
setLiveSimulationStats({
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
totalTurns: 0,
|
||||
completedGames: 0
|
||||
});
|
||||
|
||||
// Slow mode: simulate visually
|
||||
for (let i = 0; i < numGames; i++) {
|
||||
setCurrentSimulationGame(i + 1);
|
||||
setGameResult(null);
|
||||
const game = await simulateGameSlow(i + 1);
|
||||
if (game.result === 'win') wins++;else losses++;
|
||||
totalTurns += game.turns;
|
||||
|
||||
// Update live stats
|
||||
setLiveSimulationStats({
|
||||
wins,
|
||||
losses,
|
||||
totalTurns,
|
||||
completedGames: i + 1
|
||||
});
|
||||
|
||||
// Brief pause between games
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
} else {
|
||||
// Fast mode: simulate in background
|
||||
for (let i = 0; i < numGames; i++) {
|
||||
const game = simulateGame();
|
||||
if (game.result === 'win') wins++;else losses++;
|
||||
totalTurns += game.turns;
|
||||
|
||||
// Add a small delay every 10 games to show progress
|
||||
if (i % 10 === 0) {
|
||||
setCurrentSimulationGame(i + 1);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
setSimulationResults({
|
||||
wins,
|
||||
losses,
|
||||
averageTurns: totalTurns / numGames,
|
||||
totalGames: numGames
|
||||
});
|
||||
setCurrentSimulationGame(0);
|
||||
setLiveSimulationStats(null);
|
||||
setIsSimulating(false);
|
||||
};
|
||||
const resetGame = () => {
|
||||
setDice1(null);
|
||||
setDice2(null);
|
||||
setGameResult(null);
|
||||
setSimulationResults(null);
|
||||
};
|
||||
const sum = dice1 && dice2 ? dice1 + dice2 : null;
|
||||
const getResultMessage = () => {
|
||||
if (!gameResult) return null;
|
||||
switch (gameResult) {
|
||||
case 'win':
|
||||
return <div className="text-center space-y-2">
|
||||
<Badge variant="default" className="bg-green-600 text-white text-lg px-4 py-2">
|
||||
🎉 You Win! 🎉
|
||||
</Badge>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rolling {sum} on the first throw wins immediately!
|
||||
</p>
|
||||
</div>;
|
||||
case 'lose':
|
||||
return <div className="text-center space-y-2">
|
||||
<Badge variant="destructive" className="text-lg px-4 py-2">
|
||||
💸 You Lose! 💸
|
||||
</Badge>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rolling {sum} on the first throw loses immediately!
|
||||
</p>
|
||||
</div>;
|
||||
case 'continue':
|
||||
return <div className="text-center space-y-2">
|
||||
<Badge variant="secondary" className="text-lg px-4 py-2">
|
||||
🎲 Game Continues 🎲
|
||||
</Badge>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your point is {sum}. Keep playing to win or lose!
|
||||
</p>
|
||||
</div>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return <div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-3xl font-bold text-foreground">Craps: An Exploration</CardTitle>
|
||||
<CardDescription className="text-lg">In the game of Craps, we roll two dice, and note the sum S. In the traditional game, if S is 2, 3, or 12, you lose immediately; if the S is 7 or 11, you win immediately, and the game continues otherwise.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<p className="text-base text-center text-muted-foreground">
|
||||
On this page, you can: (a) play the classic game; (b) make your own win/lose conditions and play; (c) simulate the game over a number of rounds to note win/loss statistics and the average game duration. <br /><br />Note that the traditional game of craps continues with different rules after the first throw. Here we just roll until termination with respect to the original conditions.
|
||||
</p>
|
||||
{/* Simulation Controls */}
|
||||
<Card className="bg-accent/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Simulation</CardTitle>
|
||||
<CardDescription>
|
||||
Run multiple games to analyze win/loss statistics
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="slow-mode" className="text-sm font-medium">Slow Mode</label>
|
||||
<Switch id="slow-mode" checked={slowMode} onCheckedChange={setSlowMode} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Number of games: {simulationRounds[0]}</label>
|
||||
<Slider value={simulationRounds} onValueChange={setSimulationRounds} max={25} min={1} step={1} className="w-full" />
|
||||
</div>
|
||||
<Button onClick={runSimulation} disabled={isSimulating} className="w-full gap-2">
|
||||
<Play className="w-4 h-4" />
|
||||
{isSimulating ? slowMode ? `Simulating Game ${currentSimulationGame}/${simulationRounds[0]}...` : 'Simulating...' : 'Simulate'}
|
||||
</Button>
|
||||
|
||||
{(simulationResults || liveSimulationStats) && <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{liveSimulationStats ? liveSimulationStats.wins : simulationResults!.wins}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Wins</div>
|
||||
<div className="text-xs">
|
||||
{liveSimulationStats ? `(${(liveSimulationStats.wins / Math.max(liveSimulationStats.completedGames, 1) * 100).toFixed(1)}%)` : `(${(simulationResults!.wins / simulationResults!.totalGames * 100).toFixed(1)}%)`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{liveSimulationStats ? liveSimulationStats.losses : simulationResults!.losses}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Losses</div>
|
||||
<div className="text-xs">
|
||||
{liveSimulationStats ? `(${(liveSimulationStats.losses / Math.max(liveSimulationStats.completedGames, 1) * 100).toFixed(1)}%)` : `(${(simulationResults!.losses / simulationResults!.totalGames * 100).toFixed(1)}%)`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{liveSimulationStats ? (liveSimulationStats.totalTurns / Math.max(liveSimulationStats.completedGames, 1)).toFixed(1) : simulationResults!.averageTurns.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Avg Turns</div>
|
||||
<div className="text-xs">
|
||||
{liveSimulationStats ? `(${liveSimulationStats.completedGames} games)` : 'per game'}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rules */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-xl">
|
||||
{isCustomRules ? 'Custom Rules' : 'Classic Rules'}
|
||||
</CardTitle>
|
||||
<Dialog open={isRulesModalOpen} onOpenChange={setIsRulesModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Make Your Own Rules
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Customize Craps Rules</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which dice sums result in immediate wins or losses on the first throw.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-6 py-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4 text-red-600">Loss Sums</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Array.from({
|
||||
length: 11
|
||||
}, (_, i) => i + 2).map(sum => <div key={`loss-${sum}`} className="flex items-center space-x-2">
|
||||
<Checkbox id={`loss-${sum}`} checked={customLoseSums.includes(sum)} onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setCustomLoseSums([...customLoseSums.filter(s => s !== sum), sum]);
|
||||
setCustomWinSums(customWinSums.filter(s => s !== sum));
|
||||
} else {
|
||||
setCustomLoseSums(customLoseSums.filter(s => s !== sum));
|
||||
}
|
||||
}} />
|
||||
<label htmlFor={`loss-${sum}`} className="text-sm">{sum}</label>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4 text-green-600">Win Sums</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Array.from({
|
||||
length: 11
|
||||
}, (_, i) => i + 2).map(sum => <div key={`win-${sum}`} className="flex items-center space-x-2">
|
||||
<Checkbox id={`win-${sum}`} checked={customWinSums.includes(sum)} onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setCustomWinSums([...customWinSums.filter(s => s !== sum), sum]);
|
||||
setCustomLoseSums(customLoseSums.filter(s => s !== sum));
|
||||
} else {
|
||||
setCustomWinSums(customWinSums.filter(s => s !== sum));
|
||||
}
|
||||
}} />
|
||||
<label htmlFor={`win-${sum}`} className="text-sm">{sum}</label>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<Button variant="outline" onClick={() => {
|
||||
setCustomWinSums([7, 11]);
|
||||
setCustomLoseSums([2, 3, 12]);
|
||||
setIsCustomRules(false);
|
||||
}}>
|
||||
Reset to Classic
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
setIsCustomRules(true);
|
||||
setIsRulesModalOpen(false);
|
||||
resetGame();
|
||||
}}>
|
||||
Apply Custom Rules
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<Badge variant="destructive" className="mb-2">Immediate Loss</Badge>
|
||||
<p>{isCustomRules ? customLoseSums.length > 0 ? customLoseSums.join(', ') : 'None' : 'Rolling 2, 3, or 12'}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Badge variant="default" className="mb-2 bg-green-600">Immediate Win</Badge>
|
||||
<p>{isCustomRules ? customWinSums.length > 0 ? customWinSums.join(', ') : 'None' : 'Rolling 7 or 11'}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Badge variant="secondary" className="mb-2">Game Continues</Badge>
|
||||
<p>All other sums</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dice Display */}
|
||||
<div className="flex justify-center items-center gap-8">
|
||||
<div className="text-center space-y-2">
|
||||
<div className={`p-4 rounded-lg border-2 ${isRolling ? 'animate-spin' : ''} ${dice1 ? 'border-primary' : 'border-muted'}`}>
|
||||
{dice1 ? getDiceIcon(dice1) : <div className="w-12 h-12 bg-muted rounded border-2 border-dashed"></div>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Die 1</p>
|
||||
{dice1 && <p className="font-bold text-lg">{dice1}</p>}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">+</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className={`p-4 rounded-lg border-2 ${isRolling ? 'animate-spin' : ''} ${dice2 ? 'border-primary' : 'border-muted'}`}>
|
||||
{dice2 ? getDiceIcon(dice2) : <div className="w-12 h-12 bg-muted rounded border-2 border-dashed"></div>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Die 2</p>
|
||||
{dice2 && <p className="font-bold text-lg">{dice2}</p>}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">=</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className={`p-4 rounded-lg border-2 min-w-[80px] ${sum ? 'border-primary bg-primary/10' : 'border-muted'}`}>
|
||||
{sum ? <div className="text-3xl font-bold text-primary">{sum}</div> : <div className="w-12 h-12 bg-muted rounded border-2 border-dashed mx-auto"></div>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{getResultMessage()}
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">Rolls: {rollCount}</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button onClick={rollDice} disabled={isRolling || gameResult === 'win' || gameResult === 'lose'} className="px-8 py-3 text-lg">
|
||||
{isRolling ? 'Rolling...' : 'Roll Dice'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={resetGame} className="px-8 py-3">
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Educational Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">About Craps</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Craps is a classic casino dice game that demonstrates interesting probability concepts.
|
||||
The first throw (called the "come out roll") immediately determines the outcome in certain cases:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Losing Numbers (Craps)</h4>
|
||||
<ul className="space-y-1">
|
||||
<li>• 2 (Snake Eyes): 1/36 probability</li>
|
||||
<li>• 3: 2/36 probability</li>
|
||||
<li>• 12 (Boxcars): 1/36 probability</li>
|
||||
<li><strong>Total losing probability: 4/36 = 11.1%</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Winning Numbers (Natural)</h4>
|
||||
<ul className="space-y-1">
|
||||
<li>• 7: 6/36 probability</li>
|
||||
<li>• 11: 2/36 probability</li>
|
||||
<li><strong>Total winning probability: 8/36 = 22.2%</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If you roll 4, 5, 6, 8, 9, or 10, that becomes your "point" and the game continues with different rules.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
</div>;
|
||||
};
|
||||
export default CrapsGame;
|
||||
317
src/components/interactives/CubeColoring.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Box, RotateCw, ArrowRightLeft } from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface CubeColoringProps {
|
||||
shareUrl?: string;
|
||||
}
|
||||
|
||||
const colorPalette = [
|
||||
'#ef4444', // red
|
||||
'#3b82f6', // blue
|
||||
'#22c55e', // green
|
||||
'#eab308', // yellow
|
||||
'#a855f7', // purple
|
||||
];
|
||||
|
||||
// Face indices: 0=top, 1=bottom, 2=front, 3=back, 4=right, 5=left
|
||||
const faceNames = ['Top', 'Bottom', 'Front', 'Back', 'Right', 'Left'];
|
||||
|
||||
// Rotation definitions: each rotation maps face indices [0,1,2,3,4,5] to new positions
|
||||
// Also includes actual 3D rotation values for visualization
|
||||
const rotations = [
|
||||
{ name: 'Identity', permutation: [0, 1, 2, 3, 4, 5], description: 'No rotation', rotation: [0, 0, 0] },
|
||||
{ name: 'Y-90° (vertical)', permutation: [0, 1, 4, 5, 3, 2], description: 'Front→Right→Back→Left', rotation: [0, Math.PI / 2, 0] },
|
||||
{ name: 'Y-180°', permutation: [0, 1, 3, 2, 5, 4], description: 'Front↔Back, Right↔Left', rotation: [0, Math.PI, 0] },
|
||||
{ name: 'Y-270°', permutation: [0, 1, 5, 4, 2, 3], description: 'Front→Left→Back→Right', rotation: [0, -Math.PI / 2, 0] },
|
||||
{ name: 'X-90° (horizontal)', permutation: [2, 3, 1, 0, 4, 5], description: 'Top→Front→Bottom→Back', rotation: [Math.PI / 2, 0, 0] },
|
||||
{ name: 'X-180°', permutation: [1, 0, 3, 2, 4, 5], description: 'Top↔Bottom, Front↔Back', rotation: [Math.PI, 0, 0] },
|
||||
{ name: 'X-270°', permutation: [3, 2, 0, 1, 4, 5], description: 'Top→Back→Bottom→Front', rotation: [-Math.PI / 2, 0, 0] },
|
||||
{ name: 'Z-90° (front-back)', permutation: [4, 5, 2, 3, 1, 0], description: 'Top→Right→Bottom→Left', rotation: [0, 0, Math.PI / 2] },
|
||||
{ name: 'Z-180°', permutation: [1, 0, 2, 3, 5, 4], description: 'Top↔Bottom, Right↔Left', rotation: [0, 0, Math.PI] },
|
||||
{ name: 'Z-270°', permutation: [5, 4, 2, 3, 0, 1], description: 'Top→Left→Bottom→Right', rotation: [0, 0, -Math.PI / 2] },
|
||||
];
|
||||
|
||||
interface PaintableCubeProps {
|
||||
colors: string[];
|
||||
onFaceClick: (faceIndex: number) => void;
|
||||
}
|
||||
|
||||
function PaintableCube({ colors, onFaceClick }: PaintableCubeProps) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef}>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
{colors.map((color, i) => (
|
||||
<meshStandardMaterial key={i} attach={`material-${i}`} color={color} />
|
||||
))}
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function RotatedCube({ colors, rotation }: { colors: string[], rotation: number[] }) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} rotation={rotation as [number, number, number]}>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
{colors.map((color, i) => (
|
||||
<meshStandardMaterial key={i} attach={`material-${i}`} color={color} />
|
||||
))}
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
const CubeColoring = ({ shareUrl }: CubeColoringProps) => {
|
||||
const [faceColors, setFaceColors] = useState<string[]>(Array(6).fill(colorPalette[0]));
|
||||
const [selectedColor, setSelectedColor] = useState(colorPalette[0]);
|
||||
const [selectedPermutation, setSelectedPermutation] = useState<number[]>([0, 1, 2, 3, 4, 5]);
|
||||
const [selectedRotation, setSelectedRotation] = useState(0);
|
||||
|
||||
const handleFaceClick = (faceIndex: number) => {
|
||||
const newColors = [...faceColors];
|
||||
newColors[faceIndex] = selectedColor;
|
||||
setFaceColors(newColors);
|
||||
};
|
||||
|
||||
const handlePermutationChange = (position: number, value: string) => {
|
||||
const newPerm = [...selectedPermutation];
|
||||
newPerm[position] = parseInt(value);
|
||||
setSelectedPermutation(newPerm);
|
||||
};
|
||||
|
||||
const permutationToRotation = () => {
|
||||
const match = rotations.findIndex(r =>
|
||||
r.permutation.every((val, idx) => val === selectedPermutation[idx])
|
||||
);
|
||||
return match >= 0 ? rotations[match] : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Box className="w-6 h-6" />
|
||||
Burnside's Lemma: Cube Coloring
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
How many distinct cubes can you make by painting each face in one of 5 colors?
|
||||
Two cubes are the same if one can be rotated to look like the other.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
{/* 3D Cube Arena */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Paint Your Cube</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="h-[300px] border border-border rounded-lg bg-muted/20">
|
||||
<Canvas camera={{ position: [4, 4, 4] }}>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
<PaintableCube colors={faceColors} onFaceClick={handleFaceClick} />
|
||||
<OrbitControls enableZoom={true} />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Select Color</label>
|
||||
<div className="flex gap-2">
|
||||
{colorPalette.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={`w-12 h-12 rounded-lg border-2 transition-all ${
|
||||
selectedColor === color ? 'border-foreground scale-110' : 'border-border'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Face Colors</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{faceNames.map((name, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 bg-muted/50 rounded">
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-border"
|
||||
style={{ backgroundColor: faceColors[idx] }}
|
||||
/>
|
||||
<span className="text-sm">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permutation to Rotation */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ArrowRightLeft className="w-5 h-5" />
|
||||
Permutation → Rotation
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card className="bg-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pick a Permutation</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Choose where each face (0-5) goes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{selectedPermutation.map((value, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="w-20">
|
||||
Face {idx}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Select
|
||||
value={value.toString()}
|
||||
onValueChange={(v) => handlePermutationChange(idx, v)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[0, 1, 2, 3, 4, 5].map((n) => (
|
||||
<SelectItem key={n} value={n.toString()}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">({faceNames[idx]})</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Corresponding Rotation</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Visualize the rotation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{permutationToRotation() ? (
|
||||
<>
|
||||
<div className="text-center space-y-2">
|
||||
<Badge className="text-lg px-4 py-2">
|
||||
{permutationToRotation()!.name}
|
||||
</Badge>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{permutationToRotation()!.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-[200px] border border-border rounded-lg bg-background/50">
|
||||
<Canvas camera={{ position: [4, 4, 4] }}>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
<RotatedCube
|
||||
colors={faceColors}
|
||||
rotation={permutationToRotation()!.rotation}
|
||||
/>
|
||||
<OrbitControls enableZoom={true} />
|
||||
</Canvas>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>Invalid permutation</p>
|
||||
<p className="text-xs mt-1">Each number 0-5 must appear exactly once</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rotation to Permutation */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<RotateCw className="w-5 h-5" />
|
||||
Rotation → Permutation
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card className="bg-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Corresponding Permutation</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
How faces move with this rotation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{rotations[selectedRotation].permutation.map((target, source) => (
|
||||
<div key={source} className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="w-20">
|
||||
Face {source}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Badge className="w-20">Face {target}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{faceNames[source]} → {faceNames[target]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pick a Rotation</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Select a cube rotation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Select
|
||||
value={selectedRotation.toString()}
|
||||
onValueChange={(v) => setSelectedRotation(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rotations.map((rot, idx) => (
|
||||
<SelectItem key={idx} value={idx.toString()}>
|
||||
{rot.name} - {rot.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="h-[200px] border border-border rounded-lg bg-background/50">
|
||||
<Canvas camera={{ position: [4, 4, 4] }}>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
<RotatedCube
|
||||
colors={faceColors}
|
||||
rotation={rotations[selectedRotation].rotation}
|
||||
/>
|
||||
<OrbitControls enableZoom={true} />
|
||||
</Canvas>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CubeColoring;
|
||||
480
src/components/interactives/DominoRetilingPuzzle.tsx
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, ChevronDown, ChevronUp, Shuffle, Lightbulb } from 'lucide-react';
|
||||
|
||||
interface Domino {
|
||||
id: number;
|
||||
row: number;
|
||||
col: number;
|
||||
isHorizontal: boolean;
|
||||
}
|
||||
|
||||
interface DominoRetilingPuzzleProps {
|
||||
}
|
||||
|
||||
const DominoRetilingPuzzle: React.FC<DominoRetilingPuzzleProps> = () => {
|
||||
const [boardSize, setBoardSize] = useState(8);
|
||||
const [dominoes, setDominoes] = useState<Domino[]>([]);
|
||||
const [selectedDomino, setSelectedDomino] = useState<number | null>(null);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const [moveCount, setMoveCount] = useState(0);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [hoverPlacement, setHoverPlacement] = useState<{ row: number; col: number; isHorizontal: boolean } | null>(null);
|
||||
|
||||
const BOARD_ROWS = boardSize;
|
||||
const BOARD_COLS = boardSize + 1; // n + 1 extra square on top row
|
||||
|
||||
// Generate a random valid tiling of the n×n board using backtracking
|
||||
const generateRandomTiling = useCallback(() => {
|
||||
const newDominoes: Domino[] = [];
|
||||
const occupied = new Set<string>();
|
||||
let dominoId = 0;
|
||||
|
||||
// Helper to find first uncovered cell
|
||||
const findFirstUncovered = (): { row: number; col: number } | null => {
|
||||
for (let row = 0; row < boardSize; row++) {
|
||||
for (let col = 0; col < boardSize; col++) {
|
||||
if (!occupied.has(`${row},${col}`)) {
|
||||
return { row, col };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Greedy fill with backtracking if needed
|
||||
while (true) {
|
||||
const cell = findFirstUncovered();
|
||||
if (!cell) break; // All covered
|
||||
|
||||
const { row, col } = cell;
|
||||
|
||||
// Try to place a domino here
|
||||
const canHorizontal = col + 1 < boardSize && !occupied.has(`${row},${col + 1}`);
|
||||
const canVertical = row + 1 < boardSize && !occupied.has(`${row + 1},${col}`);
|
||||
|
||||
if (!canHorizontal && !canVertical) {
|
||||
// Stuck - this shouldn't happen with even board sizes, but reset if it does
|
||||
newDominoes.length = 0;
|
||||
occupied.clear();
|
||||
dominoId = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let isHorizontal: boolean;
|
||||
if (canHorizontal && canVertical) {
|
||||
isHorizontal = Math.random() > 0.5;
|
||||
} else {
|
||||
isHorizontal = canHorizontal;
|
||||
}
|
||||
|
||||
newDominoes.push({
|
||||
id: dominoId++,
|
||||
row,
|
||||
col,
|
||||
isHorizontal
|
||||
});
|
||||
|
||||
occupied.add(`${row},${col}`);
|
||||
if (isHorizontal) {
|
||||
occupied.add(`${row},${col + 1}`);
|
||||
} else {
|
||||
occupied.add(`${row + 1},${col}`);
|
||||
}
|
||||
}
|
||||
|
||||
setDominoes(newDominoes);
|
||||
setSelectedDomino(null);
|
||||
setMoveCount(0);
|
||||
setHoverPlacement(null);
|
||||
}, [boardSize]);
|
||||
|
||||
useEffect(() => {
|
||||
generateRandomTiling();
|
||||
}, [generateRandomTiling]);
|
||||
|
||||
// Get all cells occupied by dominoes
|
||||
const getOccupiedCells = useCallback((excludeDominoId?: number): Set<string> => {
|
||||
const occupied = new Set<string>();
|
||||
dominoes.forEach(d => {
|
||||
if (d.id === excludeDominoId) return;
|
||||
occupied.add(`${d.row},${d.col}`);
|
||||
if (d.isHorizontal) {
|
||||
occupied.add(`${d.row},${d.col + 1}`);
|
||||
} else {
|
||||
occupied.add(`${d.row + 1},${d.col}`);
|
||||
}
|
||||
});
|
||||
return occupied;
|
||||
}, [dominoes]);
|
||||
|
||||
// Check if a position is valid for the augmented board
|
||||
const isValidCell = useCallback((row: number, col: number): boolean => {
|
||||
if (row < 0 || row >= BOARD_ROWS) return false;
|
||||
if (row === 0) return col >= 0 && col < BOARD_COLS;
|
||||
return col >= 0 && col < boardSize;
|
||||
}, [BOARD_ROWS, BOARD_COLS, boardSize]);
|
||||
|
||||
// Check if we can place a domino at a position
|
||||
const canPlaceDomino = useCallback((row: number, col: number, isHorizontal: boolean, excludeDominoId: number): boolean => {
|
||||
const cell1Row = row;
|
||||
const cell1Col = col;
|
||||
const cell2Row = isHorizontal ? row : row + 1;
|
||||
const cell2Col = isHorizontal ? col + 1 : col;
|
||||
|
||||
if (!isValidCell(cell1Row, cell1Col) || !isValidCell(cell2Row, cell2Col)) return false;
|
||||
|
||||
const occupied = getOccupiedCells(excludeDominoId);
|
||||
return !occupied.has(`${cell1Row},${cell1Col}`) && !occupied.has(`${cell2Row},${cell2Col}`);
|
||||
}, [getOccupiedCells]);
|
||||
|
||||
// Handle clicking on a domino
|
||||
const handleDominoClick = (dominoId: number) => {
|
||||
if (selectedDomino === dominoId) {
|
||||
setSelectedDomino(null);
|
||||
setHoverPlacement(null);
|
||||
} else {
|
||||
setSelectedDomino(dominoId);
|
||||
setHoverPlacement(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clicking on a valid placement
|
||||
const handlePlacementClick = (row: number, col: number, isHorizontal: boolean) => {
|
||||
if (selectedDomino === null) return;
|
||||
|
||||
setDominoes(prev => prev.map(d =>
|
||||
d.id === selectedDomino
|
||||
? { ...d, row, col, isHorizontal }
|
||||
: d
|
||||
));
|
||||
setSelectedDomino(null);
|
||||
setHoverPlacement(null);
|
||||
setMoveCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// Handle cell hover
|
||||
const handleCellHover = (row: number, col: number) => {
|
||||
if (selectedDomino === null) return;
|
||||
|
||||
// Check all possible placements where this cell could be part of a domino
|
||||
// A domino can be placed with this cell as the first cell, or as the second cell
|
||||
|
||||
// Horizontal placements: domino at (row, col) or (row, col-1)
|
||||
const canH_start = canPlaceDomino(row, col, true, selectedDomino);
|
||||
const canH_end = col > 0 && canPlaceDomino(row, col - 1, true, selectedDomino);
|
||||
|
||||
// Vertical placements: domino at (row, col) or (row-1, col)
|
||||
const canV_start = canPlaceDomino(row, col, false, selectedDomino);
|
||||
const canV_end = row > 0 && canPlaceDomino(row - 1, col, false, selectedDomino);
|
||||
|
||||
// Prioritize: horizontal starting here, horizontal ending here, vertical starting here, vertical ending here
|
||||
if (canH_start) {
|
||||
setHoverPlacement({ row, col, isHorizontal: true });
|
||||
} else if (canH_end) {
|
||||
setHoverPlacement({ row, col: col - 1, isHorizontal: true });
|
||||
} else if (canV_start) {
|
||||
setHoverPlacement({ row, col, isHorizontal: false });
|
||||
} else if (canV_end) {
|
||||
setHoverPlacement({ row: row - 1, col, isHorizontal: false });
|
||||
} else {
|
||||
setHoverPlacement(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if puzzle is solved (all dominoes horizontal)
|
||||
const isSolved = dominoes.length > 0 && dominoes.every(d => d.isHorizontal);
|
||||
|
||||
// Get domino color based on orientation
|
||||
const getDominoColor = (isHorizontal: boolean): string => {
|
||||
if (isHorizontal) return 'bg-emerald-500 border-emerald-700';
|
||||
return 'bg-rose-400 border-rose-600';
|
||||
};
|
||||
|
||||
const cellSize = Math.max(32, Math.min(44, 480 / boardSize));
|
||||
|
||||
// Render the board
|
||||
const renderBoard = () => {
|
||||
const cells: JSX.Element[] = [];
|
||||
|
||||
// Render the inaccessible shaded region (last column except top cell)
|
||||
for (let row = 1; row < BOARD_ROWS; row++) {
|
||||
cells.push(
|
||||
<div
|
||||
key={`inaccessible-${row}`}
|
||||
className="border border-border/30"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: boardSize * cellSize,
|
||||
top: row * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
backgroundColor: 'rgba(100, 100, 100, 0.3)',
|
||||
backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(80, 80, 80, 0.4) 4px, rgba(80, 80, 80, 0.4) 8px)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render cells
|
||||
for (let row = 0; row < BOARD_ROWS; row++) {
|
||||
const maxCol = row === 0 ? BOARD_COLS : boardSize;
|
||||
for (let col = 0; col < maxCol; col++) {
|
||||
const isExtraSquare = row === 0 && col === boardSize;
|
||||
|
||||
const cellStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: col * cellSize,
|
||||
top: row * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
};
|
||||
|
||||
cells.push(
|
||||
<div
|
||||
key={`cell-${row}-${col}`}
|
||||
className={`border border-border/50 ${
|
||||
isExtraSquare
|
||||
? 'bg-blue-400/50'
|
||||
: (row + col) % 2 === 0 ? 'bg-muted/30' : 'bg-muted/60'
|
||||
} ${selectedDomino !== null ? 'cursor-pointer' : ''}`}
|
||||
style={cellStyle}
|
||||
onMouseEnter={() => handleCellHover(row, col)}
|
||||
onMouseLeave={() => setHoverPlacement(null)}
|
||||
onClick={() => {
|
||||
if (hoverPlacement && hoverPlacement.row === row && hoverPlacement.col === col) {
|
||||
handlePlacementClick(hoverPlacement.row, hoverPlacement.col, hoverPlacement.isHorizontal);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render dominoes (except selected one)
|
||||
dominoes.forEach(d => {
|
||||
if (d.id === selectedDomino) return; // Hide selected domino
|
||||
|
||||
const width = d.isHorizontal ? cellSize * 2 - 4 : cellSize - 4;
|
||||
const height = d.isHorizontal ? cellSize - 4 : cellSize * 2 - 4;
|
||||
|
||||
cells.push(
|
||||
<div
|
||||
key={`domino-${d.id}`}
|
||||
className={`absolute rounded border-2 cursor-pointer transition-all z-20 ${getDominoColor(d.isHorizontal)} hover:brightness-110`}
|
||||
style={{
|
||||
left: d.col * cellSize + 2,
|
||||
top: d.row * cellSize + 2,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
onClick={() => handleDominoClick(d.id)}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className={`w-2 h-2 rounded-full ${d.isHorizontal ? 'bg-emerald-700' : 'bg-rose-700'}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Render hover preview domino
|
||||
if (hoverPlacement && selectedDomino !== null) {
|
||||
const width = hoverPlacement.isHorizontal ? cellSize * 2 - 4 : cellSize - 4;
|
||||
const height = hoverPlacement.isHorizontal ? cellSize - 4 : cellSize * 2 - 4;
|
||||
|
||||
cells.push(
|
||||
<div
|
||||
key="hover-preview"
|
||||
className="absolute rounded border-2 cursor-pointer z-30 bg-amber-400 border-amber-600 opacity-80 hover:opacity-100 transition-opacity"
|
||||
style={{
|
||||
left: hoverPlacement.col * cellSize + 2,
|
||||
top: hoverPlacement.row * cellSize + 2,
|
||||
width,
|
||||
height,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-700" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return cells;
|
||||
};
|
||||
|
||||
const horizontalCount = dominoes.filter(d => d.isHorizontal).length;
|
||||
const verticalCount = dominoes.filter(d => !d.isHorizontal).length;
|
||||
|
||||
const numDominoes = (boardSize * boardSize) / 2;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<Card className="border-primary/20">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
|
||||
Domino Retiling Puzzle
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Can you retile the augmented board so all dominoes are horizontal?
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
A {boardSize}×{boardSize} board is tiled with {numDominoes} dominoes. An extra square is added to the top-right.
|
||||
Move dominoes one at a time to make all dominoes horizontal.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4 items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium">Board Size: {boardSize}×{boardSize}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="2"
|
||||
max="12"
|
||||
step="2"
|
||||
value={boardSize}
|
||||
onChange={(e) => setBoardSize(parseInt(e.target.value))}
|
||||
className="w-24 h-2 bg-muted rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={generateRandomTiling} variant="outline" size="sm">
|
||||
<Shuffle className="w-4 h-4 mr-2" />
|
||||
New Puzzle
|
||||
</Button>
|
||||
<Button onClick={() => setShowHint(!showHint)} variant="outline" size="sm">
|
||||
<Lightbulb className="w-4 h-4 mr-2" />
|
||||
{showHint ? 'Hide Hint' : 'Show Hint'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center items-center gap-4 flex-wrap">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
Moves: {moveCount}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm bg-emerald-100 text-emerald-800 border-emerald-300">
|
||||
Horizontal: {horizontalCount}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm bg-rose-100 text-rose-800 border-rose-300">
|
||||
Vertical: {verticalCount}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showHint && (
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb className="w-5 h-5 text-amber-600 mt-0.5" />
|
||||
<div className="text-sm text-amber-800">
|
||||
<p className="font-medium mb-1">Hint:</p>
|
||||
<p>Consider the coloring argument: in a standard chessboard coloring, each horizontal domino covers one black and one white square.
|
||||
But on the augmented board (8×8 + 1 extra square), the parity changes. Think about what this means for the possibility of an all-horizontal tiling!</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowInstructions(!showInstructions)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Instructions</CardTitle>
|
||||
{showInstructions ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showInstructions && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-4">
|
||||
<ul className="list-disc list-inside space-y-2 text-sm">
|
||||
<li>Click on a domino to pick it up (it disappears from the board)</li>
|
||||
<li>Hover over empty cells to see where you can place it</li>
|
||||
<li>Click to place the domino in the highlighted position</li>
|
||||
<li>You can only move a domino if there are two adjacent empty squares to receive it</li>
|
||||
<li>Goal: Make all {numDominoes} dominoes horizontal</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-emerald-500" />
|
||||
<span>Horizontal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-rose-400" />
|
||||
<span>Vertical</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-amber-400" />
|
||||
<span>Selected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Board */}
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="relative bg-background rounded-lg border border-border shadow-sm p-3"
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: BOARD_COLS * cellSize,
|
||||
height: BOARD_ROWS * cellSize,
|
||||
}}
|
||||
>
|
||||
{renderBoard()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedDomino !== null && (
|
||||
<p className="text-center text-sm text-muted-foreground mt-4">
|
||||
Domino picked up. Hover over empty cells to preview placement, then click to place.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Result */}
|
||||
{isSolved && (
|
||||
<Card className="border-green-200 bg-green-50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium text-green-800">
|
||||
Wait... You solved it? That shouldn't be possible!
|
||||
</p>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Actually, this puzzle is impossible to solve! The extra square creates a parity imbalance that prevents an all-horizontal tiling.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Social Share */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DominoRetilingPuzzle;
|
||||
893
src/components/interactives/ErdosDiscrepancyPuzzle.tsx
Normal file
|
|
@ -0,0 +1,893 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
Play, RotateCcw, Info, ChevronDown, ChevronUp,
|
||||
ArrowLeft, ArrowRight, Skull, AlertTriangle,
|
||||
BookOpen, Zap, Eye, Grid3X3, TrendingUp, Pause,
|
||||
Volume2, VolumeX, Target, Trophy
|
||||
} from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface ErdosDiscrepancyPuzzleProps {
|
||||
}
|
||||
|
||||
type GamePhase = 'intro' | 'writing' | 'captor-reveal' | 'walking' | 'fallen' | 'survived';
|
||||
type Direction = 1 | -1 | null;
|
||||
|
||||
interface DiscrepancyResult {
|
||||
d: number;
|
||||
k: number;
|
||||
sum: number;
|
||||
positions: number[];
|
||||
}
|
||||
|
||||
const ErdosDiscrepancyPuzzle: React.FC<ErdosDiscrepancyPuzzleProps> = () => {
|
||||
// Core game state
|
||||
const [sequence, setSequence] = useState<Direction[]>([]);
|
||||
const [discrepancyBound, setDiscrepancyBound] = useState(1);
|
||||
const [gamePhase, setGamePhase] = useState<GamePhase>('intro');
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
|
||||
// Walk visualization state
|
||||
const [selectedD, setSelectedD] = useState(1);
|
||||
const [walkPosition, setWalkPosition] = useState(0);
|
||||
const [walkStep, setWalkStep] = useState(0);
|
||||
const [isWalking, setIsWalking] = useState(false);
|
||||
const [walkSpeed, setWalkSpeed] = useState(500);
|
||||
|
||||
// Prisoner's Walk story mode
|
||||
const [captorChosenD, setCaptorChosenD] = useState<number | null>(null);
|
||||
const [prisonerPosition, setPrisonerPosition] = useState(0);
|
||||
const [prisonerStep, setPrisonerStep] = useState(0);
|
||||
|
||||
// Heatmap state
|
||||
const [hoveredCell, setHoveredCell] = useState<{d: number, k: number} | null>(null);
|
||||
const [highlightedSubsequence, setHighlightedSubsequence] = useState<number[]>([]);
|
||||
|
||||
// Best score tracking
|
||||
const [bestLength, setBestLength] = useState<number | null>(null);
|
||||
|
||||
// Load best score from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(`erdos-best-length-c${discrepancyBound}`);
|
||||
if (saved) {
|
||||
setBestLength(parseInt(saved));
|
||||
}
|
||||
}, [discrepancyBound]);
|
||||
|
||||
// Calculate all discrepancies for current sequence
|
||||
const allDiscrepancies = useMemo((): DiscrepancyResult[] => {
|
||||
if (sequence.length === 0) return [];
|
||||
|
||||
const results: DiscrepancyResult[] = [];
|
||||
const n = sequence.length;
|
||||
|
||||
for (let d = 1; d <= n; d++) {
|
||||
let sum = 0;
|
||||
const positions: number[] = [];
|
||||
|
||||
for (let k = 1; k * d <= n; k++) {
|
||||
const pos = k * d - 1; // 0-indexed
|
||||
if (sequence[pos] !== null) {
|
||||
sum += sequence[pos]!;
|
||||
positions.push(k * d);
|
||||
results.push({
|
||||
d,
|
||||
k,
|
||||
sum,
|
||||
positions: [...positions]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [sequence]);
|
||||
|
||||
// Find maximum discrepancy
|
||||
const maxDiscrepancy = useMemo(() => {
|
||||
if (allDiscrepancies.length === 0) return { value: 0, result: null };
|
||||
|
||||
let maxAbs = 0;
|
||||
let maxResult: DiscrepancyResult | null = null;
|
||||
|
||||
for (const result of allDiscrepancies) {
|
||||
if (Math.abs(result.sum) > maxAbs) {
|
||||
maxAbs = Math.abs(result.sum);
|
||||
maxResult = result;
|
||||
}
|
||||
}
|
||||
|
||||
return { value: maxAbs, result: maxResult };
|
||||
}, [allDiscrepancies]);
|
||||
|
||||
// Check if current sequence violates bound
|
||||
const hasViolation = maxDiscrepancy.value > discrepancyBound;
|
||||
|
||||
// Find worst step size for captor
|
||||
const findWorstD = useCallback((): number => {
|
||||
let worstD = 1;
|
||||
let worstDiscrepancy = 0;
|
||||
|
||||
for (const result of allDiscrepancies) {
|
||||
if (Math.abs(result.sum) > worstDiscrepancy) {
|
||||
worstDiscrepancy = Math.abs(result.sum);
|
||||
worstD = result.d;
|
||||
}
|
||||
}
|
||||
|
||||
return worstD;
|
||||
}, [allDiscrepancies]);
|
||||
|
||||
// Get discrepancy for specific d,k
|
||||
const getDiscrepancy = (d: number, k: number): number | null => {
|
||||
const result = allDiscrepancies.find(r => r.d === d && r.k === k);
|
||||
return result ? result.sum : null;
|
||||
};
|
||||
|
||||
// Toggle sequence value
|
||||
const togglePosition = (index: number) => {
|
||||
if (gamePhase !== 'writing') return;
|
||||
|
||||
setSequence(prev => {
|
||||
const newSeq = [...prev];
|
||||
if (newSeq[index] === null) {
|
||||
newSeq[index] = 1;
|
||||
} else if (newSeq[index] === 1) {
|
||||
newSeq[index] = -1;
|
||||
} else {
|
||||
newSeq[index] = 1;
|
||||
}
|
||||
return newSeq;
|
||||
});
|
||||
};
|
||||
|
||||
// Add new position to sequence
|
||||
const addPosition = () => {
|
||||
if (gamePhase !== 'writing') return;
|
||||
setSequence(prev => [...prev, null]);
|
||||
};
|
||||
|
||||
// Start the game
|
||||
const startGame = () => {
|
||||
setSequence([null, null, null, null, null]);
|
||||
setGamePhase('writing');
|
||||
setWalkPosition(0);
|
||||
setWalkStep(0);
|
||||
setPrisonerPosition(0);
|
||||
setPrisonerStep(0);
|
||||
setCaptorChosenD(null);
|
||||
setHighlightedSubsequence([]);
|
||||
};
|
||||
|
||||
// Reset the game
|
||||
const resetGame = () => {
|
||||
setSequence([]);
|
||||
setGamePhase('intro');
|
||||
setWalkPosition(0);
|
||||
setWalkStep(0);
|
||||
setIsWalking(false);
|
||||
setPrisonerPosition(0);
|
||||
setPrisonerStep(0);
|
||||
setCaptorChosenD(null);
|
||||
setHighlightedSubsequence([]);
|
||||
};
|
||||
|
||||
// Handle captor reveal (Prisoner's Walk mode)
|
||||
const revealCaptor = () => {
|
||||
const worstD = findWorstD();
|
||||
setCaptorChosenD(worstD);
|
||||
setGamePhase('captor-reveal');
|
||||
setPrisonerPosition(0);
|
||||
setPrisonerStep(0);
|
||||
};
|
||||
|
||||
// Start prisoner walking
|
||||
const startPrisonerWalk = () => {
|
||||
if (captorChosenD === null) return;
|
||||
setGamePhase('walking');
|
||||
setPrisonerPosition(0);
|
||||
setPrisonerStep(0);
|
||||
};
|
||||
|
||||
// Prisoner walk animation
|
||||
useEffect(() => {
|
||||
if (gamePhase !== 'walking' || captorChosenD === null) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setPrisonerStep(prev => {
|
||||
const nextStep = prev + 1;
|
||||
const posIndex = nextStep * captorChosenD - 1;
|
||||
|
||||
if (posIndex >= sequence.length || sequence[posIndex] === null) {
|
||||
// Sequence ended without falling
|
||||
setGamePhase('survived');
|
||||
clearInterval(interval);
|
||||
|
||||
// Save best score
|
||||
const validLength = sequence.filter(s => s !== null).length;
|
||||
if (!bestLength || validLength > bestLength) {
|
||||
setBestLength(validLength);
|
||||
localStorage.setItem(`erdos-best-length-c${discrepancyBound}`, validLength.toString());
|
||||
}
|
||||
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newPosition = prisonerPosition + sequence[posIndex]!;
|
||||
setPrisonerPosition(newPosition);
|
||||
|
||||
if (Math.abs(newPosition) > discrepancyBound) {
|
||||
setGamePhase('fallen');
|
||||
clearInterval(interval);
|
||||
return nextStep;
|
||||
}
|
||||
|
||||
return nextStep;
|
||||
});
|
||||
}, walkSpeed);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [gamePhase, captorChosenD, sequence, prisonerPosition, discrepancyBound, walkSpeed, bestLength]);
|
||||
|
||||
// Manual walk control
|
||||
const walkSubsequence = () => {
|
||||
if (isWalking) {
|
||||
setIsWalking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setWalkPosition(0);
|
||||
setWalkStep(0);
|
||||
setIsWalking(true);
|
||||
};
|
||||
|
||||
// Walk animation effect
|
||||
useEffect(() => {
|
||||
if (!isWalking) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setWalkStep(prev => {
|
||||
const nextStep = prev + 1;
|
||||
const posIndex = nextStep * selectedD - 1;
|
||||
|
||||
if (posIndex >= sequence.length || sequence[posIndex] === null) {
|
||||
setIsWalking(false);
|
||||
return prev;
|
||||
}
|
||||
|
||||
setWalkPosition(current => current + sequence[posIndex]!);
|
||||
return nextStep;
|
||||
});
|
||||
}, walkSpeed);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isWalking, selectedD, sequence, walkSpeed]);
|
||||
|
||||
// Get color for heatmap cell
|
||||
const getHeatmapColor = (discrepancy: number | null): string => {
|
||||
if (discrepancy === null) return 'bg-muted/30';
|
||||
|
||||
const absVal = Math.abs(discrepancy);
|
||||
if (absVal > discrepancyBound) {
|
||||
return 'bg-destructive/80 text-destructive-foreground';
|
||||
}
|
||||
if (absVal === discrepancyBound) {
|
||||
return 'bg-amber-500/70 text-white';
|
||||
}
|
||||
if (absVal === 0) {
|
||||
return 'bg-emerald-500/50 text-emerald-900 dark:text-emerald-100';
|
||||
}
|
||||
|
||||
const intensity = absVal / discrepancyBound;
|
||||
if (intensity < 0.5) {
|
||||
return 'bg-emerald-400/40 text-emerald-900 dark:text-emerald-100';
|
||||
}
|
||||
return 'bg-amber-400/50 text-amber-900 dark:text-amber-100';
|
||||
};
|
||||
|
||||
// Max valid k for given d
|
||||
const maxK = (d: number) => Math.floor(sequence.length / d);
|
||||
|
||||
// Highlighted positions for selected d
|
||||
const selectedPositions = useMemo(() => {
|
||||
const positions: number[] = [];
|
||||
for (let k = 1; k * selectedD <= sequence.length; k++) {
|
||||
positions.push(k * selectedD);
|
||||
}
|
||||
return positions;
|
||||
}, [selectedD, sequence.length]);
|
||||
|
||||
// Get fill status of sequence
|
||||
const filledCount = sequence.filter(s => s !== null).length;
|
||||
const allFilled = filledCount === sequence.length && sequence.length > 0;
|
||||
|
||||
// Known optimal sequences
|
||||
const optimalSequences: Record<number, Direction[]> = {
|
||||
1: [1, 1, -1, 1, -1, -1, -1, 1, 1, 1, -1], // One of the optimal 11-length sequences for C=1
|
||||
};
|
||||
|
||||
const loadOptimalSequence = () => {
|
||||
const optimal = optimalSequences[discrepancyBound];
|
||||
if (optimal) {
|
||||
setSequence([...optimal]);
|
||||
setGamePhase('writing');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">
|
||||
The Erdős Discrepancy Problem
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Can you write instructions to survive the tunnel forever?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRules(!showRules)}
|
||||
>
|
||||
{showRules ? <ChevronUp className="w-4 h-4 mr-1" /> : <Info className="w-4 h-4 mr-1" />}
|
||||
{showRules ? 'Hide' : 'Rules'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules Panel */}
|
||||
{showRules && (
|
||||
<Card className="bg-muted/30">
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<Skull className="w-4 h-4" /> The Prisoner's Dilemma
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
You're trapped in a tunnel with a <span className="text-destructive font-semibold">deadly cliff {discrepancyBound} step{discrepancyBound > 1 ? 's' : ''} to your LEFT</span> and
|
||||
a <span className="text-destructive font-semibold">pit of vipers {discrepancyBound} step{discrepancyBound > 1 ? 's' : ''} to your RIGHT</span>.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Write a list of instructions: each is either <span className="text-primary font-semibold">LEFT</span> or <span className="text-blue-500 font-semibold">RIGHT</span>.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The evil captor then picks a step size <strong>d</strong> and you follow every d-th instruction.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" /> The Impossible Task
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
<strong>C = 1:</strong> Maximum possible sequence length is <strong>11</strong>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
<strong>C = 2:</strong> Maximum possible sequence length is <strong>1,160</strong>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Terence Tao proved in 2015: <em>No infinite sequence can survive!</em>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Game Phase: Intro */}
|
||||
{gamePhase === 'intro' && (
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardContent className="py-12 text-center">
|
||||
<div className="max-w-lg mx-auto">
|
||||
<Skull className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-4">The Prisoner's Walk</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
You wake up in a dark tunnel. There's a cliff to your left and vipers to your right.
|
||||
Your captor demands you write a sequence of LEFT/RIGHT instructions...
|
||||
but they get to choose which instructions you follow.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
Discrepancy Bound (C): {discrepancyBound}
|
||||
</label>
|
||||
<div className="flex items-center gap-4 max-w-xs mx-auto">
|
||||
<span className="text-xs">1</span>
|
||||
<Slider
|
||||
value={[discrepancyBound]}
|
||||
onValueChange={(v) => setDiscrepancyBound(v[0])}
|
||||
min={1}
|
||||
max={3}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs">3</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
C=1: Max 11 steps | C=2: Max 1,160 steps | C=3: Unknown (very large)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button onClick={startGame} size="lg">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Start Writing
|
||||
</Button>
|
||||
{discrepancyBound === 1 && (
|
||||
<Button variant="outline" onClick={loadOptimalSequence} size="lg">
|
||||
<Trophy className="w-4 h-4 mr-2" />
|
||||
Load Optimal (11)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bestLength && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Your best: <strong>{bestLength}</strong> steps with C={discrepancyBound}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Main Game UI */}
|
||||
{gamePhase !== 'intro' && (
|
||||
<div className="space-y-6">
|
||||
{/* Status Bar */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
Length: {sequence.length}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
Filled: {filledCount}/{sequence.length}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={hasViolation ? "destructive" : maxDiscrepancy.value === discrepancyBound ? "secondary" : "outline"}
|
||||
className="text-sm"
|
||||
>
|
||||
Max Discrepancy: {maxDiscrepancy.value}
|
||||
{maxDiscrepancy.result && ` (d=${maxDiscrepancy.result.d}, k=${maxDiscrepancy.result.k})`}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
Bound: C = {discrepancyBound}
|
||||
</Badge>
|
||||
{bestLength && (
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
<Trophy className="w-3 h-3 mr-1" />
|
||||
Best: {bestLength}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Violation Alert */}
|
||||
{hasViolation && gamePhase === 'writing' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Bound Exceeded!</AlertTitle>
|
||||
<AlertDescription>
|
||||
The subsequence with d={maxDiscrepancy.result?.d}, k={maxDiscrepancy.result?.k} has
|
||||
discrepancy {maxDiscrepancy.value}, which exceeds your bound of {discrepancyBound}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="sequence" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="sequence" className="flex items-center gap-1">
|
||||
<Target className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Sequence</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="walk" className="flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Walk</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="heatmap" className="flex items-center gap-1">
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Heatmap</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Sequence Builder Tab */}
|
||||
<TabsContent value="sequence" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
Write Your Instructions
|
||||
{gamePhase === 'writing' && (
|
||||
<Badge variant="secondary" className="ml-2">Click to toggle</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Sequence Grid */}
|
||||
<div className="mb-4 overflow-x-auto">
|
||||
<div className="flex gap-1 min-w-max pb-2">
|
||||
{sequence.map((val, idx) => {
|
||||
const pos = idx + 1;
|
||||
const isHighlighted = highlightedSubsequence.includes(pos) || selectedPositions.includes(pos);
|
||||
const isViolationPart = maxDiscrepancy.result?.positions.includes(pos) && hasViolation;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => togglePosition(idx)}
|
||||
disabled={gamePhase !== 'writing'}
|
||||
className={`
|
||||
relative w-10 h-14 rounded-lg border-2 flex flex-col items-center justify-center
|
||||
transition-all duration-200
|
||||
${val === 1 ? 'bg-blue-500/20 border-blue-500 text-blue-600 dark:text-blue-400' : ''}
|
||||
${val === -1 ? 'bg-rose-500/20 border-rose-500 text-rose-600 dark:text-rose-400' : ''}
|
||||
${val === null ? 'bg-muted/50 border-muted-foreground/30 text-muted-foreground' : ''}
|
||||
${isHighlighted ? 'ring-2 ring-amber-400 ring-offset-1' : ''}
|
||||
${isViolationPart ? 'ring-2 ring-destructive ring-offset-1' : ''}
|
||||
${gamePhase === 'writing' ? 'hover:scale-105 cursor-pointer' : 'cursor-default'}
|
||||
`}
|
||||
>
|
||||
<span className="text-[10px] absolute top-0.5 left-1 opacity-50">{pos}</span>
|
||||
{val === 1 && <ArrowRight className="w-5 h-5" />}
|
||||
{val === -1 && <ArrowLeft className="w-5 h-5" />}
|
||||
{val === null && <span className="text-lg">?</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{gamePhase === 'writing' && (
|
||||
<button
|
||||
onClick={addPosition}
|
||||
className="w-10 h-14 rounded-lg border-2 border-dashed border-muted-foreground/30
|
||||
flex items-center justify-center text-muted-foreground
|
||||
hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{gamePhase === 'writing' && (
|
||||
<>
|
||||
<Button
|
||||
onClick={revealCaptor}
|
||||
disabled={!allFilled}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<Skull className="w-4 h-4 mr-2" />
|
||||
Face the Captor
|
||||
</Button>
|
||||
<Button variant="outline" onClick={resetGame}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Walk Visualization Tab */}
|
||||
<TabsContent value="walk" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Walk Visualization</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Step size selector */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">Step size (d):</label>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: Math.min(sequence.length, 8) }, (_, i) => i + 1).map(d => (
|
||||
<Button
|
||||
key={d}
|
||||
variant={selectedD === d ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedD(d);
|
||||
setWalkPosition(0);
|
||||
setWalkStep(0);
|
||||
setIsWalking(false);
|
||||
}}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{d}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm">Speed:</label>
|
||||
<Slider
|
||||
value={[1000 - walkSpeed]}
|
||||
onValueChange={(v) => setWalkSpeed(1000 - v[0])}
|
||||
min={0}
|
||||
max={900}
|
||||
step={100}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number line visualization */}
|
||||
<div className="relative h-32 bg-muted/20 rounded-lg overflow-hidden mb-4">
|
||||
{/* Danger zones */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-destructive/20 flex items-center justify-center"
|
||||
style={{ left: 0, width: `${(discrepancyBound / (discrepancyBound * 2 + 3)) * 100}%` }}
|
||||
>
|
||||
<span className="text-destructive text-xs font-bold rotate-90">CLIFF</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-destructive/20 flex items-center justify-center"
|
||||
style={{ right: 0, width: `${(discrepancyBound / (discrepancyBound * 2 + 3)) * 100}%` }}
|
||||
>
|
||||
<span className="text-destructive text-xs font-bold rotate-90">VIPERS</span>
|
||||
</div>
|
||||
|
||||
{/* Safe zone markers */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center">
|
||||
{Array.from({ length: discrepancyBound * 2 + 1 }, (_, i) => i - discrepancyBound).map(pos => (
|
||||
<div
|
||||
key={pos}
|
||||
className={`
|
||||
w-8 h-8 mx-1 rounded-full flex items-center justify-center text-xs font-bold
|
||||
${pos === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}
|
||||
${Math.abs(pos) === discrepancyBound ? 'border-2 border-amber-500' : ''}
|
||||
`}
|
||||
>
|
||||
{pos}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Walker position */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 transition-all duration-300"
|
||||
style={{
|
||||
left: `calc(50% + ${walkPosition * 40}px - 12px)`,
|
||||
}}
|
||||
>
|
||||
<div className="w-6 h-6 bg-amber-500 rounded-full flex items-center justify-center text-white text-xs animate-pulse">
|
||||
👤
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Walk info */}
|
||||
<div className="flex items-center justify-between mb-4 text-sm">
|
||||
<span>Position: <strong>{walkPosition}</strong></span>
|
||||
<span>Steps taken: <strong>{walkStep}</strong></span>
|
||||
<span>Following: positions {selectedPositions.slice(0, 5).join(', ')}{selectedPositions.length > 5 ? '...' : ''}</span>
|
||||
</div>
|
||||
|
||||
{/* Walk controls */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={walkSubsequence} variant="outline" className="flex-1">
|
||||
{isWalking ? <Pause className="w-4 h-4 mr-2" /> : <Play className="w-4 h-4 mr-2" />}
|
||||
{isWalking ? 'Pause' : 'Walk d=' + selectedD}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setWalkPosition(0);
|
||||
setWalkStep(0);
|
||||
setIsWalking(false);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Heatmap Tab */}
|
||||
<TabsContent value="walk" className="mt-4">
|
||||
{/* This is actually handled in the walk tab above */}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="heatmap" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Discrepancy Heatmap</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Each cell shows the sum of values at positions d, 2d, 3d, ..., kd.
|
||||
Red cells exceed the bound.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-1 text-muted-foreground">d \ k</th>
|
||||
{Array.from({ length: Math.min(12, sequence.length) }, (_, i) => i + 1).map(k => (
|
||||
<th key={k} className="p-1 w-8 text-center text-muted-foreground">{k}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: Math.min(8, sequence.length) }, (_, i) => i + 1).map(d => (
|
||||
<tr key={d}>
|
||||
<td className="p-1 font-medium text-muted-foreground">{d}</td>
|
||||
{Array.from({ length: Math.min(12, sequence.length) }, (_, i) => i + 1).map(k => {
|
||||
const discrepancy = getDiscrepancy(d, k);
|
||||
const isValid = d * k <= sequence.length;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={k}
|
||||
className={`
|
||||
p-1 w-8 h-8 text-center cursor-pointer transition-all
|
||||
${isValid ? getHeatmapColor(discrepancy) : 'bg-transparent'}
|
||||
${hoveredCell?.d === d && hoveredCell?.k === k ? 'ring-2 ring-primary' : ''}
|
||||
`}
|
||||
onMouseEnter={() => {
|
||||
if (isValid) {
|
||||
setHoveredCell({ d, k });
|
||||
const result = allDiscrepancies.find(r => r.d === d && r.k === k);
|
||||
if (result) {
|
||||
setHighlightedSubsequence(result.positions);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredCell(null);
|
||||
setHighlightedSubsequence([]);
|
||||
}}
|
||||
>
|
||||
{isValid && discrepancy !== null ? discrepancy : ''}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{hoveredCell && (
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg text-sm">
|
||||
<strong>d={hoveredCell.d}, k={hoveredCell.k}:</strong> Looking at positions{' '}
|
||||
{Array.from({ length: hoveredCell.k }, (_, i) => (i + 1) * hoveredCell.d).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Captor Reveal Phase */}
|
||||
{gamePhase === 'captor-reveal' && captorChosenD !== null && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="py-6 text-center">
|
||||
<Skull className="w-12 h-12 mx-auto text-destructive mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">The Captor Speaks...</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
"Interesting sequence... Let me see... I choose <strong className="text-destructive text-2xl">d = {captorChosenD}</strong>!"
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
You will follow every {captorChosenD === 1 ? '' : captorChosenD === 2 ? '2nd' : captorChosenD === 3 ? '3rd' : `${captorChosenD}th`} instruction:
|
||||
positions {Array.from({ length: Math.floor(sequence.length / captorChosenD) }, (_, i) => (i + 1) * captorChosenD).slice(0, 8).join(', ')}...
|
||||
</p>
|
||||
<Button onClick={startPrisonerWalk} size="lg" variant="destructive">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Begin Walking
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Walking Phase */}
|
||||
{gamePhase === 'walking' && captorChosenD !== null && (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-lg font-bold">Walking with d = {captorChosenD}...</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Step {prisonerStep} | Position: {prisonerPosition}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tunnel visualization */}
|
||||
<div className="relative h-24 bg-gradient-to-r from-destructive/30 via-muted/20 to-destructive/30 rounded-lg overflow-hidden">
|
||||
<div className="absolute inset-y-0 left-0 w-8 flex items-center justify-center">
|
||||
<span className="text-2xl">🏔️</span>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 right-0 w-8 flex items-center justify-center">
|
||||
<span className="text-2xl">🐍</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 text-3xl transition-all duration-300"
|
||||
style={{
|
||||
left: `calc(50% + ${prisonerPosition * (100 / (discrepancyBound * 2 + 2))}% - 16px)`
|
||||
}}
|
||||
>
|
||||
🚶
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Fallen Phase */}
|
||||
{gamePhase === 'fallen' && (
|
||||
<Alert variant="destructive">
|
||||
<Skull className="h-4 w-4" />
|
||||
<AlertTitle>You Fell!</AlertTitle>
|
||||
<AlertDescription>
|
||||
After {prisonerStep} steps with d={captorChosenD}, you ended up at position {prisonerPosition}.
|
||||
The captor found your weakness! Your sequence lasted {sequence.length} instructions.
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button onClick={startGame} variant="outline" size="sm">
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={resetGame} variant="outline" size="sm">
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Survived Phase (temporary - sequence ended) */}
|
||||
{gamePhase === 'survived' && (
|
||||
<Alert className="border-emerald-500 bg-emerald-500/10">
|
||||
<Trophy className="h-4 w-4 text-emerald-500" />
|
||||
<AlertTitle className="text-emerald-600 dark:text-emerald-400">Sequence Complete!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your {sequence.length}-instruction sequence survived d={captorChosenD}!
|
||||
But remember: no sequence can survive forever. Try making it longer!
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button onClick={() => setGamePhase('writing')} variant="outline" size="sm">
|
||||
Extend Sequence
|
||||
</Button>
|
||||
<Button onClick={resetGame} variant="outline" size="sm">
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
New Game
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acknowledgement */}
|
||||
<Card className="bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-800">
|
||||
<CardContent className="py-4">
|
||||
<h3 className="font-semibold text-foreground mb-2">Credits & History</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
This puzzle is based on the Erdős Discrepancy Problem, posed by Paul Erdős in the 1930s
|
||||
(with a $500 prize!). The "Prisoner's Walk" framing comes from various mathematical expositions,
|
||||
popularized by James Grime's singingbanana video.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>2014:</strong> Boris Konev & Alexei Lisitsa proved C=2 has maximum length 1,160 using SAT solvers
|
||||
(generating a 13GB proof!). <strong>2015:</strong> Terence Tao proved that NO infinite sequence
|
||||
can have bounded discrepancy, settling the problem completely.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Social Share */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErdosDiscrepancyPuzzle;
|
||||
689
src/components/interactives/EternalDominationGame.tsx
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Shield, Target, RotateCcw, Info } from "lucide-react";
|
||||
|
||||
// Extended good rectangle: 10 columns × 12 rows (following Fig. 10)
|
||||
// Border: columns 0 and 9, rows 0/1 (top) and 10/11 (bottom)
|
||||
const COLS = 10;
|
||||
const ROWS = 12;
|
||||
|
||||
type Position = { x: number; y: number };
|
||||
type Movement = { from: Position; to: Position; type: 'interior' | 'toInterior' | 'toBorder' | 'pathShift' };
|
||||
|
||||
const posKey = (p: Position): string => `${p.x},${p.y}`;
|
||||
const parseKey = (key: string): Position => {
|
||||
const [x, y] = key.split(",").map(Number);
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const isValidPos = (x: number, y: number): boolean =>
|
||||
x >= 0 && x < COLS && y >= 0 && y < ROWS;
|
||||
|
||||
// Hexagonal adjacency (brick pattern)
|
||||
const getHexNeighbors = (x: number, y: number): Position[] => {
|
||||
const neighbors: Position[] = [];
|
||||
|
||||
if (isValidPos(x - 1, y)) neighbors.push({ x: x - 1, y });
|
||||
if (isValidPos(x + 1, y)) neighbors.push({ x: x + 1, y });
|
||||
|
||||
const isEvenRow = y % 2 === 0;
|
||||
if (isEvenRow) {
|
||||
if (isValidPos(x - 1, y - 1)) neighbors.push({ x: x - 1, y: y - 1 });
|
||||
if (isValidPos(x, y - 1)) neighbors.push({ x: x, y: y - 1 });
|
||||
if (isValidPos(x - 1, y + 1)) neighbors.push({ x: x - 1, y: y + 1 });
|
||||
if (isValidPos(x, y + 1)) neighbors.push({ x: x, y: y + 1 });
|
||||
} else {
|
||||
if (isValidPos(x, y - 1)) neighbors.push({ x: x, y: y - 1 });
|
||||
if (isValidPos(x + 1, y - 1)) neighbors.push({ x: x + 1, y: y - 1 });
|
||||
if (isValidPos(x, y + 1)) neighbors.push({ x: x, y: y + 1 });
|
||||
if (isValidPos(x + 1, y + 1)) neighbors.push({ x: x + 1, y: y + 1 });
|
||||
}
|
||||
|
||||
return neighbors;
|
||||
};
|
||||
|
||||
// Border: left/right columns and top/bottom double rows
|
||||
const isBorder = (x: number, y: number): boolean => {
|
||||
if (x === 0 || x === COLS - 1) return true;
|
||||
if (y === 0 || y === 1 || y === ROWS - 2 || y === ROWS - 1) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isInterior = (x: number, y: number): boolean =>
|
||||
x > 0 && x < COLS - 1 && y > 1 && y < ROWS - 2;
|
||||
|
||||
// Get the initial guard configuration U₃
|
||||
// All border vertices guarded + interior guards in dominating pattern
|
||||
const generateInitialGuards = (): Set<string> => {
|
||||
const guards = new Set<string>();
|
||||
|
||||
// All border vertices
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
if (isBorder(x, y)) {
|
||||
guards.add(posKey({ x, y }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interior guards: dominating set pattern from S₃
|
||||
// Place at positions where (x + 2*y) % 4 === 0
|
||||
for (let x = 1; x < COLS - 1; x++) {
|
||||
for (let y = 2; y < ROWS - 2; y++) {
|
||||
if ((x + 2 * y) % 4 === 0) {
|
||||
guards.add(posKey({ x, y }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return guards;
|
||||
};
|
||||
|
||||
// Get the target pattern for interior (what we want to restore to)
|
||||
const getTargetInteriorPattern = (): Set<string> => {
|
||||
const pattern = new Set<string>();
|
||||
for (let x = 1; x < COLS - 1; x++) {
|
||||
for (let y = 2; y < ROWS - 2; y++) {
|
||||
if ((x + 2 * y) % 4 === 0) {
|
||||
pattern.add(posKey({ x, y }));
|
||||
}
|
||||
}
|
||||
}
|
||||
return pattern;
|
||||
};
|
||||
|
||||
const isDominated = (x: number, y: number, guards: Set<string>): boolean => {
|
||||
if (guards.has(posKey({ x, y }))) return true;
|
||||
return getHexNeighbors(x, y).some(p => guards.has(posKey(p)));
|
||||
};
|
||||
|
||||
const isFullyDominated = (guards: Set<string>): boolean => {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
if (!isDominated(x, y, guards)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Get ordered border cycle C (for path finding)
|
||||
const getBorderCycleOrdered = (): Position[] => {
|
||||
const cycle: Position[] = [];
|
||||
|
||||
// Outer ring clockwise
|
||||
for (let x = 0; x < COLS; x++) cycle.push({ x, y: 0 });
|
||||
for (let y = 1; y < ROWS; y++) cycle.push({ x: COLS - 1, y });
|
||||
for (let x = COLS - 2; x >= 0; x--) cycle.push({ x, y: ROWS - 1 });
|
||||
for (let y = ROWS - 2; y > 0; y--) cycle.push({ x: 0, y });
|
||||
|
||||
return cycle;
|
||||
};
|
||||
|
||||
// Find path on border between two positions (along the cycle)
|
||||
const findBorderPath = (from: Position, to: Position): Position[] => {
|
||||
const cycle = getBorderCycleOrdered();
|
||||
const fromIdx = cycle.findIndex(p => p.x === from.x && p.y === from.y);
|
||||
const toIdx = cycle.findIndex(p => p.x === to.x && p.y === to.y);
|
||||
|
||||
if (fromIdx === -1 || toIdx === -1) return [];
|
||||
|
||||
const n = cycle.length;
|
||||
const path1: Position[] = [];
|
||||
const path2: Position[] = [];
|
||||
|
||||
// Clockwise path
|
||||
for (let i = fromIdx; ; i = (i + 1) % n) {
|
||||
path1.push(cycle[i]);
|
||||
if (i === toIdx) break;
|
||||
}
|
||||
|
||||
// Counter-clockwise path
|
||||
for (let i = fromIdx; ; i = (i - 1 + n) % n) {
|
||||
path2.push(cycle[i]);
|
||||
if (i === toIdx) break;
|
||||
}
|
||||
|
||||
return path1.length <= path2.length ? path1 : path2;
|
||||
};
|
||||
|
||||
// === Pattern + simultaneous-move defense (no teleporting) =====================
|
||||
|
||||
// Interior configurations come in 4 residue classes.
|
||||
// We model the paper's “restore configuration via border patchwork” as:
|
||||
// after each attack we move (simultaneously) to the interior residue class that
|
||||
// contains the attacked vertex, while keeping the entire border guarded.
|
||||
const interiorResidue = (p: Position): number => ((p.x + 2 * p.y) % 4 + 4) % 4;
|
||||
|
||||
const getInteriorPatternByResidue = (r: number): Set<string> => {
|
||||
const pattern = new Set<string>();
|
||||
for (let x = 1; x < COLS - 1; x++) {
|
||||
for (let y = 2; y < ROWS - 2; y++) {
|
||||
if (interiorResidue({ x, y }) === r) pattern.add(posKey({ x, y }));
|
||||
}
|
||||
}
|
||||
return pattern;
|
||||
};
|
||||
|
||||
const getAllBorderKeys = (): Set<string> => {
|
||||
const border = new Set<string>();
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
if (isBorder(x, y)) border.add(posKey({ x, y }));
|
||||
}
|
||||
}
|
||||
return border;
|
||||
};
|
||||
|
||||
const BORDER_KEYS = getAllBorderKeys();
|
||||
|
||||
const getTargetGuardsForResidue = (r: number): Set<string> => {
|
||||
const target = new Set<string>(BORDER_KEYS);
|
||||
for (const k of getInteriorPatternByResidue(r)) target.add(k);
|
||||
return target;
|
||||
};
|
||||
|
||||
const inferCurrentInteriorResidue = (guards: Set<string>): number => {
|
||||
for (const k of guards) {
|
||||
const p = parseKey(k);
|
||||
if (isInterior(p.x, p.y)) return interiorResidue(p);
|
||||
}
|
||||
// Should never happen (we always have interior guards), but default safely.
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Hopcroft–Karp for perfect matching in the “guards → target cells” bipartite graph.
|
||||
// Left: current guard indices. Right: target indices.
|
||||
const hopcroftKarp = (adj: number[][], leftSize: number, rightSize: number) => {
|
||||
const NIL = -1;
|
||||
const pairU = new Array<number>(leftSize).fill(NIL);
|
||||
const pairV = new Array<number>(rightSize).fill(NIL);
|
||||
const dist = new Array<number>(leftSize).fill(0);
|
||||
|
||||
const bfs = (): boolean => {
|
||||
const q: number[] = [];
|
||||
for (let u = 0; u < leftSize; u++) {
|
||||
if (pairU[u] === NIL) {
|
||||
dist[u] = 0;
|
||||
q.push(u);
|
||||
} else {
|
||||
dist[u] = Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
let foundFreeVertex = false;
|
||||
|
||||
while (q.length) {
|
||||
const u = q.shift()!;
|
||||
for (const v of adj[u]) {
|
||||
const u2 = pairV[v];
|
||||
if (u2 !== NIL) {
|
||||
if (dist[u2] === Number.POSITIVE_INFINITY) {
|
||||
dist[u2] = dist[u] + 1;
|
||||
q.push(u2);
|
||||
}
|
||||
} else {
|
||||
foundFreeVertex = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return foundFreeVertex;
|
||||
};
|
||||
|
||||
const dfs = (u: number): boolean => {
|
||||
for (const v of adj[u]) {
|
||||
const u2 = pairV[v];
|
||||
if (u2 === NIL || (dist[u2] === dist[u] + 1 && dfs(u2))) {
|
||||
pairU[u] = v;
|
||||
pairV[v] = u;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
dist[u] = Number.POSITIVE_INFINITY;
|
||||
return false;
|
||||
};
|
||||
|
||||
let matching = 0;
|
||||
while (bfs()) {
|
||||
for (let u = 0; u < leftSize; u++) {
|
||||
if (pairU[u] === NIL && dfs(u)) matching++;
|
||||
}
|
||||
}
|
||||
|
||||
return { matching, pairU };
|
||||
};
|
||||
|
||||
// Defense = one simultaneous move of all guards (each guard moves at most 1 step).
|
||||
// We compute the intended post-defense configuration (target) and then find a
|
||||
// perfect matching that assigns each current guard to a unique reachable target.
|
||||
const defendAttack = (
|
||||
guards: Set<string>,
|
||||
attack: Position
|
||||
): {
|
||||
newGuards: Set<string>;
|
||||
movements: Movement[];
|
||||
} | null => {
|
||||
const attackKey = posKey(attack);
|
||||
|
||||
if (guards.has(attackKey)) {
|
||||
return { newGuards: guards, movements: [] };
|
||||
}
|
||||
|
||||
// Must be defendable: some guard adjacent to v_t.
|
||||
const neighbors = getHexNeighbors(attack.x, attack.y);
|
||||
if (!neighbors.some((p) => guards.has(posKey(p)))) return null;
|
||||
|
||||
const currentResidue = inferCurrentInteriorResidue(guards);
|
||||
const targetResidue = interiorResidue(attack);
|
||||
|
||||
// Target config: same “family” of configuration, but switch residue class so that
|
||||
// the attacked vertex becomes a guard position again.
|
||||
const target = getTargetGuardsForResidue(targetResidue);
|
||||
if (!target.has(attackKey)) {
|
||||
// Should never happen by construction.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build bipartite graph: each guard can go to {self ∪ hex-neighbors} ∩ target.
|
||||
const fromKeys = Array.from(guards);
|
||||
const toKeys = Array.from(target);
|
||||
|
||||
if (fromKeys.length !== toKeys.length) return null;
|
||||
|
||||
const toIndex = new Map<string, number>();
|
||||
for (let i = 0; i < toKeys.length; i++) toIndex.set(toKeys[i], i);
|
||||
|
||||
const adj: number[][] = new Array(fromKeys.length);
|
||||
for (let i = 0; i < fromKeys.length; i++) {
|
||||
const from = parseKey(fromKeys[i]);
|
||||
const reachable: string[] = [posKey(from), ...getHexNeighbors(from.x, from.y).map(posKey)];
|
||||
|
||||
const edges: number[] = [];
|
||||
for (const k of reachable) {
|
||||
const idx = toIndex.get(k);
|
||||
if (idx !== undefined) edges.push(idx);
|
||||
}
|
||||
|
||||
// Deterministic order helps keep the visual behavior stable.
|
||||
edges.sort((a, b) => a - b);
|
||||
adj[i] = edges;
|
||||
}
|
||||
|
||||
const { matching, pairU } = hopcroftKarp(adj, fromKeys.length, toKeys.length);
|
||||
if (matching !== fromKeys.length) return null;
|
||||
|
||||
const movements: Movement[] = [];
|
||||
for (let u = 0; u < fromKeys.length; u++) {
|
||||
const v = pairU[u];
|
||||
if (v === -1) return null;
|
||||
const from = parseKey(fromKeys[u]);
|
||||
const to = parseKey(toKeys[v]);
|
||||
if (from.x !== to.x || from.y !== to.y) {
|
||||
const fromBorder = isBorder(from.x, from.y);
|
||||
const toBorder = isBorder(to.x, to.y);
|
||||
|
||||
let type: Movement['type'] = 'interior';
|
||||
if (fromBorder && !toBorder) type = 'toInterior';
|
||||
else if (!fromBorder && toBorder) type = 'toBorder';
|
||||
else if (fromBorder && toBorder) type = 'pathShift';
|
||||
|
||||
movements.push({ from, to, type });
|
||||
}
|
||||
}
|
||||
|
||||
// Safety invariants: no creation / no disappearance.
|
||||
if (target.size !== guards.size) return null;
|
||||
|
||||
// Theorem-backed invariant: target configuration is dominating.
|
||||
// If our modeling is wrong, fail loudly rather than “teleport”.
|
||||
if (!isFullyDominated(target)) return null;
|
||||
|
||||
// Additional sanity: everybody moved at most one step.
|
||||
// (This should be guaranteed by the edge construction.)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _unused = currentResidue;
|
||||
|
||||
return { newGuards: target, movements };
|
||||
};
|
||||
|
||||
|
||||
interface EternalDominationGameProps {
|
||||
}
|
||||
|
||||
const EternalDominationGame: React.FC<EternalDominationGameProps> = () => {
|
||||
const [guards, setGuards] = useState<Set<string>>(generateInitialGuards);
|
||||
const [attackHistory, setAttackHistory] = useState<Position[]>([]);
|
||||
const [message, setMessage] = useState<string>("Click any unguarded cell to attack.");
|
||||
const [moveCount, setMoveCount] = useState(0);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [lastMovements, setLastMovements] = useState<Movement[]>([]);
|
||||
|
||||
const [pendingDefense, setPendingDefense] = useState<{
|
||||
endGuards: Set<string>;
|
||||
movements: Movement[];
|
||||
summary: { interiorMoves: number; borderIn: number; borderOut: number; pathShifts: number };
|
||||
} | null>(null);
|
||||
const [activeMoveIdx, setActiveMoveIdx] = useState<number>(-1);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const activeMove =
|
||||
pendingDefense && activeMoveIdx >= 0 && activeMoveIdx < pendingDefense.movements.length
|
||||
? pendingDefense.movements[activeMoveIdx]
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingDefense) return;
|
||||
|
||||
// No moves? Apply immediately.
|
||||
if (pendingDefense.movements.length === 0) {
|
||||
setGuards(pendingDefense.endGuards);
|
||||
setPendingDefense(null);
|
||||
setActiveMoveIdx(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeMoveIdx >= pendingDefense.movements.length) {
|
||||
// Finish: apply the simultaneous move.
|
||||
setGuards(pendingDefense.endGuards);
|
||||
setLastMovements(pendingDefense.movements);
|
||||
|
||||
const { interiorMoves, borderIn, pathShifts } = pendingDefense.summary;
|
||||
let desc = `Defended! Interior shifts: ${interiorMoves}`;
|
||||
if (borderIn > 0) desc += `, w→u: ${borderIn}`;
|
||||
if (pathShifts > 0) desc += `, path shifts: ${pathShifts}`;
|
||||
setMessage(desc);
|
||||
|
||||
setPendingDefense(null);
|
||||
setActiveMoveIdx(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show one movement at a time (visualizing a simultaneous sweep).
|
||||
setLastMovements([pendingDefense.movements[activeMoveIdx]]);
|
||||
setMessage(`Defending… step ${activeMoveIdx + 1}/${pendingDefense.movements.length}`);
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setActiveMoveIdx((i) => i + 1);
|
||||
}, 220);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [pendingDefense, activeMoveIdx]);
|
||||
|
||||
const handleCellClick = useCallback(
|
||||
(x: number, y: number) => {
|
||||
if (pendingDefense) return; // ignore clicks during animation
|
||||
|
||||
const attack: Position = { x, y };
|
||||
|
||||
if (guards.has(posKey(attack))) {
|
||||
setMessage("Cannot attack a guarded position.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = defendAttack(guards, attack);
|
||||
|
||||
if (!result) {
|
||||
setMessage("Cannot defend under the rules (no legal simultaneous move found). ");
|
||||
return;
|
||||
}
|
||||
|
||||
const interiorMoves = result.movements.filter((m) => m.type === "interior").length;
|
||||
const borderIn = result.movements.filter((m) => m.type === "toInterior").length;
|
||||
const borderOut = result.movements.filter((m) => m.type === "toBorder").length;
|
||||
const pathShifts = result.movements.filter((m) => m.type === "pathShift").length;
|
||||
|
||||
setAttackHistory((prev) => [...prev, attack]);
|
||||
setMoveCount((prev) => prev + 1);
|
||||
|
||||
setPendingDefense({
|
||||
endGuards: result.newGuards,
|
||||
movements: result.movements,
|
||||
summary: { interiorMoves, borderIn, borderOut, pathShifts },
|
||||
});
|
||||
setActiveMoveIdx(0);
|
||||
},
|
||||
[guards, pendingDefense]
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setPendingDefense(null);
|
||||
setActiveMoveIdx(-1);
|
||||
|
||||
setGuards(generateInitialGuards());
|
||||
setAttackHistory([]);
|
||||
setLastMovements([]);
|
||||
setMessage("Click any unguarded cell to attack.");
|
||||
setMoveCount(0);
|
||||
}, []);
|
||||
|
||||
const guardCount = guards.size;
|
||||
const interiorGuards = Array.from(guards).filter((k) => {
|
||||
const p = parseKey(k);
|
||||
return isInterior(p.x, p.y);
|
||||
}).length;
|
||||
const borderGuards = guardCount - interiorGuards;
|
||||
|
||||
const cellW = 44;
|
||||
const cellH = 40;
|
||||
const offset = cellW / 2;
|
||||
|
||||
// Color mapping for movement types
|
||||
const getMovementColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'interior': return '#ef4444'; // Red - interior chain shift
|
||||
case 'toInterior': return '#06b6d4'; // Cyan - w₃→u₃ (border to interior)
|
||||
case 'toBorder': return '#f97316'; // Orange - u₁→w₁ (interior to border)
|
||||
case 'pathShift': return '#22c55e'; // Green - complementary path shift
|
||||
default: return '#888';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Eternal Domination (Figure 10)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
10×12 grid with double-layer border — defense via interior chains and complementary path shifts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2 items-center justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="outline">Guards: {guardCount}</Badge>
|
||||
<Badge className="bg-cyan-600/80">Border: {borderGuards}</Badge>
|
||||
<Badge className="bg-red-600/80">Interior: {interiorGuards}</Badge>
|
||||
<Badge variant="outline">Attacks: {moveCount}</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowInfo(!showInfo)}>
|
||||
<Info className="w-4 h-4 mr-1" />
|
||||
Info
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
<div className="p-4 bg-muted rounded-lg text-sm space-y-2">
|
||||
<p><strong>Figure 10 Defense Strategy:</strong></p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li><span className="text-red-500 font-medium">Red arrows:</span> Interior guards shift in chains toward attack (v_t)</li>
|
||||
<li><span className="text-orange-500 font-medium">Orange:</span> Interior guard pushed to border (u₁→w₁, u₂→w₂)</li>
|
||||
<li><span className="text-cyan-500 font-medium">Cyan:</span> Border guard enters interior (w₃→u₃, w₄→u₄)</li>
|
||||
<li><span className="text-green-500 font-medium">Green arrows:</span> Complementary path shifts (P₁,₃ and P₂,₄) restore border</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3 rounded-md bg-muted/50 text-sm flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{message}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex justify-center overflow-x-auto pb-4">
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: COLS * cellW + offset + 20,
|
||||
height: ROWS * cellH + 20
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
width={COLS * cellW + offset + 20}
|
||||
height={ROWS * cellH + 20}
|
||||
>
|
||||
{/* Edges */}
|
||||
{Array.from({ length: ROWS }, (_, y) =>
|
||||
Array.from({ length: COLS }, (_, x) => {
|
||||
const rowOffset = y % 2 === 1 ? offset : 0;
|
||||
const cx = x * cellW + cellW / 2 + rowOffset + 10;
|
||||
const cy = y * cellH + cellH / 2 + 10;
|
||||
|
||||
return getHexNeighbors(x, y)
|
||||
.filter(n => n.x > x || (n.x === x && n.y > y))
|
||||
.map((nb, i) => {
|
||||
const nbOffset = nb.y % 2 === 1 ? offset : 0;
|
||||
const ncx = nb.x * cellW + cellW / 2 + nbOffset + 10;
|
||||
const ncy = nb.y * cellH + cellH / 2 + 10;
|
||||
|
||||
const isBorderEdge = isBorder(x, y) && isBorder(nb.x, nb.y);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${x}-${y}-${i}`}
|
||||
x1={cx} y1={cy} x2={ncx} y2={ncy}
|
||||
stroke={isBorderEdge ? "#22c55e" : "currentColor"}
|
||||
strokeOpacity={isBorderEdge ? 0.6 : 0.15}
|
||||
strokeWidth={isBorderEdge ? 2 : 1}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Movement arrows */}
|
||||
{lastMovements.map((move, i) => {
|
||||
const fromOff = move.from.y % 2 === 1 ? offset : 0;
|
||||
const toOff = move.to.y % 2 === 1 ? offset : 0;
|
||||
const x1 = move.from.x * cellW + cellW / 2 + fromOff + 10;
|
||||
const y1 = move.from.y * cellH + cellH / 2 + 10;
|
||||
const x2 = move.to.x * cellW + cellW / 2 + toOff + 10;
|
||||
const y2 = move.to.y * cellH + cellH / 2 + 10;
|
||||
|
||||
const color = getMovementColor(move.type);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`mv-${i}`}
|
||||
x1={x1} y1={y1} x2={x2} y2={y2}
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
strokeOpacity={0.9}
|
||||
markerEnd={`url(#arrow-${move.type})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<defs>
|
||||
<marker id="arrow-interior" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="#ef4444" />
|
||||
</marker>
|
||||
<marker id="arrow-toInterior" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="#06b6d4" />
|
||||
</marker>
|
||||
<marker id="arrow-toBorder" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="#f97316" />
|
||||
</marker>
|
||||
<marker id="arrow-pathShift" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="#22c55e" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* Cells */}
|
||||
{Array.from({ length: ROWS }, (_, y) =>
|
||||
Array.from({ length: COLS }, (_, x) => {
|
||||
const rowOffset = y % 2 === 1 ? offset : 0;
|
||||
const hasGuard = guards.has(posKey({ x, y }));
|
||||
const onBorder = isBorder(x, y);
|
||||
const dominated = isDominated(x, y, guards);
|
||||
|
||||
let bgClass = "bg-muted/60 hover:bg-muted";
|
||||
if (hasGuard) {
|
||||
bgClass = onBorder
|
||||
? "bg-cyan-500/60 border-cyan-600"
|
||||
: "bg-red-500/60 border-red-600";
|
||||
} else if (!dominated) {
|
||||
bgClass = "bg-destructive/60 border-destructive";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${x}-${y}`}
|
||||
className={`absolute w-9 h-9 rounded-full border-2 flex items-center justify-center text-sm cursor-pointer transition-colors ${bgClass}`}
|
||||
style={{
|
||||
left: x * cellW + rowOffset + 10 + (cellW - 36) / 2,
|
||||
top: y * cellH + 10 + (cellH - 36) / 2,
|
||||
}}
|
||||
onClick={() => handleCellClick(x, y)}
|
||||
title={`(${x},${y})`}
|
||||
>
|
||||
{hasGuard && <Shield className="w-4 h-4" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-cyan-500/60 border-2 border-cyan-600 rounded-full flex items-center justify-center">
|
||||
<Shield className="w-3 h-3" />
|
||||
</div>
|
||||
<span>Border</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-red-500/60 border-2 border-red-600 rounded-full flex items-center justify-center">
|
||||
<Shield className="w-3 h-3" />
|
||||
</div>
|
||||
<span>Interior</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-green-500"></div>
|
||||
<span>Path shifts (P₁,₃/P₂,₄)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attackHistory.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Attacks: </span>
|
||||
{attackHistory.slice(-8).map((pos, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs mx-0.5">
|
||||
({pos.x},{pos.y})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EternalDominationGame;
|
||||
341
src/components/interactives/FerrersRogersRamanujan.tsx
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FerrersRogersRamanujanProps {
|
||||
}
|
||||
|
||||
const FerrersRogersRamanujan = ({}: FerrersRogersRamanujanProps) => {
|
||||
const [n, setN] = useState(14);
|
||||
const [partitionSliders, setPartitionSliders] = useState<number[]>([]);
|
||||
const [committedPartition, setCommittedPartition] = useState<number[]>([]);
|
||||
const [rrPartition, setRRPartition] = useState<number[]>([]);
|
||||
const [animatingHook, setAnimatingHook] = useState<{ row: number; col: number } | null>(null);
|
||||
const [animationStep, setAnimationStep] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [hookColors, setHookColors] = useState<string[]>([]);
|
||||
const [baseHue, setBaseHue] = useState(0);
|
||||
const [completedHooks, setCompletedHooks] = useState<Array<{ row: number; col: number }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize sliders with all zeros
|
||||
setPartitionSliders(new Array(n).fill(0));
|
||||
setCommittedPartition([]);
|
||||
setRRPartition([]);
|
||||
}, [n]);
|
||||
|
||||
const currentSum = partitionSliders.reduce((sum, val) => sum + val, 0);
|
||||
const isValid = currentSum === n;
|
||||
|
||||
const handleCommit = () => {
|
||||
if (!isValid) {
|
||||
toast.error(`Sum must equal ${n}. Current sum: ${currentSum}`);
|
||||
return;
|
||||
}
|
||||
// Filter out zeros and sort in descending order
|
||||
const partition = partitionSliders.filter(v => v > 0).sort((a, b) => b - a);
|
||||
setCommittedPartition(partition);
|
||||
setRRPartition([]);
|
||||
setAnimatingHook(null);
|
||||
setAnimationStep(0);
|
||||
toast.success("Partition committed!");
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPartitionSliders(new Array(n).fill(0));
|
||||
setCommittedPartition([]);
|
||||
setRRPartition([]);
|
||||
setAnimatingHook(null);
|
||||
setAnimationStep(0);
|
||||
setIsAnimating(false);
|
||||
setHookColors([]);
|
||||
setCompletedHooks([]);
|
||||
};
|
||||
|
||||
const generateRRPartition = () => {
|
||||
if (committedPartition.length === 0) {
|
||||
toast.error("Please commit a partition first");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick a random base hue (0-360)
|
||||
const newBaseHue = Math.floor(Math.random() * 360);
|
||||
setBaseHue(newBaseHue);
|
||||
setHookColors([]);
|
||||
setCompletedHooks([]);
|
||||
setRRPartition([]);
|
||||
setAnimationStep(0);
|
||||
setIsAnimating(true);
|
||||
};
|
||||
|
||||
// Generate muted pastel color for current hook
|
||||
const generateHookColor = (index: number, total: number) => {
|
||||
// Move through shades of the base hue
|
||||
const hueVariation = (index / Math.max(total - 1, 1)) * 60 - 30; // ±30 degrees
|
||||
const hue = (baseHue + hueVariation + 360) % 360;
|
||||
const saturation = 45 + (index % 3) * 10; // 45-65% for muted pastels
|
||||
const lightness = 75 + (index % 2) * 5; // 75-80% for pastels
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
|
||||
// Animation effect - traverse main diagonal only
|
||||
useEffect(() => {
|
||||
if (!isAnimating || committedPartition.length === 0) return;
|
||||
|
||||
const partition = committedPartition;
|
||||
|
||||
// Main diagonal: positions (0,0), (1,1), (2,2), ... while box exists
|
||||
const row = animationStep;
|
||||
const col = animationStep;
|
||||
|
||||
// Check if this diagonal position has a box
|
||||
if (row >= partition.length || col >= partition[row]) {
|
||||
// Animation complete - no more diagonal boxes
|
||||
setIsAnimating(false);
|
||||
setAnimatingHook(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimatingHook({ row, col });
|
||||
|
||||
// Calculate hook size: boxes to the right + boxes below + 1 (the box itself)
|
||||
const boxesRight = partition[row] - col - 1;
|
||||
let boxesBelow = 0;
|
||||
for (let r = row + 1; r < partition.length; r++) {
|
||||
if (partition[r] > col) {
|
||||
boxesBelow++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const hookSize = boxesRight + boxesBelow + 1;
|
||||
|
||||
// Generate color for this hook (estimate total hooks as partition length)
|
||||
const estimatedTotalHooks = Math.min(partition.length, partition[0] || 0);
|
||||
const color = generateHookColor(animationStep, estimatedTotalHooks);
|
||||
|
||||
// Update colors, completed hooks, and RR partition
|
||||
setHookColors(prev => [...prev, color]);
|
||||
setCompletedHooks(prev => [...prev, { row, col }]);
|
||||
setRRPartition(prev => [...prev, hookSize]);
|
||||
|
||||
// Schedule next step
|
||||
const timeout = setTimeout(() => {
|
||||
setAnimationStep(prev => prev + 1);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isAnimating, animationStep, committedPartition, baseHue]);
|
||||
|
||||
const renderFerrersDiagram = (partition: number[], highlightHook?: { row: number; col: number }, isOriginal = true) => {
|
||||
if (partition.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="inline-block p-4 bg-card rounded-lg border">
|
||||
{partition.map((count, rowIdx) => (
|
||||
<div key={rowIdx} className="flex gap-[2px] mb-[2px]">
|
||||
{Array.from({ length: count }).map((_, colIdx) => {
|
||||
let backgroundColor = 'bg-secondary';
|
||||
let borderColor = 'border-border';
|
||||
let extraClasses = 'hover:bg-secondary/80';
|
||||
let style: React.CSSProperties = {};
|
||||
|
||||
if (isOriginal) {
|
||||
// Check if this box is part of any completed hook
|
||||
for (let i = 0; i < completedHooks.length; i++) {
|
||||
const hook = completedHooks[i];
|
||||
const isPartOfHook =
|
||||
(rowIdx === hook.row && colIdx >= hook.col) || // boxes to the right
|
||||
(colIdx === hook.col && rowIdx >= hook.row); // boxes below
|
||||
|
||||
if (isPartOfHook) {
|
||||
const color = hookColors[i];
|
||||
backgroundColor = '';
|
||||
borderColor = '';
|
||||
extraClasses = 'scale-105 shadow-md';
|
||||
style = {
|
||||
backgroundColor: color,
|
||||
borderColor: color
|
||||
};
|
||||
break; // Use the first matching hook's color
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight current hook being processed (override completed hooks)
|
||||
if (highlightHook) {
|
||||
const isCurrentHook =
|
||||
(rowIdx === highlightHook.row && colIdx >= highlightHook.col) ||
|
||||
(colIdx === highlightHook.col && rowIdx >= highlightHook.row);
|
||||
|
||||
if (isCurrentHook) {
|
||||
backgroundColor = 'bg-primary';
|
||||
borderColor = 'border-primary';
|
||||
extraClasses = 'scale-110 shadow-lg animate-pulse';
|
||||
style = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIdx}
|
||||
className={`w-[30px] h-[30px] border-2 transition-all duration-300 ${backgroundColor} ${borderColor} ${extraClasses}`}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRRDiagram = (partition: number[]) => {
|
||||
if (partition.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="inline-block p-4 bg-card rounded-lg border">
|
||||
{partition.map((count, rowIdx) => (
|
||||
<div key={rowIdx} className="flex gap-[2px] mb-[2px]">
|
||||
{Array.from({ length: count }).map((_, colIdx) => {
|
||||
const color = hookColors[rowIdx] || 'hsl(var(--secondary))';
|
||||
return (
|
||||
<div
|
||||
key={colIdx}
|
||||
className="w-[30px] h-[30px] border-2 transition-all duration-300 scale-105 shadow-md"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: color
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6 max-w-6xl mx-auto">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">Ferrers Diagram & Rogers-Ramanujan Partition</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Create a partition of a number and visualize its transformation using L-shaped hooks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Number Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Target Number (n)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
value={n}
|
||||
onChange={(e) => setN(Math.max(1, Math.min(30, parseInt(e.target.value) || 1)))}
|
||||
className="w-32"
|
||||
disabled={committedPartition.length > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Partition Sliders */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Create Partition</h3>
|
||||
<div className="text-sm">
|
||||
Sum: <span className={isValid ? "text-green-600 font-bold" : "text-destructive font-bold"}>{currentSum}</span> / {n}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{partitionSliders.map((value, idx) => (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">Part {idx + 1}</label>
|
||||
<span className="text-sm font-mono">{value}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[value]}
|
||||
onValueChange={([newVal]) => {
|
||||
const newSliders = [...partitionSliders];
|
||||
newSliders[idx] = newVal;
|
||||
setPartitionSliders(newSliders);
|
||||
}}
|
||||
max={n}
|
||||
step={1}
|
||||
disabled={committedPartition.length > 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={!isValid || committedPartition.length > 0}
|
||||
>
|
||||
Commit Partition
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="outline"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Partitions */}
|
||||
{committedPartition.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Your Partition</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{committedPartition.join(" + ")} = {n}
|
||||
</p>
|
||||
{renderFerrersDiagram(committedPartition, animatingHook || undefined)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={generateRRPartition}
|
||||
disabled={rrPartition.length > 0 || isAnimating}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{isAnimating ? "Generating..." : "Generate Rogers-Ramanujan Partition"}
|
||||
</Button>
|
||||
|
||||
{/* RR Partition Display */}
|
||||
{rrPartition.length > 0 && (
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold mb-2">Rogers-Ramanujan Partition</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{rrPartition.join(" + ")} = {n} (parts differ by ≥ 2)
|
||||
</p>
|
||||
{renderRRDiagram(rrPartition)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation */}
|
||||
<div className="pt-6 border-t space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Ferrers Diagram:</strong> A visual representation of an integer partition using boxes arranged in rows.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Rogers-Ramanujan Partition:</strong> Traverse the L-shaped hooks of the Ferrers diagram.
|
||||
Each hook consists of all boxes to the right and below (including the corner box).
|
||||
The hook sizes form a new partition where parts differ by at least 2.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FerrersRogersRamanujan;
|
||||
279
src/components/interactives/GameOfSim.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { RotateCcw, Undo2 } from 'lucide-react';
|
||||
|
||||
interface Edge {
|
||||
from: number;
|
||||
to: number;
|
||||
color: 'red' | 'blue' | null;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
edges: Edge[];
|
||||
currentPlayer: 'red' | 'blue';
|
||||
gameOver: boolean;
|
||||
winner: 'red' | 'blue' | null;
|
||||
moveHistory: Edge[];
|
||||
}
|
||||
|
||||
interface GameOfSimProps {
|
||||
}
|
||||
|
||||
const GameOfSim: React.FC<GameOfSimProps> = () => {
|
||||
const [vertices, setVertices] = useState(6);
|
||||
const [gameState, setGameState] = useState<GameState>(() => initializeGame(6));
|
||||
|
||||
function initializeGame(numVertices: number): GameState {
|
||||
const edges: Edge[] = [];
|
||||
|
||||
// Create all possible edges between vertices
|
||||
for (let i = 0; i < numVertices; i++) {
|
||||
for (let j = i + 1; j < numVertices; j++) {
|
||||
edges.push({ from: i, to: j, color: null });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
edges,
|
||||
currentPlayer: 'red',
|
||||
gameOver: false,
|
||||
winner: null,
|
||||
moveHistory: []
|
||||
};
|
||||
}
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
setGameState(initializeGame(vertices));
|
||||
}, [vertices]);
|
||||
|
||||
const undoMove = useCallback(() => {
|
||||
if (gameState.moveHistory.length === 0) return;
|
||||
|
||||
const lastMove = gameState.moveHistory[gameState.moveHistory.length - 1];
|
||||
const newEdges = gameState.edges.map(edge => {
|
||||
if (edge.from === lastMove.from && edge.to === lastMove.to) {
|
||||
return { ...edge, color: null };
|
||||
}
|
||||
return edge;
|
||||
});
|
||||
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
edges: newEdges,
|
||||
currentPlayer: prev.currentPlayer === 'red' ? 'blue' : 'red',
|
||||
moveHistory: prev.moveHistory.slice(0, -1),
|
||||
gameOver: false,
|
||||
winner: null
|
||||
}));
|
||||
}, [gameState.moveHistory, gameState.edges]);
|
||||
|
||||
const checkForTriangle = useCallback((edges: Edge[], player: 'red' | 'blue'): boolean => {
|
||||
const playerEdges = edges.filter(edge => edge.color === player);
|
||||
|
||||
// Check all possible triangles
|
||||
for (let i = 0; i < vertices; i++) {
|
||||
for (let j = i + 1; j < vertices; j++) {
|
||||
for (let k = j + 1; k < vertices; k++) {
|
||||
// Check if all three edges of triangle exist for this player
|
||||
const edge1 = playerEdges.find(e => (e.from === i && e.to === j) || (e.from === j && e.to === i));
|
||||
const edge2 = playerEdges.find(e => (e.from === i && e.to === k) || (e.from === k && e.to === i));
|
||||
const edge3 = playerEdges.find(e => (e.from === j && e.to === k) || (e.from === k && e.to === j));
|
||||
|
||||
if (edge1 && edge2 && edge3) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [vertices]);
|
||||
|
||||
const handleEdgeClick = useCallback((edgeIndex: number) => {
|
||||
if (gameState.gameOver || gameState.edges[edgeIndex].color !== null) return;
|
||||
|
||||
const newEdges = [...gameState.edges];
|
||||
const clickedEdge = { ...newEdges[edgeIndex], color: gameState.currentPlayer };
|
||||
newEdges[edgeIndex] = clickedEdge;
|
||||
|
||||
// Check if this move creates a triangle
|
||||
const hasTriangle = checkForTriangle(newEdges, gameState.currentPlayer);
|
||||
|
||||
setGameState(prev => ({
|
||||
edges: newEdges,
|
||||
currentPlayer: hasTriangle ? prev.currentPlayer : (prev.currentPlayer === 'red' ? 'blue' : 'red'),
|
||||
gameOver: hasTriangle,
|
||||
winner: hasTriangle ? (prev.currentPlayer === 'red' ? 'blue' : 'red') : null,
|
||||
moveHistory: [...prev.moveHistory, clickedEdge]
|
||||
}));
|
||||
}, [gameState, checkForTriangle]);
|
||||
|
||||
const getVertexPosition = (index: number, total: number) => {
|
||||
const angle = (2 * Math.PI * index) / total;
|
||||
const radius = 120;
|
||||
const centerX = 150;
|
||||
const centerY = 150;
|
||||
|
||||
return {
|
||||
x: centerX + radius * Math.cos(angle - Math.PI / 2),
|
||||
y: centerY + radius * Math.sin(angle - Math.PI / 2)
|
||||
};
|
||||
};
|
||||
|
||||
const handleVerticesChange = (value: string) => {
|
||||
const newVertices = parseInt(value);
|
||||
setVertices(newVertices);
|
||||
setGameState(initializeGame(newVertices));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold text-center">Game of Sim</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Take turns coloring the lines between dots. Avoid creating a triangle of your color - first player to make one loses!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Game Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="vertices-select" className="text-sm font-medium">
|
||||
Vertices:
|
||||
</label>
|
||||
<Select value={vertices.toString()} onValueChange={handleVerticesChange}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="4">4</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="6">6</SelectItem>
|
||||
<SelectItem value="7">7</SelectItem>
|
||||
<SelectItem value="8">8</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={undoMove}
|
||||
disabled={gameState.moveHistory.length === 0}
|
||||
>
|
||||
<Undo2 className="w-4 h-4 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={resetGame}>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Reset Game
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Turn Indicator */}
|
||||
<div className="text-center">
|
||||
{gameState.gameOver ? (
|
||||
<div className="space-y-2">
|
||||
<Badge variant="destructive" className="text-lg px-4 py-2">
|
||||
Game Over!
|
||||
</Badge>
|
||||
<p className="text-lg font-medium">
|
||||
<span className={gameState.winner === 'blue' ? 'text-blue-600' : 'text-red-600'}>
|
||||
{gameState.winner === 'blue' ? 'Blue' : 'Red'} Player
|
||||
</span>{' '}
|
||||
wins!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Current Turn:</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-lg px-4 py-2 ${
|
||||
gameState.currentPlayer === 'red'
|
||||
? 'border-red-500 text-red-600'
|
||||
: 'border-blue-500 text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{gameState.currentPlayer === 'red' ? 'Red' : 'Blue'} Player
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Game Board */}
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-surface rounded-lg p-8 border">
|
||||
<svg width="300" height="300" viewBox="0 0 300 300">
|
||||
{/* Draw edges */}
|
||||
{gameState.edges.map((edge, index) => {
|
||||
const pos1 = getVertexPosition(edge.from, vertices);
|
||||
const pos2 = getVertexPosition(edge.to, vertices);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
x1={pos1.x}
|
||||
y1={pos1.y}
|
||||
x2={pos2.x}
|
||||
y2={pos2.y}
|
||||
stroke={edge.color || '#e2e8f0'}
|
||||
strokeWidth={edge.color ? 3 : 2}
|
||||
className="cursor-pointer hover:stroke-gray-400 transition-colors"
|
||||
onClick={() => handleEdgeClick(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Draw vertices */}
|
||||
{Array.from({ length: vertices }, (_, i) => {
|
||||
const pos = getVertexPosition(i, vertices);
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r="8"
|
||||
fill="hsl(var(--primary))"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 20}
|
||||
textAnchor="middle"
|
||||
className="text-sm font-medium fill-foreground"
|
||||
>
|
||||
{i + 1}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-center space-y-2 p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click on the lines between dots to color them with your color.
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Avoid creating a triangle of your own color!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Share */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GameOfSim;
|
||||
429
src/components/interactives/GoldCoinGame.tsx
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Crown, Circle, RotateCcw, BookOpen, Trophy } from 'lucide-react';
|
||||
|
||||
type Player = 0 | 1;
|
||||
type CellState = 'empty' | 'penny' | 'gold';
|
||||
|
||||
interface GameState {
|
||||
track: CellState[];
|
||||
currentPlayer: Player;
|
||||
winner: Player | null;
|
||||
gameStarted: boolean;
|
||||
selectedCoin: number | null;
|
||||
moveHistory: string[];
|
||||
}
|
||||
|
||||
interface GoldCoinGameProps {
|
||||
}
|
||||
|
||||
const GoldCoinGame: React.FC<GoldCoinGameProps> = () => {
|
||||
const [gameState, setGameState] = useState<GameState>({
|
||||
track: Array(15).fill('empty'),
|
||||
currentPlayer: 0,
|
||||
winner: null,
|
||||
gameStarted: false,
|
||||
selectedCoin: null,
|
||||
moveHistory: []
|
||||
});
|
||||
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [showGameOver, setShowGameOver] = useState(false);
|
||||
|
||||
const playerNames = ['Player 1', 'Player 2'];
|
||||
const playerColors = ['#3b82f6', '#ef4444'];
|
||||
|
||||
const generateRandomBoard = (): CellState[] => {
|
||||
const track = Array(15).fill('empty') as CellState[];
|
||||
|
||||
// Always place the gold coin at position 7-14 (random)
|
||||
const goldPosition = Math.floor(Math.random() * 8) + 7;
|
||||
track[goldPosition] = 'gold';
|
||||
|
||||
// Place 3-5 pennies randomly in positions 0-6
|
||||
const numPennies = Math.floor(Math.random() * 3) + 3; // 3-5 pennies
|
||||
const availablePositions = Array.from({length: 7}, (_, i) => i); // positions 0-6
|
||||
|
||||
for (let i = 0; i < numPennies; i++) {
|
||||
if (availablePositions.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * availablePositions.length);
|
||||
const position = availablePositions.splice(randomIndex, 1)[0];
|
||||
track[position] = 'penny';
|
||||
}
|
||||
}
|
||||
|
||||
return track;
|
||||
};
|
||||
|
||||
const initializeGame = () => {
|
||||
setGameState({
|
||||
track: generateRandomBoard(),
|
||||
currentPlayer: 0,
|
||||
winner: null,
|
||||
gameStarted: true,
|
||||
selectedCoin: null,
|
||||
moveHistory: []
|
||||
});
|
||||
};
|
||||
|
||||
const resetGame = () => {
|
||||
setGameState({
|
||||
track: Array(15).fill('empty'),
|
||||
currentPlayer: 0,
|
||||
winner: null,
|
||||
gameStarted: false,
|
||||
selectedCoin: null,
|
||||
moveHistory: []
|
||||
});
|
||||
setShowGameOver(false);
|
||||
};
|
||||
|
||||
const canMoveCoin = (fromIndex: number, toIndex: number): boolean => {
|
||||
if (fromIndex <= toIndex) return false; // Can only move left
|
||||
if (toIndex < 0) return false; // Can't move off the track
|
||||
if (gameState.track[toIndex] !== 'empty') return false; // Can't move to a position with a coin
|
||||
|
||||
// Check if there are any coins in the way
|
||||
for (let i = toIndex + 1; i < fromIndex; i++) {
|
||||
if (gameState.track[i] !== 'empty') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const canTakeCoin = (index: number): boolean => {
|
||||
// Can only take the leftmost coin
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (gameState.track[i] !== 'empty') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return gameState.track[index] !== 'empty';
|
||||
};
|
||||
|
||||
const getValidMovePositions = (fromIndex: number): number[] => {
|
||||
const validPositions: number[] = [];
|
||||
for (let i = 0; i < fromIndex; i++) {
|
||||
if (canMoveCoin(fromIndex, i)) {
|
||||
validPositions.push(i);
|
||||
}
|
||||
}
|
||||
return validPositions;
|
||||
};
|
||||
|
||||
const handleCellClick = (index: number) => {
|
||||
if (gameState.winner !== null || !gameState.gameStarted) return;
|
||||
|
||||
const cellState = gameState.track[index];
|
||||
|
||||
// If a coin is selected, try to move it
|
||||
if (gameState.selectedCoin !== null) {
|
||||
// If clicking on the same coin, deselect it
|
||||
if (gameState.selectedCoin === index) {
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
selectedCoin: null
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (canMoveCoin(gameState.selectedCoin, index)) {
|
||||
const newTrack = [...gameState.track];
|
||||
newTrack[index] = newTrack[gameState.selectedCoin];
|
||||
newTrack[gameState.selectedCoin] = 'empty';
|
||||
|
||||
const moveDescription = `Player ${gameState.currentPlayer + 1} moved ${newTrack[index] === 'gold' ? 'gold coin' : 'penny'} from position ${gameState.selectedCoin + 1} to position ${index + 1}`;
|
||||
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
track: newTrack,
|
||||
currentPlayer: ((prev.currentPlayer + 1) % 2) as Player,
|
||||
selectedCoin: null,
|
||||
moveHistory: [...prev.moveHistory, moveDescription]
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Select a coin if it's not empty
|
||||
if (cellState !== 'empty') {
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
selectedCoin: index
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTakeCoin = () => {
|
||||
if (gameState.winner !== null || !gameState.gameStarted || gameState.selectedCoin === null) return;
|
||||
if (!canTakeCoin(gameState.selectedCoin)) return;
|
||||
|
||||
const cellState = gameState.track[gameState.selectedCoin];
|
||||
const newTrack = [...gameState.track];
|
||||
newTrack[gameState.selectedCoin] = 'empty';
|
||||
|
||||
const moveDescription = `Player ${gameState.currentPlayer + 1} took ${cellState === 'gold' ? 'gold coin' : 'penny'} from position ${gameState.selectedCoin + 1}`;
|
||||
|
||||
// Check if this is a winning move
|
||||
const isWinningMove = cellState === 'gold';
|
||||
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
track: newTrack,
|
||||
currentPlayer: isWinningMove ? prev.currentPlayer : ((prev.currentPlayer + 1) % 2) as Player,
|
||||
winner: isWinningMove ? prev.currentPlayer : null,
|
||||
selectedCoin: null,
|
||||
moveHistory: [...prev.moveHistory, moveDescription]
|
||||
}));
|
||||
|
||||
if (isWinningMove) {
|
||||
setShowGameOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCell = (cellState: CellState, index: number) => {
|
||||
const isSelected = gameState.selectedCoin === index;
|
||||
const validPositions = gameState.selectedCoin !== null ? getValidMovePositions(gameState.selectedCoin) : [];
|
||||
const isValidMoveTarget = validPositions.includes(index);
|
||||
const isClickable = gameState.gameStarted && (cellState !== 'empty' || gameState.selectedCoin !== null);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`
|
||||
relative w-14 h-14 border-2 rounded-lg flex items-center justify-center cursor-pointer
|
||||
transition-all duration-200 hover:scale-105 shadow-sm
|
||||
${isSelected ? 'border-blue-500 bg-blue-50 shadow-md' : 'border-gray-300 bg-white'}
|
||||
${isValidMoveTarget ? 'border-green-400 bg-green-50 shadow-md' : ''}
|
||||
${!isClickable ? 'cursor-default hover:scale-100' : 'hover:shadow-md'}
|
||||
`}
|
||||
onClick={() => handleCellClick(index)}
|
||||
>
|
||||
{cellState === 'gold' && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Crown className="w-7 h-7 text-yellow-500 drop-shadow-sm" />
|
||||
</div>
|
||||
)}
|
||||
{cellState === 'penny' && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Circle className="w-7 h-7 text-orange-500 fill-orange-500 drop-shadow-sm" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute -bottom-7 text-xs text-gray-500 font-mono font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getLeftmostCoin = (): { index: number; type: string } | null => {
|
||||
for (let i = 0; i < gameState.track.length; i++) {
|
||||
if (gameState.track[i] !== 'empty') {
|
||||
return { index: i, type: gameState.track[i] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const leftmostCoin = getLeftmostCoin();
|
||||
const canTakeSelectedCoin = gameState.selectedCoin !== null && canTakeCoin(gameState.selectedCoin);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="text-center pb-6">
|
||||
<CardTitle className="flex items-center justify-center gap-3 text-3xl">
|
||||
<Crown className="w-8 h-8 text-yellow-500" />
|
||||
The Gold Coin Game
|
||||
<Crown className="w-8 h-8 text-yellow-500" />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg max-w-2xl mx-auto">
|
||||
A strategic two-player game where you move coins to the left or take the leftmost coin.
|
||||
The player who takes the gold coin wins!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
{/* Game Controls */}
|
||||
<div className="flex justify-center gap-4">
|
||||
{!gameState.gameStarted ? (
|
||||
<Button onClick={initializeGame} size="lg" className="px-8">
|
||||
Start Game
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={resetGame} variant="outline" size="lg" className="px-8">
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reset Game
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setShowRules(true)} variant="outline" size="lg" className="px-8">
|
||||
<BookOpen className="w-4 h-4 mr-2" />
|
||||
Rules
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Game Status */}
|
||||
{gameState.gameStarted && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between p-6 bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl border">
|
||||
<div className="flex items-center gap-4 mb-4 sm:mb-0">
|
||||
<span className="font-semibold text-lg">Current Player:</span>
|
||||
<Badge
|
||||
style={{ backgroundColor: playerColors[gameState.currentPlayer] }}
|
||||
className="text-white text-lg px-4 py-2"
|
||||
>
|
||||
{playerNames[gameState.currentPlayer]}
|
||||
</Badge>
|
||||
</div>
|
||||
{gameState.selectedCoin !== null && (
|
||||
<div className="text-sm text-blue-700 bg-blue-100 px-4 py-2 rounded-lg font-medium">
|
||||
Selected coin at position {gameState.selectedCoin + 1}. Click a position to move it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Game Board */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-gray-100 p-8 rounded-2xl shadow-inner">
|
||||
<div className="flex gap-3 items-end">
|
||||
{gameState.track.map((cellState, index) => (
|
||||
<div key={index} className="flex flex-col items-center">
|
||||
{renderCell(cellState, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-center">
|
||||
<div className="inline-block bg-gray-50 px-6 py-3 rounded-lg border">
|
||||
<p className="text-sm text-gray-700 font-medium">
|
||||
{!gameState.gameStarted ? (
|
||||
"Click 'Start Game' to begin with a randomized board"
|
||||
) : gameState.selectedCoin !== null ? (
|
||||
canTakeSelectedCoin ? (
|
||||
"Use the take button below to remove this coin"
|
||||
) : (
|
||||
"Click a highlighted position to move the selected coin"
|
||||
)
|
||||
) : (
|
||||
"Click on a coin to select it, then click a position to move it left, or take the leftmost coin"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Take Coin Button */}
|
||||
{canTakeSelectedCoin && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={handleTakeCoin}
|
||||
size="lg"
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-8"
|
||||
>
|
||||
Take {gameState.track[gameState.selectedCoin!] === 'gold' ? 'Gold Coin' : 'Penny'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Move History */}
|
||||
{gameState.moveHistory.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg text-center">Move History</h3>
|
||||
<div className="max-h-40 overflow-y-auto space-y-2 bg-gray-50 p-4 rounded-lg">
|
||||
{gameState.moveHistory.map((move, index) => (
|
||||
<div key={index} className="text-sm text-gray-700 bg-white p-3 rounded border-l-4 border-blue-500">
|
||||
{move}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rules Dialog */}
|
||||
<Dialog open={showRules} onOpenChange={setShowRules}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
The Gold Coin Game Rules
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-lg">Objective</h4>
|
||||
<p className="text-gray-700">Be the first player to acquire the gold coin and win the game!</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-lg">Setup</h4>
|
||||
<p className="text-gray-700">The game starts with coins placed on a 15-position track. The gold coin is positioned farthest to the right.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-lg">On Your Turn</h4>
|
||||
<p className="text-gray-700 mb-3">You have two options:</p>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-4 text-gray-700">
|
||||
<li><strong>Move a coin:</strong> Move any coin to the left by one or more spaces. A coin cannot jump over another coin.</li>
|
||||
<li><strong>Take a coin:</strong> Take the leftmost coin on the track into your possession.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-lg">Winning</h4>
|
||||
<p className="text-gray-700">The player who successfully takes the gold coin is declared the winner!</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-lg">Strategy Tips</h4>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4 text-gray-700">
|
||||
<li>Try to control the position of the gold coin</li>
|
||||
<li>Use pennies to block your opponent's moves</li>
|
||||
<li>Plan several moves ahead</li>
|
||||
<li>Don't always take the first available coin</li>
|
||||
<li>Consider the spacing between coins carefully</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Game Over Dialog */}
|
||||
<Dialog open={showGameOver} onOpenChange={setShowGameOver}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-center gap-2">
|
||||
<Trophy className="w-6 h-6 text-yellow-500" />
|
||||
Game Over!
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Crown className="w-10 h-10 text-yellow-500" />
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
{playerNames[gameState.winner!]} wins!
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Congratulations! {playerNames[gameState.winner!]} successfully acquired the gold coin.
|
||||
</p>
|
||||
<Button onClick={resetGame} className="w-full" size="lg">
|
||||
Play Again
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoldCoinGame;
|
||||
417
src/components/interactives/GridTilingPuzzle.tsx
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { CheckCircle, XCircle, Info, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
interface Rectangle {
|
||||
id: number;
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
endRow: number;
|
||||
endCol: number;
|
||||
}
|
||||
|
||||
interface GridTilingPuzzleProps {
|
||||
}
|
||||
|
||||
const GridTilingPuzzle: React.FC<GridTilingPuzzleProps> = () => {
|
||||
const [gridSize, setGridSize] = useState<number>(9);
|
||||
const [rectangles, setRectangles] = useState<Rectangle[]>([]);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [startCell, setStartCell] = useState<{ row: number; col: number } | null>(null);
|
||||
const [hoverCell, setHoverCell] = useState<{ row: number; col: number } | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
uncoveredCount: number;
|
||||
} | null>(null);
|
||||
|
||||
const specialSizes = [4, 9, 25];
|
||||
|
||||
// Different shades of blue for rectangles - starting very dark and getting lighter
|
||||
const blueShades = [
|
||||
'bg-blue-900',
|
||||
'bg-blue-800',
|
||||
'bg-blue-700',
|
||||
'bg-blue-600',
|
||||
'bg-blue-500',
|
||||
'bg-blue-400',
|
||||
'bg-blue-300',
|
||||
'bg-blue-200'
|
||||
];
|
||||
|
||||
const resetPuzzle = () => {
|
||||
setRectangles([]);
|
||||
setIsDrawing(false);
|
||||
setStartCell(null);
|
||||
setHoverCell(null);
|
||||
setIsValidating(false);
|
||||
setValidationResult(null);
|
||||
};
|
||||
|
||||
const isCellCovered = (row: number, col: number): boolean => {
|
||||
return rectangles.some(rect =>
|
||||
row >= rect.startRow && row <= rect.endRow &&
|
||||
col >= rect.startCol && col <= rect.endCol
|
||||
);
|
||||
};
|
||||
|
||||
const isCellInRectangle = (row: number, col: number, rect: Rectangle): boolean => {
|
||||
return row >= rect.startRow && row <= rect.endRow &&
|
||||
col >= rect.startCol && col <= rect.endCol;
|
||||
};
|
||||
|
||||
const getCellClass = (row: number, col: number): string => {
|
||||
let classes = 'border border-gray-300 cursor-pointer transition-colors';
|
||||
|
||||
// Check if cell is covered by any rectangle
|
||||
const coveredRect = rectangles.find(rect => isCellInRectangle(row, col, rect));
|
||||
if (coveredRect) {
|
||||
const rectIndex = rectangles.findIndex(rect => rect.id === coveredRect.id);
|
||||
const blueShade = blueShades[rectIndex % blueShades.length];
|
||||
classes += ` ${blueShade} hover:${blueShade.replace('bg-', 'bg-').replace('-', '-')}`;
|
||||
return classes;
|
||||
}
|
||||
|
||||
// Check if this is the start cell
|
||||
if (startCell && startCell.row === row && startCell.col === col) {
|
||||
classes += ' bg-green-500 hover:bg-green-600';
|
||||
return classes;
|
||||
}
|
||||
|
||||
// Check if this would be a valid completion (no overlap)
|
||||
if (startCell && hoverCell && !isCellCovered(row, col)) {
|
||||
const minRow = Math.min(startCell.row, hoverCell.row);
|
||||
const maxRow = Math.max(startCell.row, hoverCell.row);
|
||||
const minCol = Math.min(startCell.col, hoverCell.col);
|
||||
const maxCol = Math.max(startCell.col, hoverCell.col);
|
||||
|
||||
if (row >= minRow && row <= maxRow && col >= minCol && col <= maxCol) {
|
||||
// Check if this preview rectangle would overlap with any existing rectangles
|
||||
const wouldOverlap = rectangles.some(existingRect => {
|
||||
return !(maxRow < existingRect.startRow ||
|
||||
minRow > existingRect.endRow ||
|
||||
maxCol < existingRect.startCol ||
|
||||
minCol > existingRect.endCol);
|
||||
});
|
||||
|
||||
if (!wouldOverlap) {
|
||||
classes += ' bg-yellow-300 hover:bg-yellow-400';
|
||||
return classes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
classes += ' bg-white hover:bg-gray-100';
|
||||
return classes;
|
||||
};
|
||||
|
||||
const handleCellClick = (row: number, col: number) => {
|
||||
if (isCellCovered(row, col)) return;
|
||||
|
||||
if (!isDrawing) {
|
||||
// Start drawing a rectangle
|
||||
setIsDrawing(true);
|
||||
setStartCell({ row, col });
|
||||
} else {
|
||||
// Complete the rectangle
|
||||
if (startCell) {
|
||||
const newRect: Rectangle = {
|
||||
id: Date.now(),
|
||||
startRow: Math.min(startCell.row, row),
|
||||
startCol: Math.min(startCell.col, col),
|
||||
endRow: Math.max(startCell.row, row),
|
||||
endCol: Math.max(startCell.col, col)
|
||||
};
|
||||
|
||||
// Check if the new rectangle would overlap with any existing rectangles
|
||||
const wouldOverlap = rectangles.some(existingRect => {
|
||||
return !(newRect.endRow < existingRect.startRow ||
|
||||
newRect.startRow > existingRect.endRow ||
|
||||
newRect.endCol < existingRect.startCol ||
|
||||
newRect.startCol > existingRect.endCol);
|
||||
});
|
||||
|
||||
if (wouldOverlap) {
|
||||
// Don't place the rectangle if it would overlap
|
||||
setIsDrawing(false);
|
||||
setStartCell(null);
|
||||
setHoverCell(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setRectangles(prev => [...prev, newRect]);
|
||||
setIsDrawing(false);
|
||||
setStartCell(null);
|
||||
setHoverCell(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCellHover = (row: number, col: number) => {
|
||||
if (isDrawing && startCell) {
|
||||
setHoverCell({ row, col });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCellLeave = () => {
|
||||
setHoverCell(null);
|
||||
};
|
||||
|
||||
const validateSolution = () => {
|
||||
setIsValidating(true);
|
||||
|
||||
// Check if each row and column has exactly one uncovered square
|
||||
let uncoveredCount = 0;
|
||||
const rowUncovered = new Array(gridSize).fill(0);
|
||||
const colUncovered = new Array(gridSize).fill(0);
|
||||
|
||||
for (let row = 0; row < gridSize; row++) {
|
||||
for (let col = 0; col < gridSize; col++) {
|
||||
if (!isCellCovered(row, col)) {
|
||||
uncoveredCount++;
|
||||
rowUncovered[row]++;
|
||||
colUncovered[col]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = rowUncovered.every(count => count === 1) &&
|
||||
colUncovered.every(count => count === 1);
|
||||
|
||||
let message = '';
|
||||
if (isValid) {
|
||||
message = `Perfect! Your solution is valid. You used ${rectangles.length} rectangles.`;
|
||||
} else {
|
||||
const invalidRows = rowUncovered.map((count, i) => count !== 1 ? i + 1 : null).filter(Boolean);
|
||||
const invalidCols = colUncovered.map((count, i) => count !== 1 ? i + 1 : null).filter(Boolean);
|
||||
message = `Invalid solution. Rows ${invalidRows.join(', ')} and columns ${invalidCols.join(', ')} don't have exactly one uncovered square.`;
|
||||
}
|
||||
|
||||
setValidationResult({ isValid, message, uncoveredCount });
|
||||
setIsValidating(false);
|
||||
};
|
||||
|
||||
const removeRectangle = (rectId: number) => {
|
||||
setRectangles(prev => prev.filter(rect => rect.id !== rectId));
|
||||
setValidationResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
<Card className="border-primary/20">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
|
||||
Grid Tiling Puzzle
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
This is P6 on IMO 2025. The given grid size was 2025 in the original problem.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Place rectangular tiles on the grid so that each row and each column has exactly one uncovered square. What's the smallest number of rectangles you can use?
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4 items-center">
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium">Grid Size: {gridSize}×{gridSize}</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-muted-foreground">3</span>
|
||||
<input
|
||||
type="range"
|
||||
min="3"
|
||||
max="25"
|
||||
value={gridSize}
|
||||
onChange={(e) => {
|
||||
setGridSize(parseInt(e.target.value));
|
||||
resetPuzzle();
|
||||
}}
|
||||
className={`w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${
|
||||
specialSizes.includes(gridSize) ? 'slider-special' : 'slider-normal'
|
||||
}`}
|
||||
style={{
|
||||
'--thumb-color': specialSizes.includes(gridSize) ? '#10b981' : '#6b7280'
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">25</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={resetPuzzle} variant="outline" size="sm">
|
||||
Reset Puzzle
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRectangles(prev => prev.slice(0, -1));
|
||||
setValidationResult(null);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={rectangles.length === 0}
|
||||
>
|
||||
Undo Last Move
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
Rectangles: {rectangles.length}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={specialSizes.includes(gridSize) ? "default" : "outline"}
|
||||
className={`text-sm ${specialSizes.includes(gridSize) ? 'bg-green-500 hover:bg-green-600' : ''}`}
|
||||
>
|
||||
Grid: {gridSize}×{gridSize}
|
||||
{specialSizes.includes(gridSize) && <span className="ml-1">⭐</span>}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Instructions */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowInstructions(!showInstructions)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Instructions</CardTitle>
|
||||
{showInstructions ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showInstructions && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-4">
|
||||
<ul className="list-disc list-inside space-y-2 text-sm">
|
||||
<li>Click on any empty square to start drawing a rectangle</li>
|
||||
<li>Click on another square to complete the rectangle</li>
|
||||
<li>Each row and column must have exactly one uncovered square</li>
|
||||
<li>Rectangles cannot overlap</li>
|
||||
<li>Try to use the minimum number of rectangles possible</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Info className="w-4 h-4" />
|
||||
<span>Legend: Green = Start, Yellow = Preview, Blue shades = Placed Rectangles</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Grid */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="grid gap-0 border-2 border-gray-400"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridSize}, minmax(0, 1fr))`,
|
||||
width: `${Math.min(600, 50 * gridSize)}px`,
|
||||
height: `${Math.min(600, 50 * gridSize)}px`
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: gridSize * gridSize }, (_, index) => {
|
||||
const row = Math.floor(index / gridSize);
|
||||
const col = index % gridSize;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={getCellClass(row, col)}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onClick={() => handleCellClick(row, col)}
|
||||
onMouseEnter={() => handleCellHover(row, col)}
|
||||
onMouseLeave={handleCellLeave}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Validation */}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={validateSolution}
|
||||
disabled={isValidating || rectangles.length === 0}
|
||||
size="lg"
|
||||
className="px-8"
|
||||
>
|
||||
{isValidating ? 'Validating...' : 'Validate Solution'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{validationResult && (
|
||||
<Card className={validationResult.isValid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{validationResult.isValid ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-medium ${validationResult.isValid ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{validationResult.message}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Total uncovered squares: {validationResult.uncoveredCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Rectangle List */}
|
||||
{rectangles.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Placed Rectangles</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{rectangles.map((rect, index) => (
|
||||
<div key={rect.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">Rectangle {index + 1}:</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">
|
||||
({rect.startRow + 1},{rect.startCol + 1}) to ({rect.endRow + 1},{rect.endCol + 1})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeRectangle(rect.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Social Share */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridTilingPuzzle;
|
||||
648
src/components/interactives/GuessingGame.tsx
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Brain, User, Trophy, Target, HelpCircle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import StandardGuessingMode from './guessing-game/StandardGuessingMode';
|
||||
import QuestionGuessingMode from './guessing-game/QuestionGuessingMode';
|
||||
|
||||
interface GuessingGameProps {
|
||||
}
|
||||
|
||||
type GameMode = 'user-guesses' | 'computer-asks';
|
||||
type UserGuessingMode = 'standard' | 'questions';
|
||||
type ComputerStrategy = 'random' | 'linear' | 'binary';
|
||||
type GameState = 'setup' | 'playing' | 'won' | 'contradiction';
|
||||
|
||||
interface Question {
|
||||
text: string;
|
||||
isCorrect: (num: number) => boolean;
|
||||
}
|
||||
|
||||
const GuessingGame: React.FC<GuessingGameProps> = () => {
|
||||
// Game settings
|
||||
const [range, setRange] = useState([50]);
|
||||
const [gameMode, setGameMode] = useState<GameMode>('user-guesses');
|
||||
const [userGuessingMode, setUserGuessingMode] = useState<UserGuessingMode>('standard');
|
||||
const [computerStrategy, setComputerStrategy] = useState<ComputerStrategy>('binary');
|
||||
|
||||
// Game state
|
||||
const [gameState, setGameState] = useState<GameState>('setup');
|
||||
const [secretNumber, setSecretNumber] = useState<number | null>(null);
|
||||
const [userGuess, setUserGuess] = useState('');
|
||||
const [guesses, setGuesses] = useState<number[]>([]);
|
||||
const [feedback, setFeedback] = useState<string>('');
|
||||
const [validNumbers, setValidNumbers] = useState<Set<number>>(new Set());
|
||||
|
||||
// Computer asking mode
|
||||
const [possibleNumbers, setPossibleNumbers] = useState<number[]>([]);
|
||||
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
|
||||
const [questionsAsked, setQuestionsAsked] = useState<string[]>([]);
|
||||
const [userScore, setUserScore] = useState(0);
|
||||
const [currentSearchIndex, setCurrentSearchIndex] = useState(0);
|
||||
|
||||
// Question mode stats
|
||||
const [questionModeStats, setQuestionModeStats] = useState<{questionsAsked: number, finalGuesses: number}>({questionsAsked: 0, finalGuesses: 0});
|
||||
|
||||
const maxRange = range[0];
|
||||
|
||||
// Prime checker utility
|
||||
const isPrime = (num: number): boolean => {
|
||||
if (num < 2) return false;
|
||||
for (let i = 2; i <= Math.sqrt(num); i++) {
|
||||
if (num % i === 0) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Random question generator
|
||||
const generateRandomQuestion = useCallback((): Question => {
|
||||
const questions = [
|
||||
{
|
||||
text: "Is your number prime?",
|
||||
isCorrect: (num: number) => isPrime(num)
|
||||
},
|
||||
{
|
||||
text: "Is your number even?",
|
||||
isCorrect: (num: number) => num % 2 === 0
|
||||
},
|
||||
{
|
||||
text: "Is your number divisible by 3?",
|
||||
isCorrect: (num: number) => num % 3 === 0
|
||||
},
|
||||
{
|
||||
text: "Is your number divisible by 5?",
|
||||
isCorrect: (num: number) => num % 5 === 0
|
||||
},
|
||||
{
|
||||
text: "Is your number a perfect square?",
|
||||
isCorrect: (num: number) => Math.sqrt(num) % 1 === 0
|
||||
},
|
||||
{
|
||||
text: "Is your number greater than half the maximum range?",
|
||||
isCorrect: (num: number) => num > maxRange / 2
|
||||
},
|
||||
{
|
||||
text: "Does your number contain the digit 5?",
|
||||
isCorrect: (num: number) => num.toString().includes('5')
|
||||
}
|
||||
];
|
||||
|
||||
return questions[Math.floor(Math.random() * questions.length)];
|
||||
}, [maxRange]);
|
||||
|
||||
// Linear search question generator
|
||||
const generateLinearQuestion = useCallback((): Question => {
|
||||
const currentNum = currentSearchIndex + 1;
|
||||
return {
|
||||
text: `Is your number ${currentNum}?`,
|
||||
isCorrect: (num: number) => num === currentNum
|
||||
};
|
||||
}, [currentSearchIndex]);
|
||||
|
||||
// Binary search question generator
|
||||
const generateBinaryQuestion = useCallback((): Question => {
|
||||
if (possibleNumbers.length <= 1) {
|
||||
return {
|
||||
text: `Is your number ${possibleNumbers[0]}?`,
|
||||
isCorrect: (num: number) => num === possibleNumbers[0]
|
||||
};
|
||||
}
|
||||
|
||||
const midIndex = Math.floor(possibleNumbers.length / 2);
|
||||
const midValue = possibleNumbers[midIndex];
|
||||
|
||||
return {
|
||||
text: `Is your number ${midValue} or less?`,
|
||||
isCorrect: (num: number) => num <= midValue
|
||||
};
|
||||
}, [possibleNumbers]);
|
||||
|
||||
// Calculate valid numbers based on guesses and feedback
|
||||
const updateValidNumbers = useCallback((guesses: number[], secretNumber: number | null) => {
|
||||
if (guesses.length === 0 || !secretNumber) {
|
||||
const allNumbers = new Set(Array.from({ length: maxRange }, (_, i) => i + 1));
|
||||
setValidNumbers(allNumbers);
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = new Set<number>();
|
||||
for (let num = 1; num <= maxRange; num++) {
|
||||
let isValid = true;
|
||||
|
||||
for (const guess of guesses) {
|
||||
if (guess === secretNumber) break; // Game would be over
|
||||
|
||||
if (guess < secretNumber) {
|
||||
// Guess was too low, so number must be higher than guess
|
||||
if (num <= guess) {
|
||||
isValid = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Guess was too high, so number must be lower than guess
|
||||
if (num >= guess) {
|
||||
isValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
valid.add(num);
|
||||
}
|
||||
}
|
||||
|
||||
setValidNumbers(valid);
|
||||
}, [maxRange]);
|
||||
|
||||
// Start new game
|
||||
const startGame = () => {
|
||||
if (gameMode === 'user-guesses') {
|
||||
const secret = Math.floor(Math.random() * maxRange) + 1;
|
||||
setSecretNumber(secret);
|
||||
setGuesses([]);
|
||||
setFeedback('');
|
||||
const allNumbers = new Set(Array.from({ length: maxRange }, (_, i) => i + 1));
|
||||
setValidNumbers(allNumbers);
|
||||
} else {
|
||||
const numbers = Array.from({ length: maxRange }, (_, i) => i + 1);
|
||||
setPossibleNumbers(numbers);
|
||||
setQuestionsAsked([]);
|
||||
setUserScore(0);
|
||||
setCurrentSearchIndex(0);
|
||||
setSecretNumber(null);
|
||||
}
|
||||
|
||||
setGameState('playing');
|
||||
setUserGuess('');
|
||||
};
|
||||
|
||||
// Generate next question for computer
|
||||
const generateNextQuestion = useCallback(() => {
|
||||
if (possibleNumbers.length <= 1) {
|
||||
setGameState('won');
|
||||
return;
|
||||
}
|
||||
|
||||
let question: Question;
|
||||
|
||||
switch (computerStrategy) {
|
||||
case 'random':
|
||||
question = generateRandomQuestion();
|
||||
break;
|
||||
case 'linear':
|
||||
question = generateLinearQuestion();
|
||||
break;
|
||||
case 'binary':
|
||||
question = generateBinaryQuestion();
|
||||
break;
|
||||
}
|
||||
|
||||
setCurrentQuestion(question);
|
||||
}, [computerStrategy, possibleNumbers, generateRandomQuestion, generateLinearQuestion, generateBinaryQuestion]);
|
||||
|
||||
// Handle user's guess (both input and number click)
|
||||
const handleGuess = (guessValue?: number) => {
|
||||
const guess = guessValue || parseInt(userGuess);
|
||||
if (isNaN(guess) || guess < 1 || guess > maxRange) {
|
||||
setFeedback('Please enter a valid number within the range.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newGuesses = [...guesses, guess];
|
||||
setGuesses(newGuesses);
|
||||
|
||||
if (guess === secretNumber) {
|
||||
setGameState('won');
|
||||
setFeedback(`Congratulations! You found the number in ${newGuesses.length} guesses!`);
|
||||
} else {
|
||||
if (guess < secretNumber!) {
|
||||
setFeedback('Too low! Try a higher number.');
|
||||
} else {
|
||||
setFeedback('Too high! Try a lower number.');
|
||||
}
|
||||
// Update valid numbers based on new feedback
|
||||
updateValidNumbers(newGuesses, secretNumber);
|
||||
}
|
||||
|
||||
setUserGuess('');
|
||||
};
|
||||
|
||||
// Handle computer's question answer
|
||||
const handleQuestionAnswer = (answer: boolean) => {
|
||||
if (!currentQuestion) return;
|
||||
|
||||
const questionText = `${currentQuestion.text} → ${answer ? 'Yes' : 'No'}`;
|
||||
const newQuestionsAsked = [...questionsAsked, questionText];
|
||||
setQuestionsAsked(newQuestionsAsked);
|
||||
setUserScore(userScore + 1);
|
||||
|
||||
let newPossibleNumbers = [...possibleNumbers];
|
||||
|
||||
if (computerStrategy === 'random') {
|
||||
// Filter based on the answer to the random question
|
||||
newPossibleNumbers = possibleNumbers.filter(num =>
|
||||
currentQuestion.isCorrect(num) === answer
|
||||
);
|
||||
} else if (computerStrategy === 'linear') {
|
||||
if (answer) {
|
||||
// Found the number
|
||||
setGameState('won');
|
||||
return;
|
||||
} else {
|
||||
// Move to next number in linear search
|
||||
setCurrentSearchIndex(currentSearchIndex + 1);
|
||||
newPossibleNumbers = possibleNumbers.filter(num => num > currentSearchIndex + 1);
|
||||
}
|
||||
} else if (computerStrategy === 'binary') {
|
||||
const midIndex = Math.floor(possibleNumbers.length / 2);
|
||||
const midValue = possibleNumbers[midIndex];
|
||||
|
||||
if (possibleNumbers.length === 1) {
|
||||
setGameState('won');
|
||||
return;
|
||||
}
|
||||
|
||||
if (answer) {
|
||||
// Number is <= midValue
|
||||
newPossibleNumbers = possibleNumbers.slice(0, midIndex + 1);
|
||||
} else {
|
||||
// Number is > midValue
|
||||
newPossibleNumbers = possibleNumbers.slice(midIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for contradictions - if no numbers remain, the answers are contradictory
|
||||
if (newPossibleNumbers.length === 0) {
|
||||
setGameState('contradiction');
|
||||
return;
|
||||
}
|
||||
|
||||
setPossibleNumbers(newPossibleNumbers);
|
||||
};
|
||||
|
||||
// Generate question when computer is asking
|
||||
useEffect(() => {
|
||||
if (gameState === 'playing' && gameMode === 'computer-asks') {
|
||||
generateNextQuestion();
|
||||
}
|
||||
}, [gameState, gameMode, possibleNumbers, generateNextQuestion]);
|
||||
|
||||
const resetGame = () => {
|
||||
setGameState('setup');
|
||||
setSecretNumber(null);
|
||||
setUserGuess('');
|
||||
setGuesses([]);
|
||||
setFeedback('');
|
||||
setPossibleNumbers([]);
|
||||
setCurrentQuestion(null);
|
||||
setQuestionsAsked([]);
|
||||
setUserScore(0);
|
||||
setCurrentSearchIndex(0);
|
||||
setQuestionModeStats({questionsAsked: 0, finalGuesses: 0});
|
||||
};
|
||||
|
||||
const getStrategyDescription = (strategy: ComputerStrategy) => {
|
||||
switch (strategy) {
|
||||
case 'random':
|
||||
return 'Asks random mathematical questions about your number';
|
||||
case 'linear':
|
||||
return 'Checks each number sequentially from 1 upward';
|
||||
case 'binary':
|
||||
return 'Uses binary search to efficiently narrow down the range';
|
||||
}
|
||||
};
|
||||
|
||||
const getOptimalGuesses = () => {
|
||||
return Math.ceil(Math.log2(maxRange));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-3xl font-bold flex items-center justify-center gap-2">
|
||||
<Target className="w-8 h-8 text-accent" />
|
||||
Number Guessing Game
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg">
|
||||
Experience different search strategies in this interactive guessing game
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{gameState === 'setup' && (
|
||||
<div className="space-y-6">
|
||||
{/* Range Selection */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-lg font-semibold">Number Range: 1 to {maxRange}</Label>
|
||||
<div className="space-y-2">
|
||||
<Slider
|
||||
value={range}
|
||||
onValueChange={setRange}
|
||||
min={10}
|
||||
max={512}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>Min: 10</span>
|
||||
<span>Current: {maxRange}</span>
|
||||
<span>Max: 512</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Mode Selection */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-lg font-semibold">Game Mode</Label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${gameMode === 'user-guesses' ? 'ring-2 ring-accent' : ''}`}
|
||||
onClick={() => setGameMode('user-guesses')}
|
||||
>
|
||||
<CardContent className="p-4 text-center space-y-2">
|
||||
<User className="w-8 h-8 text-accent mx-auto" />
|
||||
<h3 className="font-semibold">You Guess</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Computer thinks of a number, you try to guess it
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Optimal: {getOptimalGuesses()} guesses
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${gameMode === 'computer-asks' ? 'ring-2 ring-accent' : ''}`}
|
||||
onClick={() => setGameMode('computer-asks')}
|
||||
>
|
||||
<CardContent className="p-4 text-center space-y-2">
|
||||
<Brain className="w-8 h-8 text-accent mx-auto" />
|
||||
<h3 className="font-semibold">Computer Asks</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You think of a number, computer asks questions
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Earn points for each question
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Guessing Mode Selection */}
|
||||
{gameMode === 'user-guesses' && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-lg font-semibold">Guessing Style</Label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${userGuessingMode === 'standard' ? 'ring-2 ring-accent' : ''}`}
|
||||
onClick={() => setUserGuessingMode('standard')}
|
||||
>
|
||||
<CardContent className="p-4 text-center space-y-2">
|
||||
<Target className="w-6 h-6 text-accent mx-auto" />
|
||||
<h4 className="font-semibold">Standard (High/Low)</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Traditional guessing with "too high" or "too low" feedback
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${userGuessingMode === 'questions' ? 'ring-2 ring-accent' : ''}`}
|
||||
onClick={() => setUserGuessingMode('questions')}
|
||||
>
|
||||
<CardContent className="p-4 text-center space-y-2">
|
||||
<HelpCircle className="w-6 h-6 text-accent mx-auto" />
|
||||
<h4 className="font-semibold">Ask Questions</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ask yes/no questions about mathematical properties
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Computer Strategy Selection */}
|
||||
{gameMode === 'computer-asks' && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-lg font-semibold">Computer Strategy</Label>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{(['random', 'linear', 'binary'] as ComputerStrategy[]).map(strategy => (
|
||||
<Card
|
||||
key={strategy}
|
||||
className={`cursor-pointer transition-all ${computerStrategy === strategy ? 'ring-2 ring-accent' : ''}`}
|
||||
onClick={() => setComputerStrategy(strategy)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold capitalize">{strategy} Search</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getStrategyDescription(strategy)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={computerStrategy === strategy ? "default" : "outline"}>
|
||||
{strategy === 'binary' ? `~${getOptimalGuesses()} questions` :
|
||||
strategy === 'linear' ? `~${Math.ceil(maxRange/2)} questions` :
|
||||
'? questions'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={startGame} className="w-full" size="lg">
|
||||
Start Game
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState === 'playing' && gameMode === 'user-guesses' && userGuessingMode === 'standard' && (
|
||||
<StandardGuessingMode
|
||||
maxRange={maxRange}
|
||||
secretNumber={secretNumber!}
|
||||
guesses={guesses}
|
||||
feedback={feedback}
|
||||
validNumbers={validNumbers}
|
||||
userGuess={userGuess}
|
||||
setUserGuess={setUserGuess}
|
||||
onGuess={handleGuess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gameState === 'playing' && gameMode === 'user-guesses' && userGuessingMode === 'questions' && (
|
||||
<QuestionGuessingMode
|
||||
maxRange={maxRange}
|
||||
guesses={guesses}
|
||||
feedback={feedback}
|
||||
validNumbers={validNumbers}
|
||||
userGuess={userGuess}
|
||||
setUserGuess={setUserGuess}
|
||||
onGuess={handleGuess}
|
||||
onUpdateValidNumbers={setValidNumbers}
|
||||
onWin={(questionsAsked, finalGuesses) => {
|
||||
setQuestionModeStats({questionsAsked, finalGuesses});
|
||||
setGameState('won');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gameState === 'playing' && gameMode === 'computer-asks' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-xl font-semibold">Think of a number between 1 and {maxRange}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Strategy: {computerStrategy.charAt(0).toUpperCase() + computerStrategy.slice(1)} Search
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Badge variant="outline">
|
||||
<Trophy className="w-4 h-4 mr-1" />
|
||||
Score: {userScore}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
Remaining: {possibleNumbers.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentQuestion && (
|
||||
<Card className="text-center">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<HelpCircle className="w-12 h-12 text-accent mx-auto" />
|
||||
<h4 className="text-lg font-semibold">{currentQuestion.text}</h4>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button
|
||||
onClick={() => handleQuestionAnswer(true)}
|
||||
className="bg-success hover:bg-success/90"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleQuestionAnswer(false)}
|
||||
variant="destructive"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{questionsAsked.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">Questions Asked:</h4>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{questionsAsked.map((question, index) => (
|
||||
<div key={index} className="text-sm p-2 bg-muted rounded">
|
||||
{question}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={resetGame} variant="outline" className="w-full">
|
||||
New Game
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState === 'contradiction' && (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="space-y-2">
|
||||
<XCircle className="w-16 h-16 text-destructive mx-auto" />
|
||||
<h3 className="text-2xl font-bold text-destructive">Contradiction Detected!</h3>
|
||||
<p className="text-lg">
|
||||
Your answers are contradictory - no number can satisfy all the conditions you've given.
|
||||
</p>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>Please check your answers in the question history below.</p>
|
||||
<p>You may have accidentally given incorrect responses.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{questionsAsked.length > 0 && (
|
||||
<div className="space-y-2 text-left">
|
||||
<h4 className="font-semibold text-center">Your Answer History:</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-lg p-3 bg-muted/30">
|
||||
{questionsAsked.map((question, index) => (
|
||||
<div key={index} className="text-sm p-2 bg-card rounded border">
|
||||
{question}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={resetGame} size="lg" className="w-full">
|
||||
Start Over
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState === 'won' && (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="space-y-2">
|
||||
<CheckCircle className="w-16 h-16 text-success mx-auto" />
|
||||
<h3 className="text-2xl font-bold text-success">Game Complete!</h3>
|
||||
{gameMode === 'user-guesses' && userGuessingMode === 'standard' ? (
|
||||
<p className="text-lg">
|
||||
You found the number <span className="font-bold">{secretNumber}</span> in{' '}
|
||||
<span className="font-bold">{guesses.length}</span> guesses!
|
||||
</p>
|
||||
) : gameMode === 'user-guesses' && userGuessingMode === 'questions' ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg">
|
||||
You found the number with <span className="font-bold">{questionModeStats.questionsAsked}</span> questions and{' '}
|
||||
<span className="font-bold">{questionModeStats.finalGuesses}</span> final guesses!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total interactions: <span className="font-bold">{questionModeStats.questionsAsked + questionModeStats.finalGuesses}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg">
|
||||
The computer identified your number using the{' '}
|
||||
<span className="font-bold">{computerStrategy}</span> search strategy!
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Badge variant="outline" className="text-lg p-2">
|
||||
<Trophy className="w-5 h-5 mr-2" />
|
||||
Final Score: {userScore} points
|
||||
</Badge>
|
||||
</div>
|
||||
{possibleNumbers.length === 1 && (
|
||||
<p className="text-muted-foreground">
|
||||
Your number was: <span className="font-bold">{possibleNumbers[0]}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={resetGame} size="lg" className="w-full">
|
||||
Play Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuessingGame;
|
||||
325
src/components/interactives/KnightsAndKnavesI.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Statements
|
||||
const STATEMENTS = [
|
||||
{ id: "S", label: "At least one of us is a knave." },
|
||||
{ id: "SNEG", label: "Both of us are knights." },
|
||||
{ id: "AK", label: "A is a knight" },
|
||||
{ id: "AN", label: "A is a knave" },
|
||||
{ id: "BK", label: "B is a knight" },
|
||||
{ id: "BN", label: "B is a knave" },
|
||||
] as const;
|
||||
|
||||
type StatementId = typeof STATEMENTS[number]["id"];
|
||||
|
||||
const META: Record<StatementId, { group: "A" | "B" | "S"; neg: boolean }> = {
|
||||
S: { group: "S", neg: false },
|
||||
SNEG: { group: "S", neg: true },
|
||||
AK: { group: "A", neg: false },
|
||||
AN: { group: "A", neg: true },
|
||||
BK: { group: "B", neg: false },
|
||||
BN: { group: "B", neg: true },
|
||||
};
|
||||
|
||||
const KnightsAndKnavesI = () => {
|
||||
const [inArena, setInArena] = useState<StatementId[]>([]);
|
||||
const [showExplain, setShowExplain] = useState(false);
|
||||
// SEO basics
|
||||
useEffect(() => {
|
||||
const title = "Knights and Knaves – I (Logic Puzzle)";
|
||||
document.title = title;
|
||||
const desc = "Drag statements into the arena to test consistency in a classic Knights and Knaves puzzle.";
|
||||
let meta = document.querySelector('meta[name="description"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "description");
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute("content", desc);
|
||||
|
||||
// canonical
|
||||
let link = document.querySelector('link[rel="canonical"]');
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.setAttribute("rel", "canonical");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.setAttribute("href", window.location.href);
|
||||
}, []);
|
||||
|
||||
const available = useMemo(
|
||||
() => STATEMENTS.filter(s => !inArena.includes(s.id)),
|
||||
[inArena]
|
||||
);
|
||||
|
||||
function onDragStart(e: React.DragEvent<HTMLDivElement>, id: StatementId) {
|
||||
e.dataTransfer.setData("text/plain", id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
function onArenaDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData("text/plain") as StatementId;
|
||||
if (id && !inArena.includes(id)) setInArena(prev => [...prev, id]);
|
||||
}
|
||||
|
||||
function onArenaDragOver(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
function removeFromArena(id: StatementId) {
|
||||
setInArena(prev => prev.filter(x => x !== id));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setInArena([]);
|
||||
}
|
||||
|
||||
// Consistency checker via brute force over assignments (with A's utterance semantics)
|
||||
const consistentWith = (idsArr: StatementId[]) => {
|
||||
const ids = new Set(idsArr);
|
||||
for (const aKnight of [true, false]) {
|
||||
for (const bKnight of [true, false]) {
|
||||
// role constraints
|
||||
if (ids.has("AK") && !aKnight) continue;
|
||||
if (ids.has("AN") && aKnight) continue;
|
||||
if (ids.has("BK") && !bKnight) continue;
|
||||
if (ids.has("BN") && bKnight) continue;
|
||||
if (ids.has("SNEG") && !(aKnight && bKnight)) continue;
|
||||
|
||||
// Content of A's statement: S = "At least one of us is a knave."
|
||||
const Scontent = (!aKnight) || (!bKnight);
|
||||
|
||||
// Implicit linkage: if A is declared knight/knave, his statement's content must match
|
||||
if (ids.has("AK") && !Scontent) continue;
|
||||
if (ids.has("AN") && Scontent) continue;
|
||||
|
||||
// If token S is explicitly included, also enforce truthfulness parity
|
||||
if (ids.has("S")) {
|
||||
if (aKnight !== Scontent) continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return idsArr.length <= 1;
|
||||
};
|
||||
|
||||
const isConsistent = useMemo(() => consistentWith(inArena), [inArena]);
|
||||
|
||||
const arenaClasses = cn(
|
||||
"relative rounded-lg border transition-colors min-h-[320px] grid place-items-center p-6",
|
||||
isConsistent ? "bg-surface border-outline" : "bg-destructive/10 border-destructive"
|
||||
);
|
||||
|
||||
// Partition remaining statements into compatible vs incompatible under current worldview
|
||||
const partitions = useMemo(() => {
|
||||
const remaining = STATEMENTS.filter(s => !inArena.includes(s.id));
|
||||
const incompatible = remaining
|
||||
.filter(s => !consistentWith([...inArena, s.id]))
|
||||
.map(s => s.id);
|
||||
const compatible = remaining
|
||||
.filter(s => !incompatible.includes(s.id))
|
||||
.map(s => s.id);
|
||||
return { compatible, incompatible };
|
||||
}, [inArena]);
|
||||
|
||||
function generateExplanation(idsArr: StatementId[]): string[] {
|
||||
const ids = new Set(idsArr);
|
||||
const lines: string[] = [];
|
||||
|
||||
// Direct role contradictions
|
||||
if (ids.has("AK") && ids.has("AN")) lines.push("A cannot be both a knight and a knave.");
|
||||
if (ids.has("BK") && ids.has("BN")) lines.push("B cannot be both a knight and a knave.");
|
||||
|
||||
// A's utterance linkage
|
||||
if (ids.has("S")) {
|
||||
const forcedA = ids.has("AK") ? true : ids.has("AN") ? false : null;
|
||||
const forcedB = ids.has("BK") ? true : ids.has("BN") ? false : null;
|
||||
|
||||
if (forcedA === true && forcedB === true) {
|
||||
lines.push("If A and B are both knights, then A’s statement “at least one of us is a knave” is false; but a knight cannot assert a falsehood.");
|
||||
}
|
||||
if (forcedA === false) {
|
||||
lines.push("If A is a knave, his statement must be false. “At least one of us is a knave” being false means neither is a knave (both are knights), contradicting “A is a knave” and any claim that B is a knave.");
|
||||
}
|
||||
if (forcedB === true && forcedA === null) {
|
||||
lines.push("B is a knight. If A were a knight, his statement would be false; if A were a knave, his statement would be true. Either way, A’s truthfulness would not match his statement, so no assignment works.");
|
||||
}
|
||||
|
||||
// Enumerate remaining candidate assignments for clarity
|
||||
if (lines.length === 0) {
|
||||
const aVals = forcedA === null ? [true, false] : [forcedA];
|
||||
const bVals = forcedB === null ? [true, false] : [forcedB];
|
||||
for (const aKnight of aVals) {
|
||||
for (const bKnight of bVals) {
|
||||
const content = (!aKnight) || (!bKnight);
|
||||
const ok = (aKnight === content);
|
||||
if (!ok) {
|
||||
const contentTruth = content ? "true" : "false";
|
||||
lines.push(`If A is ${aKnight ? "a knight" : "a knave"} and B is ${bKnight ? "a knight" : "a knave"}, then the content “at least one of us is a knave” is ${contentTruth}, which a ${aKnight ? "knight must tell" : "knave must not tell"}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) lines.push("The selected set cannot be satisfied by any assignment of A and B.");
|
||||
return lines;
|
||||
}
|
||||
|
||||
const explanation = useMemo(() => (isConsistent ? [] : generateExplanation(inArena)), [inArena, isConsistent]);
|
||||
|
||||
useEffect(() => { if (isConsistent) setShowExplain(false); }, [isConsistent]);
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">Knights and Knaves – I</h1>
|
||||
<p className="text-muted-foreground max-w-3xl">
|
||||
Knights always tell the truth, knaves always lie. A says: “At least one of us is a knave.”
|
||||
Drag statements into the arena to test whether they can all be true together.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Available statements */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Potential Truths</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
{partitions.compatible.map(id => {
|
||||
const s = STATEMENTS.find(x => x.id === id)!;
|
||||
const meta = META[s.id];
|
||||
const groupClass = meta.group === "A" ? "logic-a" : meta.group === "B" ? "logic-b" : "logic-s";
|
||||
const styleClass = meta.neg ? "logic-dotted" : "logic-solid";
|
||||
const classes = cn(
|
||||
"cursor-move select-none rounded-md bg-surface-variant px-3 py-2 text-sm text-foreground shadow-card border-2 logic-border",
|
||||
groupClass,
|
||||
styleClass,
|
||||
"ring-1 ring-success/30"
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, s.id)}
|
||||
className={classes}
|
||||
aria-label={`Drag: ${s.label}`}
|
||||
role="button"
|
||||
title="Compatible with current worldview"
|
||||
>
|
||||
{s.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Arena */}
|
||||
<div className="lg:col-span-2">
|
||||
<div
|
||||
className={arenaClasses}
|
||||
onDragOver={onArenaDragOver}
|
||||
onDrop={onArenaDrop}
|
||||
aria-label="Arena drop zone"
|
||||
role="region"
|
||||
>
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className={cn("text-sm font-medium", isConsistent ? "text-success" : "text-destructive")}
|
||||
>{isConsistent ? "Consistent" : "Incompatible set"}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{!isConsistent && (
|
||||
<button
|
||||
onClick={() => setShowExplain((v) => !v)}
|
||||
className="text-destructive hover:text-destructive/80 text-sm"
|
||||
>Explain</button>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
className="text-muted-foreground hover:text-foreground text-sm"
|
||||
>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inArena.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Drag any statement here. Tokens turn green in the arena. If two statements clash,
|
||||
the arena turns red.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{inArena.map(id => {
|
||||
const s = STATEMENTS.find(x => x.id === id)!;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="group inline-flex items-center gap-2 rounded-md border border-success bg-success/15 px-3 py-2 text-sm text-success shadow-card"
|
||||
title="Click to remove"
|
||||
onClick={() => removeFromArena(id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && removeFromArena(id)}
|
||||
>
|
||||
{s.label}
|
||||
<span className="text-success/70 group-hover:text-success/100">×</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showExplain && !isConsistent && (
|
||||
<div className="mt-4 rounded-md border border-outline bg-surface p-4">
|
||||
<h2 className="text-sm font-semibold mb-2">Why inconsistent?</h2>
|
||||
<ul className="list-disc pl-5 space-y-1 text-sm text-foreground">
|
||||
{explanation.map((line, i) => (
|
||||
<li key={i}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{partitions.incompatible.length > 0 && (
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Incompatible with current worldview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
{partitions.incompatible.map(id => {
|
||||
const s = STATEMENTS.find(x => x.id === id)!;
|
||||
const meta = META[id];
|
||||
const groupClass = meta.group === "A" ? "logic-a" : meta.group === "B" ? "logic-b" : "logic-s";
|
||||
const styleClass = meta.neg ? "logic-dotted" : "logic-solid";
|
||||
const classes = cn(
|
||||
"select-none rounded-md bg-surface-variant px-3 py-2 text-sm text-foreground shadow-card border-2 logic-border",
|
||||
groupClass,
|
||||
styleClass,
|
||||
"opacity-60 cursor-not-allowed bg-destructive/10 ring-1 ring-destructive/40"
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={classes}
|
||||
aria-disabled="true"
|
||||
title="Conflicts with current worldview"
|
||||
>
|
||||
{s.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default KnightsAndKnavesI;
|
||||
254
src/components/interactives/KnightsPuzzle.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RefreshCw, RotateCcw, ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
type Piece = 'white' | 'black' | null;
|
||||
type Position = {
|
||||
row: number;
|
||||
col: number;
|
||||
};
|
||||
|
||||
const KnightsPuzzle = () => {
|
||||
// Initial state: white knights at top corners, black knights at bottom corners
|
||||
const initialBoard: Piece[][] = [['white', null, 'white'], [null, null, null], ['black', null, 'black']];
|
||||
|
||||
// Target state: black knights at top corners, white knights at bottom corners
|
||||
const targetBoard: Piece[][] = [['black', null, 'black'], [null, null, null], ['white', null, 'white']];
|
||||
const [board, setBoard] = useState<Piece[][]>(initialBoard);
|
||||
const [selectedSquare, setSelectedSquare] = useState<Position | null>(null);
|
||||
const [moveCount, setMoveCount] = useState(0);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [moveHistory, setMoveHistory] = useState<Position[][]>([]);
|
||||
|
||||
// Knight move offsets
|
||||
const knightMoves = [[-2, -1], [-2, 1], [-1, -2], [-1, 2], [1, -2], [1, 2], [2, -1], [2, 1]];
|
||||
const isValidPosition = (row: number, col: number): boolean => {
|
||||
return row >= 0 && row < 3 && col >= 0 && col < 3;
|
||||
};
|
||||
const getValidMoves = (fromRow: number, fromCol: number): Position[] => {
|
||||
const validMoves: Position[] = [];
|
||||
knightMoves.forEach(([dRow, dCol]) => {
|
||||
const newRow = fromRow + dRow;
|
||||
const newCol = fromCol + dCol;
|
||||
if (isValidPosition(newRow, newCol) && board[newRow][newCol] === null) {
|
||||
validMoves.push({
|
||||
row: newRow,
|
||||
col: newCol
|
||||
});
|
||||
}
|
||||
});
|
||||
return validMoves;
|
||||
};
|
||||
const isValidMove = (from: Position, to: Position): boolean => {
|
||||
const rowDiff = Math.abs(from.row - to.row);
|
||||
const colDiff = Math.abs(from.col - to.col);
|
||||
return rowDiff === 2 && colDiff === 1 || rowDiff === 1 && colDiff === 2;
|
||||
};
|
||||
const checkWinCondition = (currentBoard: Piece[][]): boolean => {
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let col = 0; col < 3; col++) {
|
||||
if (currentBoard[row][col] !== targetBoard[row][col]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const handleSquareClick = (row: number, col: number) => {
|
||||
const piece = board[row][col];
|
||||
if (selectedSquare) {
|
||||
const from = selectedSquare;
|
||||
const to = {
|
||||
row,
|
||||
col
|
||||
};
|
||||
|
||||
// If clicking on the same square, deselect
|
||||
if (from.row === to.row && from.col === to.col) {
|
||||
setSelectedSquare(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If clicking on an empty square and it's a valid knight move
|
||||
if (piece === null && isValidMove(from, to)) {
|
||||
const newBoard = board.map(r => [...r]);
|
||||
const movingPiece = newBoard[from.row][from.col];
|
||||
newBoard[from.row][from.col] = null;
|
||||
newBoard[to.row][to.col] = movingPiece;
|
||||
setBoard(newBoard);
|
||||
setMoveCount(moveCount + 1);
|
||||
setMoveHistory([...moveHistory, [from, to]]);
|
||||
setSelectedSquare(null);
|
||||
|
||||
// Check win condition
|
||||
if (checkWinCondition(newBoard)) {
|
||||
setIsComplete(true);
|
||||
toast.success(`Puzzle solved in ${moveCount + 1} moves!`);
|
||||
}
|
||||
} else if (piece !== null) {
|
||||
// Select a different piece
|
||||
setSelectedSquare({
|
||||
row,
|
||||
col
|
||||
});
|
||||
} else {
|
||||
setSelectedSquare(null);
|
||||
}
|
||||
} else if (piece !== null) {
|
||||
// Select a piece
|
||||
setSelectedSquare({
|
||||
row,
|
||||
col
|
||||
});
|
||||
}
|
||||
};
|
||||
const resetPuzzle = () => {
|
||||
setBoard(initialBoard);
|
||||
setSelectedSquare(null);
|
||||
setMoveCount(0);
|
||||
setIsComplete(false);
|
||||
setMoveHistory([]);
|
||||
};
|
||||
const undoLastMove = () => {
|
||||
if (moveHistory.length === 0) return;
|
||||
const lastMove = moveHistory[moveHistory.length - 1];
|
||||
const [from, to] = lastMove;
|
||||
const newBoard = board.map(r => [...r]);
|
||||
const movingPiece = newBoard[to.row][to.col];
|
||||
newBoard[to.row][to.col] = null;
|
||||
newBoard[from.row][from.col] = movingPiece;
|
||||
setBoard(newBoard);
|
||||
setMoveCount(moveCount - 1);
|
||||
setMoveHistory(moveHistory.slice(0, -1));
|
||||
setSelectedSquare(null);
|
||||
setIsComplete(false);
|
||||
};
|
||||
const getSquareClass = (row: number, col: number): string => {
|
||||
const baseClass = "w-16 h-16 border-2 border-outline flex items-center justify-center cursor-pointer transition-all duration-200";
|
||||
const isSelected = selectedSquare?.row === row && selectedSquare?.col === col;
|
||||
const validMoves = selectedSquare ? getValidMoves(selectedSquare.row, selectedSquare.col) : [];
|
||||
const isValidMoveTarget = validMoves.some(move => move.row === row && move.col === col);
|
||||
let bgClass = (row + col) % 2 === 0 ? "bg-surface" : "bg-surface-variant";
|
||||
if (isSelected) {
|
||||
bgClass = "bg-accent";
|
||||
} else if (isValidMoveTarget) {
|
||||
bgClass = "bg-accent/30 ring-2 ring-accent";
|
||||
}
|
||||
return `${baseClass} ${bgClass}`;
|
||||
};
|
||||
const renderPiece = (piece: Piece) => {
|
||||
if (piece === 'white') {
|
||||
return <span className="text-4xl">♘</span>;
|
||||
} else if (piece === 'black') {
|
||||
return <span className="text-4xl">♞</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return <div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
{/* Title and Description - Centered */}
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Knights Exchange Puzzle</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Can the black and white knights swap places using legal chess moves? Move knights by clicking on them, then clicking on a valid destination.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Blue and Orange: Moves (left) and Status (right) on same line */}
|
||||
<div className="flex justify-between items-center text-lg">
|
||||
<div>
|
||||
<span className="font-semibold">Moves: </span>
|
||||
<span className="font-mono text-primary">{moveCount}</span>
|
||||
</div>
|
||||
<div className="font-semibold">
|
||||
{isComplete ? "🎉 Puzzle Solved!" : "🎯 In Progress"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive and Goal State side by side */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Left: Interactive game */}
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="grid grid-cols-3 gap-1 border-2 border-outline rounded-lg p-4 bg-background">
|
||||
{board.map((row, rowIndex) => row.map((piece, colIndex) => <div key={`${rowIndex}-${colIndex}`} className={getSquareClass(rowIndex, colIndex)} onClick={() => handleSquareClick(rowIndex, colIndex)}>
|
||||
{renderPiece(piece)}
|
||||
</div>))}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={resetPuzzle} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={undoLastMove} variant="outline" size="sm" disabled={moveHistory.length === 0}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right: Goal state */}
|
||||
<Card className="bg-green-500/5 border-green-500/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="grid grid-cols-3 gap-1 border-2 border-outline rounded-lg p-4 bg-background">
|
||||
{targetBoard.map((row, rowIndex) => row.map((piece, colIndex) => <div key={`goal-${rowIndex}-${colIndex}`} className="w-16 h-16 border-2 border-outline flex items-center justify-center bg-surface">
|
||||
{renderPiece(piece)}
|
||||
</div>))}
|
||||
</div>
|
||||
|
||||
{/* Goal label */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-300">Goal State</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Purple: Initial and Target positions side by side */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Red: Game rules */}
|
||||
<Card className="bg-destructive/5 border-destructive/20">
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li>Click on a knight to select it, valid moves will be highlighted. Click on a highlighted square to move the knight to said square (knights move in an L-shape, like in chess).
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>Goal: Swap white and black knights</li>
|
||||
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Attribution */}
|
||||
<div className="pt-4 border-t border-outline">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Puzzle source:{" "}
|
||||
<a href="https://bsky.app/profile/neuwirthe.bsky.social/post/3luhmc7gilc2x" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline inline-flex items-center gap-1">
|
||||
@neuwirthe.bsky.social
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Share Section */}
|
||||
</div>;
|
||||
};
|
||||
export default KnightsPuzzle;
|
||||