chronocanvas / script.js
iamsomon's picture
You are an experienced frontend engineer and creative technologist. Your task is to design and implement a fully client-side web application that generates a dynamic calendar wallpaper image. The project must be suitable for hosting on GitHub Pages (static hosting only, no backend, no server-side code). The application must rely исключительно on HTML, CSS, and JavaScript running in the browser.
5ba9e29 verified
/**
* ChronoCanvas — Calendar Wallpaper Generator
* Shared utilities and index.html logic
*/
// ==========================================
// Device Detection & Presets
// ==========================================
const DEVICE_PRESETS = {
// iPhone
iphone15pm: { width: 1290, height: 2796, platform: 'ios', name: 'iPhone 15 Pro Max' },
iphone15p: { width: 1179, height: 2556, platform: 'ios', name: 'iPhone 15 Pro' },
iphone15: { width: 1179, height: 2556, platform: 'ios', name: 'iPhone 15' },
iphone14pm: { width: 1290, height: 2796, platform: 'ios', name: 'iPhone 14 Pro Max' },
iphone14p: { width: 1179, height: 2556, platform: 'ios', name: 'iPhone 14 Pro' },
iphone13: { width: 1170, height: 2532, platform: 'ios', name: 'iPhone 13/14' },
iphonese: { width: 750, height: 1334, platform: 'ios', name: 'iPhone SE' },
// Android
pixel8p: { width: 1344, height: 2992, platform: 'android', name: 'Pixel 8 Pro' },
pixel8: { width: 1080, height: 2400, platform: 'android', name: 'Pixel 8' },
s24u: { width: 1440, height: 3120, platform: 'android', name: 'Galaxy S24 Ultra' },
s24: { width: 1080, height: 2340, platform: 'android', name: 'Galaxy S24' },
// Desktop
macbook14: { width: 3024, height: 1964, platform: 'desktop', name: 'MacBook 14"' },
macbook16: { width: 3456, height: 2234, platform: 'desktop', name: 'MacBook 16"' },
imac: { width: 5120, height: 2880, platform: 'desktop', name: 'iMac 5K' },
fhd: { width: 1920, height: 1080, platform: 'desktop', name: 'Full HD' },
'4k': { width: 3840, height: 2160, platform: 'desktop', name: '4K UHD' }
};
const ACCENT_COLORS = {
primary: { 500: '#0ea5e9', 600: '#0284c7' },
secondary: { 500: '#d946ef', 600: '#c026d3' },
emerald: { 500: '#10b981', 600: '#059669' },
amber: { 500: '#f59e0b', 600: '#d97706' },
rose: { 500: '#f43f5e', 600: '#e11d48' },
violet: { 500: '#8b5cf6', 600: '#7c3aed' }
};
// ==========================================
// Utility Functions
// ==========================================
function detectPlatform() {
const ua = navigator.userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(ua)) return 'ios';
if (/android/.test(ua)) return 'android';
return 'desktop';
}
function detectResolution() {
const dpr = window.devicePixelRatio || 1;
// Use screen dimensions for wallpaper generation
// For mobile, we want the native resolution
const width = Math.round(window.screen.width * dpr);
const height = Math.round(window.screen.height * dpr);
return { width, height, dpr };
}
function findBestPreset(width, height, platform) {
// Try to find matching preset
for (const [key, preset] of Object.entries(DEVICE_PRESETS)) {
if (preset.platform === platform &&
Math.abs(preset.width - width) < 100 &&
Math.abs(preset.height - height) < 100) {
return { key, ...preset };
}
}
return null;
}
function getMonthData(year, month) {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startDayOfWeek = firstDay.getDay(); // 0 = Sunday
const weeks = [];
let currentWeek = [];
// Fill leading empty days
for (let i = 0; i < startDayOfWeek; i++) {
currentWeek.push(null);
}
// Fill days
for (let day = 1; day <= daysInMonth; day++) {
currentWeek.push(day);
if (currentWeek.length === 7) {
weeks.push(currentWeek);
currentWeek = [];
}
}
// Fill trailing empty days
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push(null);
}
weeks.push(currentWeek);
}
return { weeks, daysInMonth, startDayOfWeek };
}
// ==========================================
// Canvas Rendering Engine
// ==========================================
function renderCalendarWallpaper(canvas, options) {
const {
width,
height,
theme = 'dark',
accent = 'primary',
showSeconds = false,
showWeekday = true,
showYear = true,
date = new Date()
} = options;
const ctx = canvas.getContext('2d');
const colors = ACCENT_COLORS[accent] || ACCENT_COLORS.primary;
// Set canvas size
canvas.width = width;
canvas.height = height;
// Theme colors
const isDark = theme === 'dark';
const bgColor = isDark ? '#0f172a' : '#ffffff';
const textColor = isDark ? '#f8fafc' : '#1e293b';
const mutedColor = isDark ? '#94a3b8' : '#64748b';
const surfaceColor = isDark ? 'rgba(30, 41, 59, 0.6)' : 'rgba(241, 245, 249, 0.8)';
const gridColor = isDark ? 'rgba(148, 163, 184, 0.1)' : 'rgba(100, 116, 139, 0.1)';
// Background
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
// Subtle gradient overlay
const gradient = ctx.createLinearGradient(0, 0, width, height);
if (isDark) {
gradient.addColorStop(0, 'rgba(14, 165, 233, 0.08)');
gradient.addColorStop(0.5, 'transparent');
gradient.addColorStop(1, 'rgba(217, 70, 239, 0.05)');
} else {
gradient.addColorStop(0, 'rgba(14, 165, 233, 0.05)');
gradient.addColorStop(0.5, 'transparent');
gradient.addColorStop(1, 'rgba(217, 70, 239, 0.03)');
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// Calculate layout based on aspect ratio
const isPortrait = height > width;
const padding = Math.min(width, height) * 0.08;
const availableWidth = width - padding * 2;
const availableHeight = height - padding * 2;
// Scale factor based on resolution
const baseScale = Math.min(width, height) / 1000;
const scale = Math.max(baseScale, 1);
let currentY = padding;
// Date display
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const weekdayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const currentMonth = date.getMonth();
const currentYear = date.getFullYear();
const currentDay = date.getDate();
const currentWeekday = date.getDay();
// Header section
if (showWeekday) {
ctx.font = `300 ${24 * scale}px system-ui, -apple-system, sans-serif`;
ctx.fillStyle = colors[500];
ctx.textAlign = 'left';
ctx.fillText(weekdayNames[currentWeekday], padding, currentY + 24 * scale);
currentY += 40 * scale;
}
// Large date
ctx.font = `200 ${120 * scale}px system-ui, -apple-system, sans-serif`;
ctx.fillStyle = textColor;
ctx.fillText(String(currentDay).padStart(2, '0'), padding, currentY + 100 * scale);
currentY += 130 * scale;
// Month and year
ctx.font = `400 ${28 * scale}px system-ui, -apple-system, sans-serif`;
ctx.fillStyle = mutedColor;
const yearText = showYear ? ` ${currentYear}` : '';
ctx.fillText(`${monthNames[currentMonth]}${yearText}`, padding, currentY);
currentY += 60 * scale;
// Time (optional)
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
const ampm = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
const timeString = `${displayHours}:${String(minutes).padStart(2, '0')}${showSeconds ? ':' + String(seconds).padStart(2, '0') : ''} ${ampm}`;
ctx.font = `300 ${20 * scale}px system-ui, -apple-system, sans-serif`;
ctx.fillStyle = mutedColor;
ctx.fillText(timeString, padding, currentY);
currentY += 50 * scale;
// Calendar grid
const { weeks } = getMonthData(currentYear, currentMonth);
const dayNames = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
// Calendar container
const calendarTop = currentY;
const cellSize = Math.min(availableWidth / 7, (availableHeight - currentY + padding) / (weeks.length + 2));
const calendarWidth = cellSize * 7;
const startX = (width - calendarWidth) / 2;
// Day headers
ctx.font = `500 ${14 * scale}px system-ui, -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
dayNames.forEach((day, i) => {
const x = startX + i * cellSize + cellSize / 2;
const y = calendarTop + cellSize / 2;
ctx.fillStyle = i === 0 || i === 6 ? colors[500] : mutedColor;
ctx.fillText(day, x, y);
});
// Calendar days
const today = new Date();
const isCurrentMonth = today.getMonth() === currentMonth && today.getFullYear() === currentYear;
weeks.forEach((week, weekIndex) => {
const rowY = calendarTop + (weekIndex + 1.5) * cellSize;
week.forEach((day, dayIndex) => {
if (!day) return;
const x = startX + dayIndex * cellSize + cellSize / 2;
const y = rowY;
const isToday = isCurrentMonth && day === currentDay;
// Today highlight
if (isToday) {
ctx.beginPath();
ctx.arc(x, y, cellSize * 0.35, 0, Math.PI * 2);
ctx.fillStyle = colors[500];
ctx.fill();
// Glow effect
ctx.beginPath();
ctx.arc(x, y, cellSize * 0.45, 0, Math.PI * 2);
ctx.fillStyle = colors[500] + '20';
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = `600 ${16 * scale}px system-ui, -apple-system, sans-serif`;
} else {
ctx.fillStyle = textColor;
ctx.font = `400 ${16 * scale}px system-ui, -apple-system, sans-serif`;
}
ctx.fillText(String(day), x, y);
});
});
// Decorative elements
// Subtle grid lines
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
for (let i = 0; i <= 7; i++) {
const x = startX + i * cellSize;
ctx.beginPath();
ctx.moveTo(x, calendarTop + cellSize);
ctx.lineTo(x, calendarTop + (weeks.length + 1) * cellSize);
ctx.stroke();
}
for (let i = 0; i <= weeks.length + 1; i++) {
const y = calendarTop + i * cellSize;
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(startX + calendarWidth, y);
ctx.stroke();
}
// Bottom branding (subtle)
ctx.font = `300 ${12 * scale}px system-ui, -apple-system, sans-serif`;
ctx.fillStyle = isDark ? 'rgba(148, 163, 184, 0.3)' : 'rgba(100, 116, 139, 0.3)';
ctx.textAlign = 'center';
ctx.fillText('ChronoCanvas', width / 2, height - padding / 2);
}
// ==========================================
// URL Parameter Handling
// ==========================================
function buildRenderUrl(params) {
const url = new URL('render.html', window.location.href);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
});
return url.toString();
}
function parseRenderUrl(urlString) {
const url = new URL(urlString);
const params = {};
url.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
}
// ==========================================
// Index Page Logic
// ==========================================
function initIndexPage() {
// DOM Elements
const platformInput = document.getElementById('platform');
const widthInput = document.getElementById('width');
const heightInput = document.getElementById('height');
const themeInput = document.getElementById('theme');
const accentInput = document.getElementById('accent');
const presetSelect = document.getElementById('preset');
const deviceLabelInput = document.getElementById('device-label');
const showSecondsInput = document.getElementById('show-seconds');
const showWeekdayInput = document.getElementById('show-weekday');
const showYearInput = document.getElementById('show-year');
const previewCanvas = document.getElementById('preview-canvas');
const previewLoading = document.getElementById('preview-loading');
const generatedUrlInput = document.getElementById('generated-url');
const generateBtn = document.getElementById('generate-btn');
const copyBtn = document.getElementById('copy-btn');
const openRenderBtn = document.getElementById('open-render');
const detectedDeviceEl = document.getElementById('detected-device');
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toast-message');
// Platform buttons
const platformBtns = document.querySelectorAll('.platform-btn');
const themeBtns = document.querySelectorAll('.theme-btn');
const accentBtns = document.querySelectorAll('.accent-btn');
// State
let currentParams = {};
// Auto-detect device
function autoDetect() {
const platform = detectPlatform();
const { width, height, dpr } = detectResolution();
// Find best preset
const preset = findBestPreset(width, height, platform);
// Update UI
platformInput.value = platform;
updatePlatformButtons();
if (preset) {
widthInput.value = preset.width;
heightInput.value = preset.height;
presetSelect.value = preset.key;
deviceLabelInput.value = preset.name;
detectedDeviceEl.textContent = `${preset.name} (${width}×${height} @ ${dpr}x)`;
} else {
// Use detected resolution directly
widthInput.value = width;
heightInput.value = height;
presetSelect.value = '';
deviceLabelInput.value = '';
detectedDeviceEl.textContent = `${platform} (${width}×${height} @ ${dpr}x)`;
}
updatePreview();
}
function updatePlatformButtons() {
const value = platformInput.value;
platformBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.value === value);
});
}
function updateThemeButtons() {
const value = themeInput.value;
themeBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.value === value);
});
}
function updateAccentButtons() {
const value = accentInput.value;
accentBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.value === value);
});
}
function getCurrentParams() {
return {
width: parseInt(widthInput.value) || 1179,
height: parseInt(heightInput.value) || 2556,
platform: platformInput.value,
theme: themeInput.value,
accent: accentInput.value,
label: deviceLabelInput.value,
seconds: showSecondsInput.checked ? '1' : '0',
weekday: showWeekdayInput.checked ? '1' : '0',
year: showYearInput.checked ? '1' : '0'
};
}
function updatePreview() {
const params = getCurrentParams();
currentParams = params;
// Generate preview at smaller size
const previewWidth = 400;
const aspectRatio = params.height / params.width;
const previewHeight = Math.round(previewWidth * aspectRatio);
previewCanvas.width = previewWidth;
previewCanvas.height = previewHeight;
renderCalendarWallpaper(previewCanvas, {
width: previewWidth,
height: previewHeight,
theme: params.theme,
accent: params.accent,
showSeconds: params.seconds === '1',
showWeekday: params.weekday === '1',
showYear: params.year === '1',
date: new Date()
});
previewLoading.classList.add('hidden');
// Update URL
const renderUrl = buildRenderUrl(params);
generatedUrlInput.value = renderUrl;
openRenderBtn.href = renderUrl;
}
function showToast(message) {
toastMessage.textContent = message;
toast.classList.remove('translate-y-20', 'opacity-0');
setTimeout(() => {
toast.classList.add('translate-y-20', 'opacity-0');
}, 2000);
}
// Event Listeners
platformBtns.forEach(btn => {
btn.addEventListener('click', () => {
platformInput.value = btn.dataset.value;
updatePlatformButtons();
updatePreview();
});
});
themeBtns.forEach(btn => {
btn.addEventListener('click', () => {
themeInput.value = btn.dataset.value;
updateThemeButtons();
updatePreview();
});
});
accentBtns.forEach(btn => {
btn.addEventListener('click', () => {
accentInput.value = btn.dataset.value;
updateAccentButtons();
updatePreview();
});
});
presetSelect.addEventListener('change', () => {
const key = presetSelect.value;
if (key && DEVICE_PRESETS[key]) {
const preset = DEVICE_PRESETS[key];
widthInput.value = preset.width;
heightInput.value = preset.height;
platformInput.value = preset.platform;
deviceLabelInput.value = preset.name;
updatePlatformButtons();
}
updatePreview();
});
[widthInput, heightInput, deviceLabelInput].forEach(el => {
el.addEventListener('input', updatePreview);
});
[showSecondsInput, showWeekdayInput, showYearInput].forEach(el => {
el.addEventListener('change', updatePreview);
});
generateBtn.addEventListener('click', updatePreview);
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(generatedUrlInput.value);
showToast('Copied to clipboard!');
} catch (err) {
// Fallback
generatedUrlInput.select();
document.execCommand('copy');
showToast('Copied to clipboard!');
}
});
// Initialize
updateThemeButtons();
updateAccentButtons();
autoDetect();
}
// ==========================================
// Render Page Logic
// ==========================================
function initRenderPage() {
const canvas = document.getElementById('wallpaper-canvas');
const loading = document.getElementById('loading');
// Parse URL parameters
const urlParams = new URLSearchParams(window.location.search);
// Get parameters with fallbacks
const getParam = (name, defaultValue, parser = String) => {
const value = urlParams.get(name);
return value !== null ? parser(value) : defaultValue;
};
// Auto-detect if needed
const { width: detectedWidth, height: detectedHeight } = detectResolution();
const detectedPlatform = detectPlatform();
const params = {
width: getParam('width', detectedWidth, parseInt),
height: getParam('height', detectedHeight, parseInt),
theme: getParam('theme', 'dark'),
accent: getParam('accent', 'primary'),
showSeconds: getParam('seconds', '0') === '1',
showWeekday: getParam('weekday', '1') === '1',
showYear: getParam('year', '1') === '1'
};
// Validate dimensions
params.width = Math.max(320, Math.min(8192, params.width));
params.height = Math.max(320, Math.min(8192, params.height));
// Render
function render() {
renderCalendarWallpaper(canvas, {
width: params.width,
height: params.height,
theme: params.theme,
accent: params.accent,
showSeconds: params.showSeconds,
showWeekday: params.showWeekday,
showYear: params.showYear,
date: new Date()
});
// Hide loading
loading.classList.add('hidden');
// For automation: update title with timestamp
document.title = `ChronoCanvas — ${new Date().toLocaleString()}`;
}
// Initial render
render();
// Auto-refresh for clock if showing seconds
if (params.showSeconds) {
setInterval(render, 1000);
} else {
// Refresh every minute to update time
setInterval(render, 60000);
}
}
// ==========================================
// Initialize
// ==========================================
document.addEventListener('DOMContentLoaded', () => {
// Detect which page we're on
const isRenderPage = document.getElementById('wallpaper-canvas') !== null;
if (isRenderPage) {
initRenderPage();
} else {
initIndexPage();
}
});