Implement Game of Life feature with card flip UI and simulation controls
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m30s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m30s
This commit is contained in:
parent
7e6195eb2c
commit
412fbc37f7
4 changed files with 816 additions and 89 deletions
221
index.html
221
index.html
|
@ -26,6 +26,8 @@
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import '~icons/mdi/github'
|
import '~icons/mdi/github'
|
||||||
import '~icons/mdi/git'
|
import '~icons/mdi/git'
|
||||||
|
import '~icons/mdi/orbit-variant'
|
||||||
|
import '/src/gameoflife.ts'
|
||||||
import '/src/client.ts'
|
import '/src/client.ts'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -35,109 +37,150 @@
|
||||||
<div class="shape shape-3"></div>
|
<div class="shape shape-3"></div>
|
||||||
|
|
||||||
<!-- Main container -->
|
<!-- Main container -->
|
||||||
<main class="min-h-screen w-full flex items-center justify-center p-4 sm:p-6 lg:p-8">
|
<main class="min-h-screen w-full flex items-center justify-center p-4 sm:p-6 lg:p-8 card-container">
|
||||||
|
|
||||||
<!-- The Glass Card -->
|
<!-- The Glass Card -->
|
||||||
<div class="glass-card w-full max-w-4xl rounded-2xl p-6 sm:p-8 md:p-10">
|
<div class="card-flipper w-full max-w-4xl">
|
||||||
<div class="flex flex-col md:flex-row items-center md:space-x-10">
|
<div class="card-front glass-card w-full max-w-4xl rounded-2xl p-6 sm:p-8 md:p-10">
|
||||||
|
<icon-mdi-orbit-variant
|
||||||
|
class="absolute top-0 right-0 m-3 p-1 text-3xl rounded-full cursor-pointer hover-glass"></icon-mdi-orbit-variant>
|
||||||
|
<div class="flex flex-col md:flex-row items-center md:space-x-10">
|
||||||
|
|
||||||
<!-- Left Column: Profile Picture and Social Links -->
|
<!-- Left Column: Profile Picture and Social Links -->
|
||||||
<div class="flex-shrink-0 text-center mb-6 md:mb-0">
|
<div class="flex-shrink-0 text-center mb-6 md:mb-0">
|
||||||
<img src="/avatar.webp" alt="Cameron B. R. Redmore"
|
<img src="/avatar.webp" alt="Cameron B. R. Redmore"
|
||||||
class="rounded-full w-32 h-32 md:w-40 md:h-40 mx-auto border-4 border-white/20 shadow-lg hover-glass"
|
class="rounded-full w-32 h-32 md:w-40 md:h-40 mx-auto border-4 border-white/20 shadow-lg hover-glass"
|
||||||
loading="lazy" decoding="async">
|
loading="lazy" decoding="async">
|
||||||
<div class="mt-6 flex justify-center space-x-4">
|
<div class="mt-6 flex justify-center space-x-4">
|
||||||
<a href="https://github.com/CameronRedmore" target="_blank"
|
<a href="https://github.com/CameronRedmore" target="_blank"
|
||||||
class="hover-glass hover:text-cyan-200 p-2 rounded-full" aria-label="GitHub Profile">
|
class="hover-glass hover:text-cyan-200 p-2 rounded-full" aria-label="GitHub Profile">
|
||||||
<icon-mdi-github class="text-3xl" />
|
<icon-mdi-github class="text-3xl" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://git.cmzi.uk/" target="_blank"
|
<a href="https://git.cmzi.uk/" target="_blank"
|
||||||
class="hover-glass hover:text-cyan-200 p-2 rounded-full" aria-label="Personal Git Hosting">
|
class="hover-glass hover:text-cyan-200 p-2 rounded-full"
|
||||||
<icon-mdi-git class="text-3xl" />
|
aria-label="Personal Git Hosting">
|
||||||
</a>
|
<icon-mdi-git class="text-3xl" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last.fm Now Playing Section -->
|
||||||
|
<div class="mt-6 md:mt-8 hidden md:block text-center">
|
||||||
|
<div class="p-4 rounded-lg hover-glass">
|
||||||
|
<div id="last-song-container">
|
||||||
|
<div class="text-white-400 text-sm animate-pulse">
|
||||||
|
Loading music data...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Last.fm Now Playing Section -->
|
<!-- Right Column: Main Content -->
|
||||||
<div class="mt-6 md:mt-8 hidden md:block text-center">
|
<div class="w-full text-center md:text-left">
|
||||||
<div class="p-4 rounded-lg hover-glass">
|
<h1 class="text-4xl md:text-5xl font-bold text-white animated-gradient">Cameron B. R. Redmore
|
||||||
<div id="last-song-container">
|
</h1>
|
||||||
<div class="text-white-400 text-sm animate-pulse">
|
<p class="mt-2 text-lg text-cyan-200">Software Developer from Kingston-Upon-Hull, UK</p>
|
||||||
Loading music data...
|
|
||||||
|
<p class="mt-6 text-gray-200 leading-relaxed">
|
||||||
|
With 8 years of professional experience and over 15 years of passion-driven coding, I build
|
||||||
|
robust and efficient software solutions. My journey has taken me from recreational projects
|
||||||
|
to
|
||||||
|
developing complex professional applications.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Interests & Hobbies Section -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-3">Interests & Hobbies</h2>
|
||||||
|
<p class="text-gray-200 leading-relaxed">
|
||||||
|
Beyond my core work, I have a deep interest in the broader field of computing and
|
||||||
|
programming. I'm particularly passionate about the AI and Machine Learning space,
|
||||||
|
focusing
|
||||||
|
on integrating Large Language Models (LLMs) in novel ways. In my spare time, I also
|
||||||
|
enjoy
|
||||||
|
the logical challenge of puzzles like Sudoku and Nonograms.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-3">Programming Skills</h2>
|
||||||
|
<div class="flex flex-wrap gap-2 items-center justify-center">
|
||||||
|
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">HTML</span>
|
||||||
|
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">CSS</span>
|
||||||
|
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">JavaScript</span>
|
||||||
|
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">TypeScript</span>
|
||||||
|
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C#</span>
|
||||||
|
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C++</span>
|
||||||
|
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C</span>
|
||||||
|
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">Java</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Project Section -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-3">Current Hobby Project</h2>
|
||||||
|
<a href="https://weaver.cmzi.uk/" target="_blank"
|
||||||
|
class="block p-4 rounded-lg hover-glass transition-all duration-300 ease-in-out hover:bg-white/30">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="w-8 h-8 text-cyan-300" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold text-white">Word Weaver</p>
|
||||||
|
<p class="text-sm text-cyan-200">An AI-powered web game of "Alchemy" with
|
||||||
|
infinite,
|
||||||
|
wacky combinations.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last.fm Now Playing Section - Mobile Only -->
|
||||||
|
<div class="mt-8 md:hidden">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-3">Music</h2>
|
||||||
|
<div class="p-4 rounded-lg hover-glass">
|
||||||
|
<div id="last-song-container-mobile">
|
||||||
|
<div class="text-white-400 text-sm animate-pulse">
|
||||||
|
Loading music data...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-back glass-card w-full max-w-4xl rounded-2xl p-6 sm:p-8 md:p-10">
|
||||||
|
<icon-mdi-orbit-variant
|
||||||
|
class="absolute top-0 right-0 m-3 p-1 text-3xl rounded-full cursor-pointer hover-glass"></icon-mdi-orbit-variant>
|
||||||
|
|
||||||
<!-- Right Column: Main Content -->
|
<!-- Game Of Life Implementation -->
|
||||||
<div class="w-full text-center md:text-left">
|
<div id="game-of-life-container" class="mt-4">
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-white animated-gradient">Cameron B. R. Redmore</h1>
|
<canvas id="game-of-life-canvas" class="border border-white"></canvas>
|
||||||
<p class="mt-2 text-lg text-cyan-200">Software Developer from Kingston-Upon-Hull, UK</p>
|
</div>
|
||||||
|
|
||||||
<p class="mt-6 text-gray-200 leading-relaxed">
|
<!-- Save/Load Controls -->
|
||||||
With 8 years of professional experience and over 15 years of passion-driven coding, I build
|
<div class="mt-4 flex flex-col space-y-4">
|
||||||
robust and efficient software solutions. My journey has taken me from recreational projects to
|
<div class="flex space-x-4">
|
||||||
developing complex professional applications.
|
<button id="save-pattern"
|
||||||
</p>
|
class="flex-1 px-4 py-2 bg-cyan-600/20 hover:bg-cyan-600/40 text-white rounded-lg border border-cyan-500/50 hover-glass transition-all">
|
||||||
|
Save Pattern
|
||||||
<!-- Interests & Hobbies Section -->
|
</button>
|
||||||
<div class="mt-8">
|
<button id="load-pattern"
|
||||||
<h2 class="text-xl font-semibold text-white mb-3">Interests & Hobbies</h2>
|
class="flex-1 px-4 py-2 bg-green-600/20 hover:bg-green-600/40 text-white rounded-lg border border-green-500/50 hover-glass transition-all">
|
||||||
<p class="text-gray-200 leading-relaxed">
|
Load Pattern
|
||||||
Beyond my core work, I have a deep interest in the broader field of computing and
|
</button>
|
||||||
programming. I'm particularly passionate about the AI and Machine Learning space, focusing
|
|
||||||
on integrating Large Language Models (LLMs) in novel ways. In my spare time, I also enjoy
|
|
||||||
the logical challenge of puzzles like Sudoku and Nonograms.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<!-- Simulation Speed Control -->
|
||||||
<h2 class="text-xl font-semibold text-white mb-3">Programming Skills</h2>
|
<div class="mb-4 flex items-center space-x-4">
|
||||||
<div class="flex flex-wrap gap-2 items-center justify-center">
|
<label for="simulation-speed" class="text-white font-medium">Simulation Speed:</label>
|
||||||
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">HTML</span>
|
<input type="range" id="simulation-speed" min="1" max="30" value="10"
|
||||||
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">CSS</span>
|
class="flex-1 h-2 bg-white/20 rounded-lg appearance-none cursor-pointer slider">
|
||||||
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">JavaScript</span>
|
<span id="speed-display" class="text-cyan-200 font-mono min-w-[3rem]">10 Hz</span>
|
||||||
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">TypeScript</span>
|
|
||||||
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C#</span>
|
|
||||||
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C++</span>
|
|
||||||
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C</span>
|
|
||||||
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">Java</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Project Section -->
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-xl font-semibold text-white mb-3">Current Hobby Project</h2>
|
|
||||||
<a href="https://weaver.cmzi.uk/" target="_blank"
|
|
||||||
class="block p-4 rounded-lg hover-glass transition-all duration-300 ease-in-out hover:bg-white/30">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg class="w-8 h-8 text-cyan-300" fill="none" stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="font-bold text-white">Word Weaver</p>
|
|
||||||
<p class="text-sm text-cyan-200">An AI-powered web game of "Alchemy" with infinite,
|
|
||||||
wacky combinations.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Last.fm Now Playing Section - Mobile Only -->
|
|
||||||
<div class="mt-8 md:hidden">
|
|
||||||
<h2 class="text-xl font-semibold text-white mb-3">Music</h2>
|
|
||||||
<div class="p-4 rounded-lg hover-glass">
|
|
||||||
<div id="last-song-container-mobile">
|
|
||||||
<div class="text-white-400 text-sm animate-pulse">
|
|
||||||
Loading music data...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { treaty } from '@elysiajs/eden';
|
import { treaty } from '@elysiajs/eden';
|
||||||
import type { App } from './server';
|
import type { App } from './server';
|
||||||
|
import './gameoflife';
|
||||||
|
|
||||||
// Create Eden Treaty client using current page origin
|
// Create Eden Treaty client using current page origin
|
||||||
const client = treaty<App>(window.location.origin);
|
const client = treaty<App>(window.location.origin);
|
||||||
|
@ -251,6 +252,19 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
|
||||||
|
|
||||||
// Initialize when DOM is loaded
|
// Initialize when DOM is loaded
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Card flip logic - attach to orbit buttons
|
||||||
|
const cardContainer = document.querySelector('.card-container');
|
||||||
|
const flipButtons = document.querySelectorAll('icon-mdi-orbit-variant');
|
||||||
|
|
||||||
|
flipButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (cardContainer) {
|
||||||
|
cardContainer.classList.toggle('is-flipped');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Lazy-load: observe the desktop and mobile containers
|
// Lazy-load: observe the desktop and mobile containers
|
||||||
const targets = [
|
const targets = [
|
||||||
document.getElementById('last-song-container'),
|
document.getElementById('last-song-container'),
|
||||||
|
|
589
src/gameoflife.ts
Normal file
589
src/gameoflife.ts
Normal file
|
@ -0,0 +1,589 @@
|
||||||
|
// File to implement Game of Life using stored co-ordinates.
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const canvas = document.getElementById('game-of-life-canvas') as HTMLCanvasElement;
|
||||||
|
|
||||||
|
const gameOfLife = new GameOfLife(canvas);
|
||||||
|
|
||||||
|
gameOfLife.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameOfLife {
|
||||||
|
private canvas: HTMLCanvasElement;
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
// Define sets of cells (much more efficient for large grids than 2D array)
|
||||||
|
private activeCells: Set<string> = new Set(); // Cells that are currently alive
|
||||||
|
private activeCellsNext: Set<string> = new Set(); // Cells that will be alive in the next generation
|
||||||
|
|
||||||
|
private potentialCells: Set<string> = new Set(); // Cells that could potentially become alive in the next generation
|
||||||
|
private potentialCellsNext: Set<string> = new Set(); // Cells that will be alive in the next generation
|
||||||
|
|
||||||
|
// Viewport and interaction state
|
||||||
|
private offsetX: number = 0;
|
||||||
|
private offsetY: number = 0;
|
||||||
|
private zoomLevel: number = 1;
|
||||||
|
private isPaused: boolean = false;
|
||||||
|
|
||||||
|
private isDragging: boolean = false;
|
||||||
|
private lastMouseX: number = 0;
|
||||||
|
private lastMouseY: number = 0;
|
||||||
|
|
||||||
|
private targetFrameRate: number = 144;
|
||||||
|
private targetSimulationRate: number = 10;
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
// Set canvas size
|
||||||
|
this.canvas.width = 800;
|
||||||
|
this.canvas.height = 550;
|
||||||
|
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupSimulationSpeedSlider();
|
||||||
|
this.setupSaveLoadButtons();
|
||||||
|
this.seedInitialPattern();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.update();
|
||||||
|
this.drawCells();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners() {
|
||||||
|
// Click to add cells
|
||||||
|
this.canvas.addEventListener('click', (e) => {
|
||||||
|
if (!this.isDragging) {
|
||||||
|
this.handleCellClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Right click to fill area with random cells
|
||||||
|
this.canvas.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault(); // Prevent context menu
|
||||||
|
if (!this.isDragging) {
|
||||||
|
this.handleRightClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse events for panning
|
||||||
|
this.canvas.addEventListener('mousedown', (e) => {
|
||||||
|
this.isDragging = false;
|
||||||
|
this.lastMouseX = e.clientX;
|
||||||
|
this.lastMouseY = e.clientY;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (e.buttons === 1) { // Left mouse button pressed
|
||||||
|
const deltaX = e.clientX - this.lastMouseX;
|
||||||
|
const deltaY = e.clientY - this.lastMouseY;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
|
||||||
|
this.isDragging = true;
|
||||||
|
this.offsetX -= deltaX;
|
||||||
|
this.offsetY -= deltaY;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastMouseX = e.clientX;
|
||||||
|
this.lastMouseY = e.clientY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zoom with mouse wheel
|
||||||
|
this.canvas.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Calculate world coordinates of mouse position before zoom
|
||||||
|
const worldX = (mouseX + this.offsetX) / this.zoomLevel;
|
||||||
|
const worldY = (mouseY + this.offsetY) / this.zoomLevel;
|
||||||
|
|
||||||
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
const newZoomLevel = Math.max(0.1, Math.min(5, this.zoomLevel * zoomFactor));
|
||||||
|
|
||||||
|
// Calculate new offset to keep the mouse position at the same world coordinates
|
||||||
|
this.offsetX = (worldX * newZoomLevel) - mouseX;
|
||||||
|
this.offsetY = (worldY * newZoomLevel) - mouseY;
|
||||||
|
|
||||||
|
this.zoomLevel = newZoomLevel;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard controls
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isPaused = !this.isPaused;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSimulationSpeedSlider() {
|
||||||
|
const speedSlider = document.getElementById('simulation-speed') as HTMLInputElement;
|
||||||
|
const speedDisplay = document.getElementById('speed-display') as HTMLSpanElement;
|
||||||
|
|
||||||
|
if (speedSlider && speedDisplay) {
|
||||||
|
// Update display with initial value
|
||||||
|
speedDisplay.textContent = `${speedSlider.value} Hz`;
|
||||||
|
|
||||||
|
// Listen for slider changes
|
||||||
|
speedSlider.addEventListener('input', (e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const newSpeed = parseInt(target.value);
|
||||||
|
this.targetSimulationRate = newSpeed;
|
||||||
|
speedDisplay.textContent = `${newSpeed} Hz`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSaveLoadButtons() {
|
||||||
|
const saveButton = document.getElementById('save-pattern') as HTMLButtonElement;
|
||||||
|
const loadButton = document.getElementById('load-pattern') as HTMLButtonElement;
|
||||||
|
const fileInput = document.getElementById('pattern-file') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (saveButton && loadButton) {
|
||||||
|
saveButton.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const patternBlob = await this.savePattern();
|
||||||
|
|
||||||
|
// Create a download link
|
||||||
|
const url = URL.createObjectURL(patternBlob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `game-of-life-pattern-${Date.now()}.gol.zz`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
saveButton.textContent = 'Downloaded!';
|
||||||
|
setTimeout(() => {
|
||||||
|
saveButton.textContent = 'Save Pattern';
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save pattern:', error);
|
||||||
|
saveButton.textContent = 'Save Failed';
|
||||||
|
setTimeout(() => {
|
||||||
|
saveButton.textContent = 'Save Pattern';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadButton.addEventListener('click', () => {
|
||||||
|
// Trigger the file input
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.click();
|
||||||
|
} else {
|
||||||
|
// Create a temporary file input if none exists
|
||||||
|
const tempInput = document.createElement('input');
|
||||||
|
tempInput.type = 'file';
|
||||||
|
tempInput.accept = '.gol.zz,.zz';
|
||||||
|
tempInput.addEventListener('change', async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file) {
|
||||||
|
await this.handleFileLoad(file, loadButton);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tempInput.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up file input if it exists
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener('change', async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file) {
|
||||||
|
await this.handleFileLoad(file, loadButton);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleFileLoad(file: File, loadButton: HTMLButtonElement) {
|
||||||
|
try {
|
||||||
|
await this.loadPattern(file);
|
||||||
|
loadButton.textContent = 'Loaded!';
|
||||||
|
setTimeout(() => {
|
||||||
|
loadButton.textContent = 'Load Pattern';
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load pattern:', error);
|
||||||
|
loadButton.textContent = 'Load Failed';
|
||||||
|
setTimeout(() => {
|
||||||
|
loadButton.textContent = 'Load Pattern';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private seedInitialPattern() {
|
||||||
|
// Add a simple glider pattern to start
|
||||||
|
const glider = [
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 2, y: 1 },
|
||||||
|
{ x: 0, y: 2 },
|
||||||
|
{ x: 1, y: 2 },
|
||||||
|
{ x: 2, y: 2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cell of glider) {
|
||||||
|
this.activeCells.add(this.cellToString(cell));
|
||||||
|
this.potentialCells.add(this.cellToString(cell));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cellToString(cell: Cell): string {
|
||||||
|
return `${cell.x},${cell.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringToCell(str: string): Cell {
|
||||||
|
const [x, y] = str.split(',').map(Number);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async savePattern(): Promise<Blob> {
|
||||||
|
// Convert active cells to an array of coordinates
|
||||||
|
const cells = Array.from(this.activeCells).map(cellKey => {
|
||||||
|
const cell = this.stringToCell(cellKey);
|
||||||
|
return [cell.x, cell.y];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to JSON
|
||||||
|
const jsonData = JSON.stringify(cells);
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const jsonBytes = encoder.encode(jsonData);
|
||||||
|
|
||||||
|
// Compress using deflate
|
||||||
|
const compressionStream = new CompressionStream('deflate-raw');
|
||||||
|
const writer = compressionStream.writable.getWriter();
|
||||||
|
const reader = compressionStream.readable.getReader();
|
||||||
|
|
||||||
|
// Write the data to the compression stream
|
||||||
|
writer.write(jsonBytes);
|
||||||
|
writer.close();
|
||||||
|
|
||||||
|
// Read the compressed data
|
||||||
|
const compressedChunks: Uint8Array[] = [];
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: readerDone } = await reader.read();
|
||||||
|
done = readerDone;
|
||||||
|
if (value) {
|
||||||
|
compressedChunks.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine all chunks into a single blob
|
||||||
|
const combinedArray = new Uint8Array(
|
||||||
|
compressedChunks.reduce((totalLength, chunk) => totalLength + chunk.length, 0)
|
||||||
|
);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of compressedChunks) {
|
||||||
|
combinedArray.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([combinedArray], { type: 'application/deflate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPattern(file: File): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Read the file as array buffer
|
||||||
|
const compressedData = await file.arrayBuffer();
|
||||||
|
|
||||||
|
// Decompress using deflate
|
||||||
|
const decompressionStream = new DecompressionStream('deflate-raw');
|
||||||
|
const writer = decompressionStream.writable.getWriter();
|
||||||
|
const reader = decompressionStream.readable.getReader();
|
||||||
|
|
||||||
|
// Write the compressed data to the decompression stream
|
||||||
|
writer.write(new Uint8Array(compressedData));
|
||||||
|
writer.close();
|
||||||
|
|
||||||
|
// Read the decompressed data
|
||||||
|
const decompressedChunks: Uint8Array[] = [];
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: readerDone } = await reader.read();
|
||||||
|
done = readerDone;
|
||||||
|
if (value) {
|
||||||
|
decompressedChunks.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine chunks and decode to string
|
||||||
|
const totalLength = decompressedChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const combinedArray = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const chunk of decompressedChunks) {
|
||||||
|
combinedArray.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const jsonData = decoder.decode(combinedArray);
|
||||||
|
const cells = JSON.parse(jsonData) as number[][];
|
||||||
|
|
||||||
|
// Clear current pattern
|
||||||
|
this.activeCells.clear();
|
||||||
|
this.activeCellsNext.clear();
|
||||||
|
this.potentialCells.clear();
|
||||||
|
this.potentialCellsNext.clear();
|
||||||
|
|
||||||
|
// Load new pattern
|
||||||
|
for (const [x, y] of cells) {
|
||||||
|
const cellKey = this.cellToString({ x, y });
|
||||||
|
this.activeCells.add(cellKey);
|
||||||
|
this.potentialCells.add(cellKey);
|
||||||
|
|
||||||
|
// Add neighbors to potential cells
|
||||||
|
const neighbors = this.getNeighbors({ x, y });
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
this.potentialCells.add(this.cellToString(neighbor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize next generation sets
|
||||||
|
this.activeCellsNext = new Set(this.activeCells);
|
||||||
|
this.potentialCellsNext = new Set(this.potentialCells);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Invalid pattern file or decompression failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCellClick(e: MouseEvent) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Convert screen coordinates to grid coordinates
|
||||||
|
const cellSize = 10 * this.zoomLevel;
|
||||||
|
const gridX = Math.floor((x + this.offsetX) / cellSize);
|
||||||
|
const gridY = Math.floor((y + this.offsetY) / cellSize);
|
||||||
|
|
||||||
|
const cellKey = this.cellToString({ x: gridX, y: gridY });
|
||||||
|
|
||||||
|
// Toggle cell state
|
||||||
|
if (this.activeCells.has(cellKey)) {
|
||||||
|
this.activeCells.delete(cellKey);
|
||||||
|
this.activeCellsNext.delete(cellKey);
|
||||||
|
this.potentialCellsNext.add(cellKey);
|
||||||
|
|
||||||
|
const neighbors = this.getNeighbors({ x: gridX, y: gridY });
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
this.potentialCellsNext.add(this.cellToString(neighbor));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.activeCells.add(cellKey);
|
||||||
|
this.activeCellsNext.add(cellKey);
|
||||||
|
this.potentialCellsNext.add(cellKey);
|
||||||
|
|
||||||
|
// Add neighbors to potential cells
|
||||||
|
const neighbors = this.getNeighbors({ x: gridX, y: gridY });
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
this.potentialCellsNext.add(this.cellToString(neighbor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRightClick(e: MouseEvent) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Convert screen coordinates to grid coordinates
|
||||||
|
const cellSize = 10 * this.zoomLevel;
|
||||||
|
const centerX = Math.floor((x + this.offsetX) / cellSize);
|
||||||
|
const centerY = Math.floor((y + this.offsetY) / cellSize);
|
||||||
|
|
||||||
|
// Define the area size (radius around the cursor) - scale inversely with zoom level
|
||||||
|
// Base radius of 5 at zoom level 1, scales up when zoomed out
|
||||||
|
const areaRadius = Math.max(2, Math.round(5 / this.zoomLevel));
|
||||||
|
const fillDensity = 0.3; // 30% chance for each cell to be filled
|
||||||
|
|
||||||
|
// Fill random cells in the area
|
||||||
|
for (let dx = -areaRadius; dx <= areaRadius; dx++) {
|
||||||
|
for (let dy = -areaRadius; dy <= areaRadius; dy++) {
|
||||||
|
// Only fill cells within a circular area
|
||||||
|
if (dx * dx + dy * dy <= areaRadius * areaRadius) {
|
||||||
|
if (Math.random() < fillDensity) {
|
||||||
|
const gridX = centerX + dx;
|
||||||
|
const gridY = centerY + dy;
|
||||||
|
const cellKey = this.cellToString({ x: gridX, y: gridY });
|
||||||
|
|
||||||
|
// Add the cell if it's not already alive
|
||||||
|
if (!this.activeCells.has(cellKey)) {
|
||||||
|
this.activeCells.add(cellKey);
|
||||||
|
this.activeCellsNext.add(cellKey);
|
||||||
|
this.potentialCellsNext.add(cellKey);
|
||||||
|
|
||||||
|
// Add neighbors to potential cells
|
||||||
|
const neighbors = this.getNeighbors({ x: gridX, y: gridY });
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
this.potentialCellsNext.add(this.cellToString(neighbor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
if (!this.isPaused) {
|
||||||
|
this.updateCells();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => requestAnimationFrame(this.update.bind(this)), 1000 / this.targetSimulationRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCells() {
|
||||||
|
//Check if the card is currently flipped, otherwise do not update cells.
|
||||||
|
const isFlipped = document.querySelector('main')?.classList.contains('is-flipped');
|
||||||
|
if (!isFlipped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeCells = new Set(this.activeCellsNext);
|
||||||
|
this.activeCellsNext = new Set<string>();
|
||||||
|
|
||||||
|
this.potentialCells = new Set(this.potentialCellsNext);
|
||||||
|
|
||||||
|
// Each current cell has the potential to cause change on this update.
|
||||||
|
this.potentialCellsNext = new Set(this.activeCells);
|
||||||
|
|
||||||
|
for (const cellKey of this.potentialCells) {
|
||||||
|
const cell = this.stringToCell(cellKey);
|
||||||
|
const neighbors = this.getNeighbors(cell);
|
||||||
|
const aliveNeighbors = neighbors.filter(n => this.activeCells.has(this.cellToString(n))).length;
|
||||||
|
|
||||||
|
if (this.activeCells.has(cellKey)) {
|
||||||
|
// Cell is currently alive
|
||||||
|
if (aliveNeighbors < 2 || aliveNeighbors > 3) {
|
||||||
|
// Cell dies, so we don't add it to the next generation
|
||||||
|
|
||||||
|
// However, this means the surrounding cells could change on the next generation.
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
// Add neighbors to potential cells for next generation
|
||||||
|
this.potentialCellsNext.add(this.cellToString(neighbor));
|
||||||
|
}
|
||||||
|
this.potentialCellsNext.add(cellKey);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// Cell stays alive
|
||||||
|
this.activeCellsNext.add(cellKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cell is currently dead
|
||||||
|
if (aliveNeighbors === 3) {
|
||||||
|
// Cell becomes alive
|
||||||
|
this.activeCellsNext.add(cellKey);
|
||||||
|
this.potentialCellsNext.add(cellKey);
|
||||||
|
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
// Add neighbors to potential cells for next generation
|
||||||
|
this.potentialCellsNext.add(this.cellToString(neighbor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNeighbors(cell: Cell): Cell[] {
|
||||||
|
const neighbors: Cell[] = [];
|
||||||
|
for (let x = -1; x <= 1; x++) {
|
||||||
|
for (let y = -1; y <= 1; y++) {
|
||||||
|
// Ignore the cell itself.
|
||||||
|
if (x === 0 && y === 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
neighbors.push({ x: cell.x + x, y: cell.y + y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawCells() {
|
||||||
|
const isFlipped = document.querySelector('main')?.classList.contains('is-flipped');
|
||||||
|
if (!isFlipped) {
|
||||||
|
setTimeout(() => requestAnimationFrame(this.drawCells.bind(this)), 1000 / this.targetFrameRate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the cells, taking into account viewport offset and zoom level.
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Find what range of cells are currently visible based on the canvas size and zoom level.
|
||||||
|
let cellSize = 10; // Default size of each cell in pixels
|
||||||
|
|
||||||
|
// Adjust size by zoom level
|
||||||
|
cellSize *= this.zoomLevel;
|
||||||
|
|
||||||
|
// Calculate the visible range of cells
|
||||||
|
const startX = Math.floor(this.offsetX / cellSize);
|
||||||
|
const startY = Math.floor(this.offsetY / cellSize);
|
||||||
|
const endX = Math.ceil((this.offsetX + this.canvas.width) / cellSize);
|
||||||
|
const endY = Math.ceil((this.offsetY + this.canvas.height) / cellSize);
|
||||||
|
|
||||||
|
// Draw grid lines for better visibility
|
||||||
|
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
this.ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let x = startX; x <= endX; x++) {
|
||||||
|
const screenX = (x * cellSize) - this.offsetX;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(screenX, 0);
|
||||||
|
this.ctx.lineTo(screenX, this.canvas.height);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = startY; y <= endY; y++) {
|
||||||
|
const screenY = (y * cellSize) - this.offsetY;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(0, screenY);
|
||||||
|
this.ctx.lineTo(this.canvas.width, screenY);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.fillStyle = '#67e8f9'; // cyan color to match theme
|
||||||
|
|
||||||
|
// Draw active cells
|
||||||
|
for (let x = startX; x < endX; x++) {
|
||||||
|
for (let y = startY; y < endY; y++) {
|
||||||
|
const cellKey = this.cellToString({ x, y });
|
||||||
|
if (this.activeCells.has(cellKey)) {
|
||||||
|
const screenX = (x * cellSize) - this.offsetX;
|
||||||
|
const screenY = (y * cellSize) - this.offsetY;
|
||||||
|
this.ctx.fillRect(screenX, screenY, cellSize - 1, cellSize - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||||
|
this.ctx.font = '20px Exo 2 Variable, sans-serif';
|
||||||
|
|
||||||
|
// Draw pause indicator
|
||||||
|
if (this.isPaused) {
|
||||||
|
this.ctx.fillText('PAUSED - Press SPACE to resume', 10, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw current X, Y and zoom level along with total number of active cells in bottom left
|
||||||
|
this.ctx.fillText(`X: ${Math.floor(this.offsetX / cellSize)}, Y: ${Math.floor(this.offsetY / cellSize)}, Zoom: ${this.zoomLevel.toFixed(2)}`, 10, this.canvas.height - 10);
|
||||||
|
this.ctx.fillText(`Active Cells: ${this.activeCells.size}`, 10, this.canvas.height - 30);
|
||||||
|
|
||||||
|
|
||||||
|
setTimeout(() => requestAnimationFrame(this.drawCells.bind(this)), 1000 / this.targetFrameRate);
|
||||||
|
}
|
||||||
|
}
|
|
@ -219,4 +219,85 @@ details.history ul li {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #cbd5e0;
|
color: #cbd5e0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card flip styles */
|
||||||
|
.card-container {
|
||||||
|
perspective: 1500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-flipper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 720px;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-container.is-flipped .card-flipper {
|
||||||
|
transform: rotateY(-180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-front,
|
||||||
|
.card-back {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-front {
|
||||||
|
z-index: 2;
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-back {
|
||||||
|
transform: rotateY(-180deg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider styles for simulation speed control */
|
||||||
|
.slider {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #67e8f9;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #4dd0e1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #67e8f9;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb:hover {
|
||||||
|
background: #4dd0e1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
Loading…
Add table
Add a link
Reference in a new issue