initial commit
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "timer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "webpack serve",
|
||||||
|
"build": "webpack --mode production",
|
||||||
|
"dev": "webpack --mode development"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.28.0",
|
||||||
|
"@babel/preset-env": "^7.28.0",
|
||||||
|
"babel-loader": "^10.0.0",
|
||||||
|
"copy-webpack-plugin": "^13.0.0",
|
||||||
|
"css-loader": "^7.1.2",
|
||||||
|
"html-webpack-plugin": "^5.6.3",
|
||||||
|
"style-loader": "^4.0.0",
|
||||||
|
"webpack": "^5.100.1",
|
||||||
|
"webpack-cli": "^6.0.1",
|
||||||
|
"webpack-dev-server": "^5.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Timer App</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<h1>Timer</h1>
|
||||||
|
|
||||||
|
<div class="timer-setup">
|
||||||
|
<input type="text" id="time-input" class="time-input" value="5:00" placeholder="0:00">
|
||||||
|
<button id="start-button" class="start-button">Start</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-timers">
|
||||||
|
<button class="quick-timer" data-minutes="1" data-seconds="0">1m</button>
|
||||||
|
<button class="quick-timer" data-minutes="2" data-seconds="0">2m</button>
|
||||||
|
<button class="quick-timer" data-minutes="3" data-seconds="0">3m</button>
|
||||||
|
<button class="quick-timer" data-minutes="5" data-seconds="0">5m</button>
|
||||||
|
<button class="quick-timer" data-minutes="10" data-seconds="0">10m</button>
|
||||||
|
<button class="quick-timer" data-minutes="15" data-seconds="0">15m</button>
|
||||||
|
<button class="quick-timer" data-minutes="20" data-seconds="0">20m</button>
|
||||||
|
<button class="quick-timer" data-minutes="25" data-seconds="0">25m</button>
|
||||||
|
<button class="quick-timer" data-minutes="30" data-seconds="0">30m</button>
|
||||||
|
<button class="quick-timer" data-minutes="45" data-seconds="0">45m</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-grid">
|
||||||
|
<!-- Theme cards will be generated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+470
@@ -0,0 +1,470 @@
|
|||||||
|
// CSS is loaded via link tag in HTML
|
||||||
|
import { TextTheme } from './themes/textTheme';
|
||||||
|
import { CircleShaderTheme } from './themes/circleShaderTheme';
|
||||||
|
import { RainbowShaderTheme } from './themes/rainbowShaderTheme';
|
||||||
|
import { CircularBlobsShaderTheme } from './themes/circularBlobsShaderTheme';
|
||||||
|
import { ShaderTheme } from './themes/shaderTheme';
|
||||||
|
|
||||||
|
class TimerApp {
|
||||||
|
constructor() {
|
||||||
|
this.themes = {
|
||||||
|
shader: {
|
||||||
|
class: ShaderTheme,
|
||||||
|
name: 'Plasma Flow',
|
||||||
|
description: 'Dynamic WebGL shader with rings',
|
||||||
|
popularity: 0
|
||||||
|
},
|
||||||
|
circularBlobs: {
|
||||||
|
class: CircularBlobsShaderTheme,
|
||||||
|
name: 'Orbital Dots',
|
||||||
|
description: 'Dots consumed in circular motion',
|
||||||
|
popularity: 0
|
||||||
|
},
|
||||||
|
circle: {
|
||||||
|
class: CircleShaderTheme,
|
||||||
|
name: 'Arc Progress',
|
||||||
|
description: 'Classic circular progress indicator',
|
||||||
|
popularity: 0
|
||||||
|
},
|
||||||
|
rainbow: {
|
||||||
|
class: RainbowShaderTheme,
|
||||||
|
name: 'Rainbow Orbits',
|
||||||
|
description: 'Colorful rotating circles',
|
||||||
|
popularity: 0
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
class: TextTheme,
|
||||||
|
name: 'Digital Clock',
|
||||||
|
description: 'Simple text countdown',
|
||||||
|
popularity: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.app = document.getElementById('app');
|
||||||
|
this.setupContent = null; // Will store the setup screen content
|
||||||
|
|
||||||
|
this.currentTheme = null;
|
||||||
|
this.selectedThemeId = 'shader';
|
||||||
|
this.selectedDuration = { minutes: 5, seconds: 0 };
|
||||||
|
this.animationId = null;
|
||||||
|
this.startTime = null;
|
||||||
|
this.duration = null;
|
||||||
|
|
||||||
|
// Time in milliseconds for completion animation to reach resting state
|
||||||
|
this.completionDuration = 2400;
|
||||||
|
|
||||||
|
// Load saved data
|
||||||
|
this.loadSavedData();
|
||||||
|
|
||||||
|
// Initialize UI
|
||||||
|
this.initializeThemeGallery();
|
||||||
|
this.initializeEventListeners();
|
||||||
|
|
||||||
|
// Canvas will be created when timer starts
|
||||||
|
this.canvas = null;
|
||||||
|
this.ctx = null;
|
||||||
|
|
||||||
|
// Expose to window for debugging
|
||||||
|
window.timerApp = {
|
||||||
|
setCompletionDuration: (ms) => {
|
||||||
|
this.completionDuration = Math.max(100, Math.min(10000, ms));
|
||||||
|
console.log(`Completion animation duration set to ${this.completionDuration}ms`);
|
||||||
|
},
|
||||||
|
getCompletionDuration: () => this.completionDuration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSavedData() {
|
||||||
|
// Load theme popularity
|
||||||
|
const savedPopularity = localStorage.getItem('themePopularity');
|
||||||
|
if (savedPopularity) {
|
||||||
|
const popularity = JSON.parse(savedPopularity);
|
||||||
|
Object.keys(popularity).forEach(themeId => {
|
||||||
|
if (this.themes[themeId]) {
|
||||||
|
this.themes[themeId].popularity = popularity[themeId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Load last used theme
|
||||||
|
const lastTheme = localStorage.getItem('lastSelectedTheme');
|
||||||
|
if (lastTheme && this.themes[lastTheme]) {
|
||||||
|
this.selectedThemeId = lastTheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveThemePopularity() {
|
||||||
|
const popularity = {};
|
||||||
|
Object.keys(this.themes).forEach(themeId => {
|
||||||
|
popularity[themeId] = this.themes[themeId].popularity;
|
||||||
|
});
|
||||||
|
localStorage.setItem('themePopularity', JSON.stringify(popularity));
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeThemeGallery() {
|
||||||
|
const themeGrid = document.querySelector('.theme-grid');
|
||||||
|
themeGrid.innerHTML = '';
|
||||||
|
|
||||||
|
// Sort themes by popularity
|
||||||
|
const sortedThemes = Object.entries(this.themes).sort((a, b) =>
|
||||||
|
b[1].popularity - a[1].popularity
|
||||||
|
);
|
||||||
|
|
||||||
|
sortedThemes.forEach(([themeId, theme]) => {
|
||||||
|
const card = this.createThemeCard(themeId, theme);
|
||||||
|
themeGrid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select the initial theme
|
||||||
|
this.selectTheme(this.selectedThemeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
createThemeCard(themeId, theme) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'theme-card';
|
||||||
|
card.dataset.themeId = themeId;
|
||||||
|
|
||||||
|
// Preview canvas
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'theme-preview';
|
||||||
|
const previewCanvas = document.createElement('canvas');
|
||||||
|
previewCanvas.width = 200;
|
||||||
|
previewCanvas.height = 120;
|
||||||
|
preview.appendChild(previewCanvas);
|
||||||
|
|
||||||
|
// Theme info
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'theme-info';
|
||||||
|
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'theme-name';
|
||||||
|
name.textContent = theme.name;
|
||||||
|
|
||||||
|
const description = document.createElement('div');
|
||||||
|
description.className = 'theme-description';
|
||||||
|
description.textContent = theme.description;
|
||||||
|
|
||||||
|
info.appendChild(name);
|
||||||
|
info.appendChild(description);
|
||||||
|
|
||||||
|
// Popularity badge
|
||||||
|
if (theme.popularity > 0) {
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'popularity-badge';
|
||||||
|
badge.innerHTML = `⭐ ${theme.popularity}`;
|
||||||
|
card.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.appendChild(preview);
|
||||||
|
card.appendChild(info);
|
||||||
|
|
||||||
|
// Initialize mini theme preview AFTER card is built
|
||||||
|
this.initializeThemePreview(themeId, theme, previewCanvas, card);
|
||||||
|
|
||||||
|
// Click handler
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
this.selectTheme(themeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeThemePreview(themeId, theme, canvas, card) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const miniTheme = new theme.class(canvas, ctx);
|
||||||
|
|
||||||
|
// Set completion duration for preview
|
||||||
|
if (miniTheme.setCompletionDuration) {
|
||||||
|
miniTheme.setCompletionDuration(this.completionDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a static snapshot at 25% progress
|
||||||
|
miniTheme.render(0.25, 45000); // 45 seconds remaining
|
||||||
|
|
||||||
|
// Just show static preview - no animations
|
||||||
|
}
|
||||||
|
|
||||||
|
selectTheme(themeId) {
|
||||||
|
this.selectedThemeId = themeId;
|
||||||
|
localStorage.setItem('lastSelectedTheme', themeId);
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
document.querySelectorAll('.theme-card').forEach(card => {
|
||||||
|
card.classList.toggle('selected', card.dataset.themeId === themeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateStartButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeEventListeners() {
|
||||||
|
// Start button
|
||||||
|
document.getElementById('start-button').addEventListener('click', () => {
|
||||||
|
this.startTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timer screen buttons will be added when timer starts
|
||||||
|
|
||||||
|
// Quick timer buttons
|
||||||
|
document.querySelectorAll('.quick-timer').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
const minutes = parseInt(e.target.dataset.minutes);
|
||||||
|
const seconds = parseInt(e.target.dataset.seconds);
|
||||||
|
this.selectDuration(minutes, seconds);
|
||||||
|
|
||||||
|
// Update selected state
|
||||||
|
document.querySelectorAll('.quick-timer').forEach(b => b.classList.remove('selected'));
|
||||||
|
e.target.classList.add('selected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Time input
|
||||||
|
const timeInput = document.getElementById('time-input');
|
||||||
|
|
||||||
|
// Parse time input
|
||||||
|
const parseTimeInput = (value) => {
|
||||||
|
// Remove all non-numeric characters except :
|
||||||
|
const cleaned = value.replace(/[^\d:]/g, '');
|
||||||
|
|
||||||
|
// Handle different formats
|
||||||
|
if (cleaned.includes(':')) {
|
||||||
|
const parts = cleaned.split(':');
|
||||||
|
const minutes = parseInt(parts[0]) || 0;
|
||||||
|
const seconds = parseInt(parts[1]) || 0;
|
||||||
|
return { minutes: Math.min(999, minutes), seconds: Math.min(59, seconds) };
|
||||||
|
} else {
|
||||||
|
// If no colon, treat as minutes if > 2 digits, otherwise seconds
|
||||||
|
const num = parseInt(cleaned) || 0;
|
||||||
|
if (cleaned.length > 2 || num > 59) {
|
||||||
|
return { minutes: Math.min(999, num), seconds: 0 };
|
||||||
|
} else {
|
||||||
|
return { minutes: 0, seconds: num };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format and update time input
|
||||||
|
const formatTimeInput = (minutes, seconds) => {
|
||||||
|
if (minutes === 0) {
|
||||||
|
return `0:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
timeInput.addEventListener('input', (e) => {
|
||||||
|
const { minutes, seconds } = parseTimeInput(e.target.value);
|
||||||
|
this.selectedDuration = { minutes, seconds };
|
||||||
|
this.updateStartButton();
|
||||||
|
document.querySelectorAll('.quick-timer').forEach(b => b.classList.remove('selected'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format on blur
|
||||||
|
timeInput.addEventListener('blur', () => {
|
||||||
|
timeInput.value = formatTimeInput(this.selectedDuration.minutes, this.selectedDuration.seconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Enter key
|
||||||
|
timeInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!document.getElementById('start-button').disabled) {
|
||||||
|
this.startTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectDuration(minutes, seconds) {
|
||||||
|
this.selectedDuration = { minutes, seconds };
|
||||||
|
const timeInput = document.getElementById('time-input');
|
||||||
|
if (minutes === 0) {
|
||||||
|
timeInput.value = `0:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
} else {
|
||||||
|
timeInput.value = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
this.updateStartButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
formatDuration(minutes, seconds) {
|
||||||
|
if (minutes === 0) {
|
||||||
|
return `${seconds}s`;
|
||||||
|
} else if (seconds === 0) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
} else {
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStartButton() {
|
||||||
|
const totalSeconds = this.selectedDuration.minutes * 60 + this.selectedDuration.seconds;
|
||||||
|
const button = document.getElementById('start-button');
|
||||||
|
button.disabled = totalSeconds < 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeCanvas() {
|
||||||
|
if (this.canvas) {
|
||||||
|
this.canvas.width = window.innerWidth;
|
||||||
|
this.canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer() {
|
||||||
|
const totalSeconds = this.selectedDuration.minutes * 60 + this.selectedDuration.seconds;
|
||||||
|
|
||||||
|
if (totalSeconds < 1) {
|
||||||
|
alert('Please enter a valid duration (at least 1 second)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track theme popularity
|
||||||
|
this.themes[this.selectedThemeId].popularity++;
|
||||||
|
this.saveThemePopularity();
|
||||||
|
|
||||||
|
// Store settings for restart
|
||||||
|
this.currentSettings = {
|
||||||
|
theme: this.selectedThemeId,
|
||||||
|
duration: totalSeconds * 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
this.duration = totalSeconds * 1000; // Convert to milliseconds
|
||||||
|
this.startTime = Date.now();
|
||||||
|
|
||||||
|
// Store current content
|
||||||
|
this.setupContent = this.app.innerHTML;
|
||||||
|
|
||||||
|
// Create timer screen
|
||||||
|
this.app.innerHTML = `
|
||||||
|
<canvas id="timer-canvas"></canvas>
|
||||||
|
<button id="stop-button">Stop Timer</button>
|
||||||
|
<div id="completion-controls" class="completion-controls">
|
||||||
|
<button id="restart-button" class="control-button" title="Restart">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="menu-button" class="control-button" title="Back to Menu">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Set up timer screen
|
||||||
|
this.app.className = 'timer-active';
|
||||||
|
this.canvas = document.getElementById('timer-canvas');
|
||||||
|
this.ctx = this.canvas.getContext('2d');
|
||||||
|
this.resizeCanvas();
|
||||||
|
|
||||||
|
// Create theme instance
|
||||||
|
const ThemeClass = this.themes[this.selectedThemeId].class;
|
||||||
|
this.currentTheme = new ThemeClass(this.canvas, this.ctx);
|
||||||
|
|
||||||
|
// Pass completion duration to theme if it supports it
|
||||||
|
if (this.currentTheme.setCompletionDuration) {
|
||||||
|
this.currentTheme.setCompletionDuration(this.completionDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners for timer screen
|
||||||
|
document.getElementById('stop-button').addEventListener('click', () => this.stopTimer());
|
||||||
|
document.getElementById('restart-button').addEventListener('click', () => this.restartTimer());
|
||||||
|
document.getElementById('menu-button').addEventListener('click', () => this.backToMenu());
|
||||||
|
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTimer() {
|
||||||
|
if (this.animationId) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.backToMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
restartTimer() {
|
||||||
|
if (this.currentSettings) {
|
||||||
|
this.selectedThemeId = this.currentSettings.theme;
|
||||||
|
this.duration = this.currentSettings.duration;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
|
||||||
|
const ThemeClass = this.themes[this.selectedThemeId].class;
|
||||||
|
this.currentTheme = new ThemeClass(this.canvas, this.ctx);
|
||||||
|
|
||||||
|
if (this.currentTheme.setCompletionDuration) {
|
||||||
|
this.currentTheme.setCompletionDuration(this.completionDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide completion controls
|
||||||
|
document.getElementById('completion-controls').classList.remove('active');
|
||||||
|
document.getElementById('stop-button').style.display = 'block';
|
||||||
|
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backToMenu() {
|
||||||
|
if (this.animationId) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore setup screen
|
||||||
|
this.app.className = '';
|
||||||
|
this.app.innerHTML = this.setupContent;
|
||||||
|
|
||||||
|
// Re-initialize everything
|
||||||
|
this.initializeThemeGallery();
|
||||||
|
this.initializeEventListeners();
|
||||||
|
|
||||||
|
// Restore the selected duration in the UI
|
||||||
|
const timeInput = document.getElementById('time-input');
|
||||||
|
if (timeInput) {
|
||||||
|
const minutes = this.selectedDuration.minutes;
|
||||||
|
const seconds = this.selectedDuration.seconds;
|
||||||
|
if (minutes === 0) {
|
||||||
|
timeInput.value = `0:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
} else {
|
||||||
|
timeInput.value = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update quick timer selection
|
||||||
|
const totalSeconds = this.selectedDuration.minutes * 60 + this.selectedDuration.seconds;
|
||||||
|
document.querySelectorAll('.quick-timer').forEach(button => {
|
||||||
|
const btnMinutes = parseInt(button.dataset.minutes);
|
||||||
|
const btnSeconds = parseInt(button.dataset.seconds);
|
||||||
|
const btnTotal = btnMinutes * 60 + btnSeconds;
|
||||||
|
button.classList.toggle('selected', btnTotal === totalSeconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
animate() {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.startTime;
|
||||||
|
const progress = Math.min(elapsed / this.duration, 1.0);
|
||||||
|
const remainingTime = Math.max(0, this.duration - elapsed);
|
||||||
|
|
||||||
|
this.currentTheme.render(progress, remainingTime);
|
||||||
|
|
||||||
|
if (progress < 1.0) {
|
||||||
|
this.animationId = requestAnimationFrame(() => this.animate());
|
||||||
|
} else {
|
||||||
|
this.onTimerComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTimerComplete() {
|
||||||
|
// Hide stop button, show completion controls
|
||||||
|
document.getElementById('stop-button').style.display = 'none';
|
||||||
|
document.getElementById('completion-controls').classList.add('active');
|
||||||
|
|
||||||
|
if (this.currentTheme.onComplete) {
|
||||||
|
this.currentTheme.onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize app when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new TimerApp();
|
||||||
|
});
|
||||||
+310
@@ -0,0 +1,310 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0a;
|
||||||
|
--bg-secondary: #141414;
|
||||||
|
--bg-card: #1a1a1a;
|
||||||
|
--bg-hover: #242424;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--success: #10b981;
|
||||||
|
--danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timer Setup */
|
||||||
|
.timer-setup {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Grid */
|
||||||
|
.theme-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-info {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-description {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popularity-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fbbf24;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time Input */
|
||||||
|
.time-input {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.25rem;
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-timers {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-timer {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-timer:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-timer.selected {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Start Button */
|
||||||
|
.start-button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 3rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.start-button:disabled {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timer Screen */
|
||||||
|
.timer-active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timer-canvas {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stop-button {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(239, 68, 68, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stop-button:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-controls {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-controls.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button svg {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#setup-screen {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-timers {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
export class CircleShaderTheme {
|
||||||
|
constructor(canvas, ctx) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.gl = null;
|
||||||
|
this.program = null;
|
||||||
|
this.animationFrame = 0;
|
||||||
|
this.completionDuration = 2400; // milliseconds to reach resting state
|
||||||
|
this.initWebGL();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompletionDuration(duration) {
|
||||||
|
this.completionDuration = duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
initWebGL() {
|
||||||
|
// Create a temporary canvas for WebGL
|
||||||
|
this.glCanvas = document.createElement('canvas');
|
||||||
|
this.glCanvas.width = this.canvas.width;
|
||||||
|
this.glCanvas.height = this.canvas.height;
|
||||||
|
this.gl = this.glCanvas.getContext('webgl') || this.glCanvas.getContext('experimental-webgl');
|
||||||
|
|
||||||
|
if (!this.gl) {
|
||||||
|
console.error('WebGL not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertex shader
|
||||||
|
const vertexShaderSource = `
|
||||||
|
attribute vec2 a_position;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||||
|
v_uv = (a_position + 1.0) * 0.5;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fragment shader - circle segment
|
||||||
|
const fragmentShaderSource = `
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
uniform float u_time;
|
||||||
|
uniform float u_progress;
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = v_uv;
|
||||||
|
vec2 p = (uv - 0.5) * 2.0;
|
||||||
|
float aspect = u_resolution.x / u_resolution.y;
|
||||||
|
p.x *= aspect;
|
||||||
|
|
||||||
|
float dist = length(p);
|
||||||
|
float angle = atan(p.y, p.x);
|
||||||
|
|
||||||
|
// Background color
|
||||||
|
vec3 bgColor = vec3(0.1, 0.1, 0.1);
|
||||||
|
vec3 color = bgColor;
|
||||||
|
|
||||||
|
// Circle parameters
|
||||||
|
float radius = 0.7;
|
||||||
|
float thickness = 0.04;
|
||||||
|
|
||||||
|
// Background circle (gray)
|
||||||
|
float bgCircle = abs(dist - radius);
|
||||||
|
if (bgCircle < thickness * 0.5) {
|
||||||
|
float edge = smoothstep(thickness * 0.5, thickness * 0.45, bgCircle);
|
||||||
|
color = mix(color, vec3(0.2), edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress arc
|
||||||
|
float startAngle = -1.5708; // Start from top (-PI/2)
|
||||||
|
float endAngle = startAngle + (6.28318 * (1.0 - u_progress));
|
||||||
|
float normalizedAngle = mod(angle - startAngle, 6.28318);
|
||||||
|
|
||||||
|
if (normalizedAngle < endAngle - startAngle) {
|
||||||
|
float arcDist = abs(dist - radius);
|
||||||
|
if (arcDist < thickness) {
|
||||||
|
float edge = smoothstep(thickness, thickness * 0.8, arcDist);
|
||||||
|
|
||||||
|
// Color gradient from green to red
|
||||||
|
float r = u_progress;
|
||||||
|
float g = 1.0 - u_progress;
|
||||||
|
vec3 arcColor = vec3(r, g, 0.4);
|
||||||
|
|
||||||
|
color = mix(color, arcColor, edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subtle glow
|
||||||
|
float glow = exp(-pow(dist - radius, 2.0) * 50.0) * 0.3;
|
||||||
|
color += vec3(u_progress, 1.0 - u_progress, 0.4) * glow * (1.0 - u_progress);
|
||||||
|
|
||||||
|
// Completion state
|
||||||
|
if (u_progress > 1.0) {
|
||||||
|
float completion = min((u_progress - 1.0) * 2.0, 1.0);
|
||||||
|
|
||||||
|
// Fade out
|
||||||
|
color *= 1.0 - completion * 0.7;
|
||||||
|
|
||||||
|
// Pulsing ring that slows down with completion
|
||||||
|
float pulseSpeed = mix(2.0, 0.5, completion);
|
||||||
|
float pulse = sin(u_time * pulseSpeed) * 0.05 + 0.95;
|
||||||
|
float ringDist = abs(dist - radius * pulse);
|
||||||
|
if (ringDist < thickness * 2.0) {
|
||||||
|
float ringEdge = smoothstep(thickness * 2.0, thickness * 1.5, ringDist);
|
||||||
|
color += vec3(1.0, 0.2, 0.2) * ringEdge * completion * 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create and compile shaders
|
||||||
|
const vertexShader = this.createShader(this.gl.VERTEX_SHADER, vertexShaderSource);
|
||||||
|
const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, fragmentShaderSource);
|
||||||
|
|
||||||
|
if (!vertexShader || !fragmentShader) return;
|
||||||
|
|
||||||
|
// Create program
|
||||||
|
this.program = this.gl.createProgram();
|
||||||
|
this.gl.attachShader(this.program, vertexShader);
|
||||||
|
this.gl.attachShader(this.program, fragmentShader);
|
||||||
|
this.gl.linkProgram(this.program);
|
||||||
|
|
||||||
|
if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
|
||||||
|
console.error('Unable to initialize the shader program:', this.gl.getProgramInfoLog(this.program));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up geometry
|
||||||
|
const positions = new Float32Array([
|
||||||
|
-1, -1,
|
||||||
|
1, -1,
|
||||||
|
-1, 1,
|
||||||
|
1, 1
|
||||||
|
]);
|
||||||
|
|
||||||
|
const positionBuffer = this.gl.createBuffer();
|
||||||
|
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const positionLocation = this.gl.getAttribLocation(this.program, 'a_position');
|
||||||
|
this.gl.enableVertexAttribArray(positionLocation);
|
||||||
|
this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
// Get uniform locations
|
||||||
|
this.uniforms = {
|
||||||
|
time: this.gl.getUniformLocation(this.program, 'u_time'),
|
||||||
|
progress: this.gl.getUniformLocation(this.program, 'u_progress'),
|
||||||
|
resolution: this.gl.getUniformLocation(this.program, 'u_resolution')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createShader(type, source) {
|
||||||
|
const shader = this.gl.createShader(type);
|
||||||
|
this.gl.shaderSource(shader, source);
|
||||||
|
this.gl.compileShader(shader);
|
||||||
|
|
||||||
|
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
||||||
|
console.error('An error occurred compiling the shaders:', this.gl.getShaderInfoLog(shader));
|
||||||
|
this.gl.deleteShader(shader);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(progress, remainingTime) {
|
||||||
|
this.animationFrame++;
|
||||||
|
|
||||||
|
// Clear main canvas
|
||||||
|
this.ctx.fillStyle = '#1a1a1a';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
if (this.gl && this.program) {
|
||||||
|
// Resize WebGL canvas if needed
|
||||||
|
if (this.glCanvas.width !== this.canvas.width || this.glCanvas.height !== this.canvas.height) {
|
||||||
|
this.glCanvas.width = this.canvas.width;
|
||||||
|
this.glCanvas.height = this.canvas.height;
|
||||||
|
this.gl.viewport(0, 0, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear WebGL canvas
|
||||||
|
this.gl.clearColor(0.1, 0.1, 0.1, 1.0);
|
||||||
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
// Use shader program
|
||||||
|
this.gl.useProgram(this.program);
|
||||||
|
|
||||||
|
// Set uniforms
|
||||||
|
this.gl.uniform1f(this.uniforms.time, this.animationFrame * 0.01);
|
||||||
|
this.gl.uniform1f(this.uniforms.progress, progress);
|
||||||
|
this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
|
||||||
|
// Draw
|
||||||
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
// Copy WebGL canvas to main canvas
|
||||||
|
this.ctx.drawImage(this.glCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw timer text on top
|
||||||
|
const minutes = Math.floor(remainingTime / 60000);
|
||||||
|
const seconds = Math.floor((remainingTime % 60000) / 1000);
|
||||||
|
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const centerX = this.canvas.width / 2;
|
||||||
|
const centerY = this.canvas.height / 2;
|
||||||
|
const fontSize = Math.min(this.canvas.width, this.canvas.height) * 0.175;
|
||||||
|
|
||||||
|
this.ctx.font = `${fontSize}px Arial`;
|
||||||
|
this.ctx.fillStyle = '#ffffff';
|
||||||
|
this.ctx.textAlign = 'center';
|
||||||
|
this.ctx.textBaseline = 'middle';
|
||||||
|
this.ctx.fillText(timeString, centerX, centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete() {
|
||||||
|
let completionFrame = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
completionFrame++;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const restingProgress = Math.min(elapsed / this.completionDuration, 1.0);
|
||||||
|
|
||||||
|
if (this.gl && this.program) {
|
||||||
|
this.ctx.fillStyle = '#1a1a1a';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
this.gl.clearColor(0.1, 0.1, 0.1, 1.0);
|
||||||
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
this.gl.useProgram(this.program);
|
||||||
|
|
||||||
|
const progress = 1.0 + (restingProgress * 0.5);
|
||||||
|
this.gl.uniform1f(this.uniforms.time, elapsed * 0.001);
|
||||||
|
this.gl.uniform1f(this.uniforms.progress, progress);
|
||||||
|
this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
|
||||||
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
this.ctx.drawImage(this.glCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
export class CircularBlobsShaderTheme {
|
||||||
|
constructor(canvas, ctx) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.gl = null;
|
||||||
|
this.program = null;
|
||||||
|
this.animationFrame = 0;
|
||||||
|
this.completionDuration = 2400; // milliseconds to reach resting state
|
||||||
|
this.initWebGL();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompletionDuration(duration) {
|
||||||
|
this.completionDuration = duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
initWebGL() {
|
||||||
|
// Create a temporary canvas for WebGL
|
||||||
|
this.glCanvas = document.createElement('canvas');
|
||||||
|
this.glCanvas.width = this.canvas.width;
|
||||||
|
this.glCanvas.height = this.canvas.height;
|
||||||
|
this.gl = this.glCanvas.getContext('webgl') || this.glCanvas.getContext('experimental-webgl');
|
||||||
|
|
||||||
|
if (!this.gl) {
|
||||||
|
console.error('WebGL not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertex shader
|
||||||
|
const vertexShaderSource = `
|
||||||
|
attribute vec2 a_position;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||||
|
v_uv = (a_position + 1.0) * 0.5;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fragment shader - circular blobs
|
||||||
|
const fragmentShaderSource = `
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
uniform float u_time;
|
||||||
|
uniform float u_progress;
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
float easeInBack(float t) {
|
||||||
|
float c1 = 1.70158;
|
||||||
|
float c3 = c1 + 1.0;
|
||||||
|
return c3 * t * t * t - c1 * t * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = v_uv;
|
||||||
|
vec2 p = (uv - 0.5) * 2.0;
|
||||||
|
float aspect = u_resolution.x / u_resolution.y;
|
||||||
|
p.x *= aspect;
|
||||||
|
|
||||||
|
vec3 color = vec3(0.0);
|
||||||
|
float numBlobs = 12.0;
|
||||||
|
|
||||||
|
// Draw blobs in a circle
|
||||||
|
for (float i = 0.0; i < 12.0; i++) {
|
||||||
|
float angle = (i / numBlobs) * 6.28318 - 1.5708; // Start from top
|
||||||
|
float radius = 0.6;
|
||||||
|
vec2 blobPos = vec2(cos(angle), sin(angle)) * radius;
|
||||||
|
|
||||||
|
// Calculate which blobs should be consumed
|
||||||
|
float blobProgress = u_progress * numBlobs;
|
||||||
|
float isConsumed = 0.0;
|
||||||
|
float localProgress = 0.0;
|
||||||
|
|
||||||
|
if (i < blobProgress) {
|
||||||
|
isConsumed = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the blob currently being consumed
|
||||||
|
if (i < blobProgress && i + 1.0 > blobProgress) {
|
||||||
|
localProgress = fract(blobProgress);
|
||||||
|
// Apply easing
|
||||||
|
localProgress = easeInBack(localProgress);
|
||||||
|
isConsumed = localProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only render if not fully consumed
|
||||||
|
if (isConsumed < 1.0) {
|
||||||
|
// Blob size based on consumption
|
||||||
|
float blobRadius = 0.06 * (1.0 - isConsumed);
|
||||||
|
float dist = distance(p, blobPos);
|
||||||
|
|
||||||
|
// Smooth blob
|
||||||
|
float blob = 1.0 - smoothstep(blobRadius - 0.01, blobRadius, dist);
|
||||||
|
|
||||||
|
// Color based on progress
|
||||||
|
float hue = 120.0 - (u_progress * 120.0);
|
||||||
|
vec3 blobColor = vec3(
|
||||||
|
0.5 + 0.5 * cos(radians(hue)),
|
||||||
|
0.5 + 0.5 * cos(radians(hue - 120.0)),
|
||||||
|
0.5 + 0.5 * cos(radians(hue - 240.0))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add glow
|
||||||
|
float glow = exp(-dist * 20.0) * 0.3 * (1.0 - isConsumed);
|
||||||
|
|
||||||
|
|
||||||
|
color += blobColor * (blob + glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No center dot - keep centerDist for completion effect
|
||||||
|
float centerDist = length(p);
|
||||||
|
|
||||||
|
// Completion state - calm fade
|
||||||
|
if (u_progress > 1.0) {
|
||||||
|
float completion = min((u_progress - 1.0) * 2.0, 1.0);
|
||||||
|
|
||||||
|
// Simple fade to dark
|
||||||
|
color *= 1.0 - completion * 0.8;
|
||||||
|
|
||||||
|
// Gentle breathing center dot - slows down with completion
|
||||||
|
float breathSpeed = mix(1.5, 0.5, completion);
|
||||||
|
float breathe = sin(u_time * breathSpeed) * 0.05 + 0.95;
|
||||||
|
float centerGlow = 1.0 - smoothstep(0.0, 0.15 * breathe, centerDist);
|
||||||
|
color += vec3(0.8, 0.3, 0.3) * centerGlow * completion * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create and compile shaders
|
||||||
|
const vertexShader = this.createShader(this.gl.VERTEX_SHADER, vertexShaderSource);
|
||||||
|
const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, fragmentShaderSource);
|
||||||
|
|
||||||
|
if (!vertexShader || !fragmentShader) return;
|
||||||
|
|
||||||
|
// Create program
|
||||||
|
this.program = this.gl.createProgram();
|
||||||
|
this.gl.attachShader(this.program, vertexShader);
|
||||||
|
this.gl.attachShader(this.program, fragmentShader);
|
||||||
|
this.gl.linkProgram(this.program);
|
||||||
|
|
||||||
|
if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
|
||||||
|
console.error('Unable to initialize the shader program:', this.gl.getProgramInfoLog(this.program));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up geometry (full screen quad)
|
||||||
|
const positions = new Float32Array([
|
||||||
|
-1, -1,
|
||||||
|
1, -1,
|
||||||
|
-1, 1,
|
||||||
|
1, 1
|
||||||
|
]);
|
||||||
|
|
||||||
|
const positionBuffer = this.gl.createBuffer();
|
||||||
|
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const positionLocation = this.gl.getAttribLocation(this.program, 'a_position');
|
||||||
|
this.gl.enableVertexAttribArray(positionLocation);
|
||||||
|
this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
// Get uniform locations
|
||||||
|
this.uniforms = {
|
||||||
|
time: this.gl.getUniformLocation(this.program, 'u_time'),
|
||||||
|
progress: this.gl.getUniformLocation(this.program, 'u_progress'),
|
||||||
|
resolution: this.gl.getUniformLocation(this.program, 'u_resolution')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createShader(type, source) {
|
||||||
|
const shader = this.gl.createShader(type);
|
||||||
|
this.gl.shaderSource(shader, source);
|
||||||
|
this.gl.compileShader(shader);
|
||||||
|
|
||||||
|
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
||||||
|
console.error('An error occurred compiling the shaders:', this.gl.getShaderInfoLog(shader));
|
||||||
|
this.gl.deleteShader(shader);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(progress, remainingTime) {
|
||||||
|
this.animationFrame++;
|
||||||
|
|
||||||
|
// Clear main canvas
|
||||||
|
this.ctx.fillStyle = '#0a0a0a';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
if (this.gl && this.program) {
|
||||||
|
// Resize WebGL canvas if needed
|
||||||
|
if (this.glCanvas.width !== this.canvas.width || this.glCanvas.height !== this.canvas.height) {
|
||||||
|
this.glCanvas.width = this.canvas.width;
|
||||||
|
this.glCanvas.height = this.canvas.height;
|
||||||
|
this.gl.viewport(0, 0, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear WebGL canvas
|
||||||
|
this.gl.clearColor(0.04, 0.04, 0.04, 1.0);
|
||||||
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
// Use shader program
|
||||||
|
this.gl.useProgram(this.program);
|
||||||
|
|
||||||
|
// Set uniforms
|
||||||
|
this.gl.uniform1f(this.uniforms.time, this.animationFrame * 0.01);
|
||||||
|
this.gl.uniform1f(this.uniforms.progress, progress);
|
||||||
|
this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
|
||||||
|
// Draw
|
||||||
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
// Copy WebGL canvas to main canvas
|
||||||
|
this.ctx.drawImage(this.glCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw timer text on top
|
||||||
|
const minutes = Math.floor(remainingTime / 60000);
|
||||||
|
const seconds = Math.floor((remainingTime % 60000) / 1000);
|
||||||
|
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const centerX = this.canvas.width / 2;
|
||||||
|
const centerY = this.canvas.height / 2;
|
||||||
|
|
||||||
|
this.ctx.font = '48px Arial';
|
||||||
|
this.ctx.fillStyle = '#ffffff';
|
||||||
|
this.ctx.textAlign = 'center';
|
||||||
|
this.ctx.textBaseline = 'middle';
|
||||||
|
this.ctx.fillText(timeString, centerX, centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete() {
|
||||||
|
let completionFrame = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
completionFrame++;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const restingProgress = Math.min(elapsed / this.completionDuration, 1.0);
|
||||||
|
|
||||||
|
// Continue using shader for completion
|
||||||
|
if (this.gl && this.program) {
|
||||||
|
this.ctx.fillStyle = '#0a0a0a';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
this.gl.clearColor(0.04, 0.04, 0.04, 1.0);
|
||||||
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
this.gl.useProgram(this.program);
|
||||||
|
|
||||||
|
const progress = 1.0 + (restingProgress * 0.5);
|
||||||
|
// Use elapsed time for consistent animation speed
|
||||||
|
this.gl.uniform1f(this.uniforms.time, elapsed * 0.001);
|
||||||
|
this.gl.uniform1f(this.uniforms.progress, progress);
|
||||||
|
this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
|
||||||
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
this.ctx.drawImage(this.glCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
export class RainbowShaderTheme {
|
||||||
|
constructor(canvas, ctx) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.gl = null;
|
||||||
|
this.program = null;
|
||||||
|
this.animationFrame = 0;
|
||||||
|
this.completionDuration = 2400; // milliseconds to reach resting state
|
||||||
|
this.initWebGL();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompletionDuration(duration) {
|
||||||
|
this.completionDuration = duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
initWebGL() {
|
||||||
|
// Create a temporary canvas for WebGL
|
||||||
|
this.glCanvas = document.createElement('canvas');
|
||||||
|
this.glCanvas.width = this.canvas.width;
|
||||||
|
this.glCanvas.height = this.canvas.height;
|
||||||
|
this.gl = this.glCanvas.getContext('webgl') || this.glCanvas.getContext('experimental-webgl');
|
||||||
|
|
||||||
|
if (!this.gl) {
|
||||||
|
console.error('WebGL not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertex shader
|
||||||
|
const vertexShaderSource = `
|
||||||
|
attribute vec2 a_position;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||||
|
v_uv = (a_position + 1.0) * 0.5;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fragment shader - rainbow circles
|
||||||
|
const fragmentShaderSource = `
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
uniform float u_time;
|
||||||
|
uniform float u_progress;
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
vec3 hsv2rgb(vec3 c) {
|
||||||
|
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
|
||||||
|
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
|
||||||
|
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = v_uv;
|
||||||
|
vec2 p = (uv - 0.5) * 2.0;
|
||||||
|
float aspect = u_resolution.x / u_resolution.y;
|
||||||
|
p.x *= aspect;
|
||||||
|
|
||||||
|
vec3 color = vec3(0.0);
|
||||||
|
float centerDist = length(p);
|
||||||
|
|
||||||
|
// Multiple animated circles
|
||||||
|
for (float i = 0.0; i < 6.0; i++) {
|
||||||
|
float angle = (i / 6.0) * 6.28318;
|
||||||
|
float orbitRadius = 0.3;
|
||||||
|
float speed = 0.5 + i * 0.2;
|
||||||
|
|
||||||
|
// Orbit position
|
||||||
|
float orbitAngle = angle + u_time * speed * (mod(i, 2.0) * 2.0 - 1.0);
|
||||||
|
vec2 circlePos = vec2(cos(orbitAngle), sin(orbitAngle)) * orbitRadius;
|
||||||
|
|
||||||
|
// Circle properties
|
||||||
|
float radius = 0.08 + i * 0.02;
|
||||||
|
radius *= (1.0 - u_progress);
|
||||||
|
|
||||||
|
float dist = distance(p, circlePos);
|
||||||
|
float circle = 1.0 - smoothstep(radius - 0.01, radius, dist);
|
||||||
|
|
||||||
|
// Rainbow color
|
||||||
|
vec3 hsvColor = vec3(i / 6.0, 1.0, 1.0);
|
||||||
|
vec3 rgbColor = hsv2rgb(hsvColor);
|
||||||
|
|
||||||
|
// Add glow
|
||||||
|
float glow = exp(-dist * dist / (radius * radius * 2.0)) * 0.5;
|
||||||
|
|
||||||
|
color += rgbColor * (circle + glow) * (1.0 - u_progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center pulse
|
||||||
|
float centerRadius = 0.05 * (1.0 + sin(u_time * 3.0) * 0.1);
|
||||||
|
float centerCircle = 1.0 - smoothstep(centerRadius - 0.01, centerRadius, centerDist);
|
||||||
|
color += vec3(1.0) * centerCircle * 0.8;
|
||||||
|
|
||||||
|
// Add trails
|
||||||
|
vec3 trailColor = vec3(0.0);
|
||||||
|
for (float i = 0.0; i < 6.0; i++) {
|
||||||
|
float hue = i / 6.0 + u_time * 0.1;
|
||||||
|
vec3 rainbow = hsv2rgb(vec3(mod(hue, 1.0), 1.0, 0.5));
|
||||||
|
trailColor += rainbow * 0.02 * (1.0 - u_progress);
|
||||||
|
}
|
||||||
|
color += trailColor;
|
||||||
|
|
||||||
|
// Completion state
|
||||||
|
if (u_progress > 1.0) {
|
||||||
|
float completion = min((u_progress - 1.0) * 2.0, 1.0);
|
||||||
|
|
||||||
|
// Fade to dark
|
||||||
|
color *= 1.0 - completion * 0.9;
|
||||||
|
|
||||||
|
// Gentle rainbow pulse at center
|
||||||
|
float breathSpeed = mix(2.0, 0.5, completion);
|
||||||
|
float breathe = sin(u_time * breathSpeed) * 0.1 + 0.9;
|
||||||
|
|
||||||
|
// Create a soft rainbow gradient that pulses
|
||||||
|
float gradientRadius = 0.3 * breathe;
|
||||||
|
float gradientFade = 1.0 - smoothstep(0.0, gradientRadius, centerDist);
|
||||||
|
|
||||||
|
// Rotating hue
|
||||||
|
float hue = u_time * 0.5;
|
||||||
|
vec3 pulseColor = hsv2rgb(vec3(mod(hue, 1.0), 0.6, 0.8));
|
||||||
|
|
||||||
|
color += pulseColor * gradientFade * completion * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create and compile shaders
|
||||||
|
const vertexShader = this.createShader(this.gl.VERTEX_SHADER, vertexShaderSource);
|
||||||
|
const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, fragmentShaderSource);
|
||||||
|
|
||||||
|
if (!vertexShader || !fragmentShader) return;
|
||||||
|
|
||||||
|
// Create program
|
||||||
|
this.program = this.gl.createProgram();
|
||||||
|
this.gl.attachShader(this.program, vertexShader);
|
||||||
|
this.gl.attachShader(this.program, fragmentShader);
|
||||||
|
this.gl.linkProgram(this.program);
|
||||||
|
|
||||||
|
if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
|
||||||
|
console.error('Unable to initialize the shader program:', this.gl.getProgramInfoLog(this.program));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up geometry
|
||||||
|
const positions = new Float32Array([
|
||||||
|
-1, -1,
|
||||||
|
1, -1,
|
||||||
|
-1, 1,
|
||||||
|
1, 1
|
||||||
|
]);
|
||||||
|
|
||||||
|
const positionBuffer = this.gl.createBuffer();
|
||||||
|
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const positionLocation = this.gl.getAttribLocation(this.program, 'a_position');
|
||||||
|
this.gl.enableVertexAttribArray(positionLocation);
|
||||||
|
this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
// Get uniform locations
|
||||||
|
this.uniforms = {
|
||||||
|
time: this.gl.getUniformLocation(this.program, 'u_time'),
|
||||||
|
progress: this.gl.getUniformLocation(this.program, 'u_progress'),
|
||||||
|
resolution: this.gl.getUniformLocation(this.program, 'u_resolution')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createShader(type, source) {
|
||||||
|
const shader = this.gl.createShader(type);
|
||||||
|
this.gl.shaderSource(shader, source);
|
||||||
|
this.gl.compileShader(shader);
|
||||||
|
|
||||||
|
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
||||||
|
console.error('An error occurred compiling the shaders:', this.gl.getShaderInfoLog(shader));
|
||||||
|
this.gl.deleteShader(shader);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(progress, remainingTime) {
|
||||||
|
this.animationFrame++;
|
||||||
|
|
||||||
|
// Clear main canvas with slight trail
|
||||||
|
this.ctx.fillStyle = 'rgba(26, 26, 26, 0.1)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
if (this.gl && this.program) {
|
||||||
|
// Resize WebGL canvas if needed
|
||||||
|
if (this.glCanvas.width !== this.canvas.width || this.glCanvas.height !== this.canvas.height) {
|
||||||
|
this.glCanvas.width = this.canvas.width;
|
||||||
|
this.glCanvas.height = this.canvas.height;
|
||||||
|
this.gl.viewport(0, 0, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear WebGL canvas
|
||||||
|
this.gl.clearColor(0.1, 0.1, 0.1, 0.0);
|
||||||
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
// Enable blending for trails
|
||||||
|
this.gl.enable(this.gl.BLEND);
|
||||||
|
this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE);
|
||||||
|
|
||||||
|
// Use shader program
|
||||||
|
this.gl.useProgram(this.program);
|
||||||
|
|
||||||
|
// Set uniforms
|
||||||
|
this.gl.uniform1f(this.uniforms.time, this.animationFrame * 0.01);
|
||||||
|
this.gl.uniform1f(this.uniforms.progress, progress);
|
||||||
|
this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
|
||||||
|
// Draw
|
||||||
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
// Copy WebGL canvas to main canvas
|
||||||
|
this.ctx.drawImage(this.glCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw timer text on top
|
||||||
|
const minutes = Math.floor(remainingTime / 60000);
|
||||||
|
const seconds = Math.floor((remainingTime % 60000) / 1000);
|
||||||
|
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const centerX = this.canvas.width / 2;
|
||||||
|
const centerY = this.canvas.height / 2;
|
||||||
|
const fontSize = 60;
|
||||||
|
|
||||||
|
// Black background for text
|
||||||
|
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
this.ctx.fillRect(centerX - 150, centerY - 60, 300, 120);
|
||||||
|
|
||||||
|
this.ctx.font = `bold ${fontSize}px Arial`;
|
||||||
|
this.ctx.fillStyle = '#ffffff';
|
||||||
|
this.ctx.textAlign = 'center';
|
||||||
|
this.ctx.textBaseline = 'middle';
|
||||||
|
this.ctx.fillText(timeString, centerX, centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete() {
|
||||||
|
let completionFrame = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
completionFrame++;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const restingProgress = Math.min(elapsed / this.completionDuration, 1.0);
|
||||||
|
|
||||||
|
if (this.gl && this.program) {
|
||||||
|
this.ctx.fillStyle = 'rgba(26, 26, 26, 0.05)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
this.gl.clearColor(0.1, 0.1, 0.1, 0.0);
|
||||||
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
this.gl.useProgram(this.program);
|
||||||
|
|
||||||
|
const progress = 1.0 + (restingProgress * 0.5);
|
||||||
|
this.gl.uniform1f(this.uniforms.time, elapsed * 0.001);
|
||||||
|
this.gl.uniform1f(this.uniforms.progress, progress);
|
||||||
|
this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
|
||||||
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
this.ctx.drawImage(this.glCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
export class ShaderTheme {
|
||||||
|
constructor(canvas, ctx) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.gl = null;
|
||||||
|
this.program = null;
|
||||||
|
this.animationFrame = 0;
|
||||||
|
this.completionDuration = 2400; // milliseconds to reach resting state
|
||||||
|
this.initWebGL();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompletionDuration(duration) {
|
||||||
|
this.completionDuration = duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
initWebGL() {
|
||||||
|
// Create a temporary canvas for WebGL
|
||||||
|
this.glCanvas = document.createElement('canvas');
|
||||||
|
this.glCanvas.width = this.canvas.width;
|
||||||
|
this.glCanvas.height = this.canvas.height;
|
||||||
|
this.gl = this.glCanvas.getContext('webgl') || this.glCanvas.getContext('experimental-webgl');
|
||||||
|
|
||||||
|
if (!this.gl) {
|
||||||
|
console.error('WebGL not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertex shader - simple quad
|
||||||
|
const vertexShaderSource = `
|
||||||
|
attribute vec2 a_position;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||||
|
v_uv = (a_position + 1.0) * 0.5;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fragment shader - elegant animated timer
|
||||||
|
const fragmentShaderSource = `
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
uniform float u_time;
|
||||||
|
uniform float u_progress;
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = v_uv;
|
||||||
|
vec2 p = (uv - 0.5) * 2.0;
|
||||||
|
float aspect = u_resolution.x / u_resolution.y;
|
||||||
|
p.x *= aspect;
|
||||||
|
|
||||||
|
float dist = length(p);
|
||||||
|
float angle = atan(p.y, p.x);
|
||||||
|
|
||||||
|
// Create multiple rotating rings that deplete like a clock
|
||||||
|
float rings = 0.0;
|
||||||
|
for (float i = 0.0; i < 3.0; i++) {
|
||||||
|
float ringRadius = 0.3 + i * 0.15;
|
||||||
|
float ringSpeed = 0.5 + i * 0.3;
|
||||||
|
|
||||||
|
// Each ring depletes at different rates
|
||||||
|
float ringProgress = u_progress * (1.0 + i * 0.3);
|
||||||
|
ringProgress = min(ringProgress, 1.0);
|
||||||
|
|
||||||
|
// Create clock-like depletion starting from top
|
||||||
|
float depletionAngle = ringProgress * 6.28318;
|
||||||
|
float adjustedAngle = mod(angle + 1.5708, 6.28318); // Start from top
|
||||||
|
float arcMask = 1.0 - smoothstep(depletionAngle - 0.1, depletionAngle, adjustedAngle);
|
||||||
|
|
||||||
|
// Rotating effect
|
||||||
|
float rotationOffset = u_time * ringSpeed * (mod(i, 2.0) * 2.0 - 1.0);
|
||||||
|
|
||||||
|
float ring = abs(dist - ringRadius);
|
||||||
|
float ringMask = 1.0 - smoothstep(0.015, 0.025, ring);
|
||||||
|
|
||||||
|
// Add subtle rotation animation to the pattern
|
||||||
|
float pattern = sin(angle * 8.0 + rotationOffset) * 0.3 + 0.7;
|
||||||
|
|
||||||
|
rings += ringMask * arcMask * pattern * (1.0 - i * 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color gradient based on progress
|
||||||
|
vec3 color1 = vec3(0.2, 0.6, 1.0); // Cool blue
|
||||||
|
vec3 color2 = vec3(1.0, 0.5, 0.2); // Warm orange
|
||||||
|
vec3 color3 = vec3(1.0, 0.2, 0.3); // Red
|
||||||
|
|
||||||
|
vec3 color;
|
||||||
|
if (u_progress < 0.5) {
|
||||||
|
color = mix(color1, color2, u_progress * 2.0);
|
||||||
|
} else {
|
||||||
|
color = mix(color2, color3, (u_progress - 0.5) * 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center circle that pulses
|
||||||
|
float centerRadius = 0.15 * (1.0 + sin(u_time * 3.0) * 0.1);
|
||||||
|
float centerMask = 1.0 - smoothstep(centerRadius - 0.02, centerRadius, dist);
|
||||||
|
|
||||||
|
// Combine elements
|
||||||
|
vec3 finalColor = color * rings;
|
||||||
|
finalColor += color * centerMask * 0.8;
|
||||||
|
|
||||||
|
// Add radial gradient fade
|
||||||
|
float fade = 1.0 - smoothstep(0.0, 1.2, dist);
|
||||||
|
finalColor *= fade;
|
||||||
|
|
||||||
|
// Subtle overall pulse
|
||||||
|
finalColor *= 0.9 + sin(u_time * 2.0 + dist * 5.0) * 0.1;
|
||||||
|
|
||||||
|
// Completion state - rings collapse inward
|
||||||
|
if (u_progress > 1.0) {
|
||||||
|
float completion = min((u_progress - 1.0) * 2.0, 1.0);
|
||||||
|
|
||||||
|
// Rings collapse to center
|
||||||
|
float collapse = 1.0 - completion;
|
||||||
|
|
||||||
|
// Create imploding rings effect
|
||||||
|
for (float i = 0.0; i < 5.0; i++) {
|
||||||
|
float ringTime = completion + i * 0.1;
|
||||||
|
float ringRadius = (0.8 - ringTime) * collapse;
|
||||||
|
if (ringRadius > 0.0) {
|
||||||
|
float ring = abs(dist - ringRadius);
|
||||||
|
float ringMask = 1.0 - smoothstep(0.01, 0.03, ring);
|
||||||
|
float ringFade = 1.0 - ringTime;
|
||||||
|
finalColor += color * ringMask * ringFade * 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade the original content
|
||||||
|
finalColor *= collapse;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(finalColor, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create and compile shaders
|
||||||
|
const vertexShader = this.createShader(this.gl.VERTEX_SHADER, vertexShaderSource);
|
||||||
|
const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, fragmentShaderSource);
|
||||||
|
|
||||||
|
if (!vertexShader || !fragmentShader) return;
|
||||||
|
|
||||||
|
// Create program
|
||||||
|
this.program = this.gl.createProgram();
|
||||||
|
this.gl.attachShader(this.program, vertexShader);
|
||||||
|
this.gl.attachShader(this.program, fragmentShader);
|
||||||
|
this.gl.linkProgram(this.program);
|
||||||
|
|
||||||
|
if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
|
||||||
|
console.error('Unable to initialize the shader program:', this.gl.getProgramInfoLog(this.program));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up geometry (full screen quad)
|
||||||
|
const positions = new Float32Array([
|
||||||
|
-1, -1,
|
||||||
|
1, -1,
|
||||||
|
-1, 1,
|
||||||
|
1, 1
|
||||||
|
]);
|
||||||
|
|
||||||
|
const positionBuffer = this.gl.createBuffer();
|
||||||
|
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const positionLocation = this.gl.getAttribLocation(this.program, 'a_position');
|
||||||
|
this.gl.enableVertexAttribArray(positionLocation);
|
||||||
|
this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
// Get uniform locations
|
||||||
|
this.uniforms = {
|
||||||
|
time: this.gl.getUniformLocation(this.program, 'u_time'),
|
||||||
|
progress: this.gl.getUniformLocation(this.program, 'u_progress'),
|
||||||
|
resolution: this.gl.getUniformLocation(this.program, 'u_resolution')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createShader(type, source) {
|
||||||
|
const shader = this.gl.createShader(type);
|
||||||
|
this.gl.shaderSource(shader, source);
|
||||||
|
this.gl.compileShader(shader);
|
||||||
|
|
||||||
|
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
||||||
|
console.error('An error occurred compiling the shaders:', this.gl.getShaderInfoLog(shader));
|
||||||
|
this.gl.deleteShader(shader);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(progress, remainingTime) {
|
||||||
|
this.animationFrame++;
|
||||||
|
|
||||||
|
// Clear main canvas with deep black
|
||||||
|
this.ctx.fillStyle = '#000000';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
if (this.gl && this.program) {
|
||||||
|
// Resize WebGL canvas if needed
|
||||||
|
if (this.glCanvas.width !== this.canvas.width || this.glCanvas.height !== this.canvas.height) {
|
||||||
|
this.glCanvas.width = this.canvas.width;
|
||||||
|
this.glCanvas.height = this.canvas.height;
|
||||||
|
this.gl.viewport(0, 0, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear WebGL canvas with pure black
|
||||||
|
this.gl.clearColor(0.0, 0.0, 0.0, 1.0);
|
||||||
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
// Use shader program
|
||||||
|
this.gl.useProgram(this.program);
|
||||||
|
|
||||||
|
// Set uniforms
|
||||||
|
this.gl.uniform1f(this.uniforms.time, this.animationFrame * 0.01);
|
||||||
|
this.gl.uniform1f(this.uniforms.progress, progress);
|
||||||
|
this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
|
||||||
|
// Draw
|
||||||
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
// Copy WebGL canvas to main canvas
|
||||||
|
this.ctx.drawImage(this.glCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw timer text on top
|
||||||
|
const minutes = Math.floor(remainingTime / 60000);
|
||||||
|
const seconds = Math.floor((remainingTime % 60000) / 1000);
|
||||||
|
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const centerX = this.canvas.width / 2;
|
||||||
|
const centerY = this.canvas.height / 2;
|
||||||
|
|
||||||
|
// Text shadow for better readability
|
||||||
|
this.ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||||
|
this.ctx.shadowBlur = 10;
|
||||||
|
|
||||||
|
this.ctx.font = 'bold 80px Arial';
|
||||||
|
this.ctx.fillStyle = '#ffffff';
|
||||||
|
this.ctx.textAlign = 'center';
|
||||||
|
this.ctx.textBaseline = 'middle';
|
||||||
|
this.ctx.fillText(timeString, centerX, centerY);
|
||||||
|
|
||||||
|
this.ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete() {
|
||||||
|
const centerX = this.canvas.width / 2;
|
||||||
|
const centerY = this.canvas.height / 2;
|
||||||
|
|
||||||
|
if (!this.gl || !this.program) {
|
||||||
|
// Fallback
|
||||||
|
this.ctx.fillStyle = '#000000';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.font = 'bold 80px Arial';
|
||||||
|
this.ctx.fillStyle = '#ffffff';
|
||||||
|
this.ctx.textAlign = 'center';
|
||||||
|
this.ctx.textBaseline = 'middle';
|
||||||
|
this.ctx.fillText("TIME'S UP", centerX, centerY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let completionFrame = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
completionFrame++;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const restingProgress = Math.min(elapsed / this.completionDuration, 1.0);
|
||||||
|
|
||||||
|
// Clear to black
|
||||||
|
this.ctx.fillStyle = '#000000';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Continue shader animation with gentle fade
|
||||||
|
this.gl.clearColor(0.0, 0.0, 0.0, 1.0);
|
||||||
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||||
|
this.gl.useProgram(this.program);
|
||||||
|
|
||||||
|
const progress = 1.0 + (restingProgress * 0.5);
|
||||||
|
// Use elapsed time for consistent animation speed
|
||||||
|
this.gl.uniform1f(this.uniforms.time, elapsed * 0.001);
|
||||||
|
this.gl.uniform1f(this.uniforms.progress, progress);
|
||||||
|
this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height);
|
||||||
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
// Draw shader result with much slower fade
|
||||||
|
const shaderFade = Math.max(0, 1.0 - restingProgress);
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.globalAlpha = shaderFade;
|
||||||
|
this.ctx.drawImage(this.glCanvas, 0, 0);
|
||||||
|
this.ctx.restore();
|
||||||
|
|
||||||
|
// Calm completion indicator
|
||||||
|
const fadeIn = restingProgress;
|
||||||
|
|
||||||
|
// Single breathing ring - tied to resting progress
|
||||||
|
const breathPhase = restingProgress * Math.PI * 4; // 2 full breaths during completion
|
||||||
|
const breathe = Math.sin(breathPhase) * 0.03 + 0.97;
|
||||||
|
this.ctx.strokeStyle = `rgba(255, 100, 100, ${fadeIn * 0.5})`;
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(centerX, centerY, 100 * breathe, 0, Math.PI * 2);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
// Soft center glow
|
||||||
|
const gradient = this.ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, 80);
|
||||||
|
gradient.addColorStop(0, `rgba(255, 150, 150, ${fadeIn * 0.2})`);
|
||||||
|
gradient.addColorStop(1, 'rgba(255, 100, 100, 0)');
|
||||||
|
this.ctx.fillStyle = gradient;
|
||||||
|
this.ctx.fillRect(centerX - 150, centerY - 150, 300, 300);
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export class TextTheme {
|
||||||
|
constructor(canvas, ctx) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.completionDuration = 2400; // milliseconds to reach resting state
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompletionDuration(duration) {
|
||||||
|
this.completionDuration = duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(progress, remainingTime) {
|
||||||
|
const minutes = Math.floor(remainingTime / 60000);
|
||||||
|
const seconds = Math.floor((remainingTime % 60000) / 1000);
|
||||||
|
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
this.ctx.fillStyle = '#1a1a1a';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
const fontSize = Math.min(this.canvas.width / 4, this.canvas.height / 3);
|
||||||
|
this.ctx.font = `${fontSize}px Arial`;
|
||||||
|
this.ctx.fillStyle = this.getColor(progress);
|
||||||
|
this.ctx.textAlign = 'center';
|
||||||
|
this.ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
this.ctx.fillText(timeString, this.canvas.width / 2, this.canvas.height / 2);
|
||||||
|
|
||||||
|
const progressBarHeight = 20;
|
||||||
|
const progressBarY = this.canvas.height - 100;
|
||||||
|
const progressBarWidth = this.canvas.width * 0.8;
|
||||||
|
const progressBarX = (this.canvas.width - progressBarWidth) / 2;
|
||||||
|
|
||||||
|
this.ctx.fillStyle = '#333';
|
||||||
|
this.ctx.fillRect(progressBarX, progressBarY, progressBarWidth, progressBarHeight);
|
||||||
|
|
||||||
|
this.ctx.fillStyle = this.getColor(progress);
|
||||||
|
this.ctx.fillRect(progressBarX, progressBarY, progressBarWidth * (1 - progress), progressBarHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
getColor(progress) {
|
||||||
|
if (progress < 0.5) {
|
||||||
|
return '#4CAF50';
|
||||||
|
} else if (progress < 0.8) {
|
||||||
|
return '#FFC107';
|
||||||
|
} else {
|
||||||
|
return '#F44336';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete() {
|
||||||
|
let frame = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
frame++;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const restingProgress = Math.min(elapsed / this.completionDuration, 1.0);
|
||||||
|
|
||||||
|
this.ctx.fillStyle = '#1a1a1a';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
const centerX = this.canvas.width / 2;
|
||||||
|
const centerY = this.canvas.height / 2;
|
||||||
|
const radius = Math.min(this.canvas.width, this.canvas.height) * 0.25;
|
||||||
|
|
||||||
|
// Breathing completes 2 full cycles during completion duration
|
||||||
|
const breathPhase = (elapsed / this.completionDuration) * Math.PI * 4;
|
||||||
|
// Amplitude decreases as we reach resting state
|
||||||
|
const breathAmplitude = 0.05 * (1 - restingProgress * 0.5);
|
||||||
|
const breathe = Math.sin(breathPhase) * breathAmplitude + 0.95;
|
||||||
|
|
||||||
|
// Soft glow
|
||||||
|
const gradient = this.ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius * 1.5);
|
||||||
|
gradient.addColorStop(0, 'rgba(244, 67, 54, 0.3)');
|
||||||
|
gradient.addColorStop(1, 'rgba(244, 67, 54, 0)');
|
||||||
|
this.ctx.fillStyle = gradient;
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Main circle
|
||||||
|
this.ctx.fillStyle = 'rgba(244, 67, 54, 0.8)';
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(centerX, centerY, radius * breathe, 0, Math.PI * 2);
|
||||||
|
this.ctx.fill();
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const CopyPlugin = require('copy-webpack-plugin');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mode: 'development',
|
||||||
|
entry: './src/index.js',
|
||||||
|
output: {
|
||||||
|
filename: 'bundle.js',
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
clean: true,
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
static: './dist',
|
||||||
|
hot: true,
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.m?js$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: {
|
||||||
|
loader: 'babel-loader',
|
||||||
|
options: {
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', {
|
||||||
|
targets: {
|
||||||
|
esmodules: true
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: './src/index.html',
|
||||||
|
title: 'Timer App',
|
||||||
|
}),
|
||||||
|
new CopyPlugin({
|
||||||
|
patterns: [
|
||||||
|
{ from: 'src/styles.css', to: 'styles.css' }
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.js', '.mjs']
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user