Incorporate BCT branding: Add Logo, favicon, and update styles

This commit is contained in:
2025-12-01 00:11:43 +11:00
parent 35b7528d0a
commit 4bfa62dbf3
4 changed files with 1722 additions and 0 deletions

BIN
Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

205
index.html Normal file
View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beyond Cloud Technology - RAID Calculator</title>
<meta name="description"
content="Professional RAID calculator by Beyond Cloud Technology supporting standard RAID, Synology SHR, ZFS, and Unraid configurations with accurate usable storage calculations.">
<link rel="icon" type="image/png" href="Logo.png">
<link rel="stylesheet" href="styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<header class="header">
<div class="header-content">
<h1 class="title">
<img src="Logo.png" alt="BCT Logo" class="title-icon">
Beyond Cloud Technology - RAID Calculator
</h1>
<p class="subtitle">Professional storage capacity planning for RAID, Synology SHR, ZFS & Unraid
configurations
</p>
<a href="https://www.youtube.com/@beyondcloudtechnology" target="_blank" rel="noopener noreferrer"
class="youtube-link">
<svg class="youtube-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
Watch on YouTube @beyondcloudtechnology
</a>
</div>
</header>
<main class="main-content">
<div class="calculator-grid">
<!-- Configuration Panel -->
<section class="config-panel card">
<h2 class="section-title">Configuration</h2>
<div class="form-group">
<label for="raid-type" class="label">RAID Type</label>
<select id="raid-type" class="select">
<optgroup label="Standard RAID">
<option value="raid0">RAID 0 - Striping (No Redundancy)</option>
<option value="raid1">RAID 1 - Mirroring</option>
<option value="raid5" selected>RAID 5 - Single Parity</option>
<option value="raid6">RAID 6 - Double Parity</option>
<option value="raid10">RAID 10 - Mirrored Stripes</option>
</optgroup>
<optgroup label="Synology RAID">
<option value="shr">SHR - Synology Hybrid RAID</option>
<option value="shr2">SHR-2 - Synology Hybrid RAID (2-disk fault tolerance)</option>
</optgroup>
<optgroup label="ZFS RAID">
<option value="zfs-stripe">ZFS Stripe (No Redundancy)</option>
<option value="zfs-mirror">ZFS Mirror</option>
<option value="raidz1">RAIDZ1 - Single Parity</option>
<option value="raidz2">RAIDZ2 - Double Parity</option>
<option value="raidz3">RAIDZ3 - Triple Parity</option>
</optgroup>
<optgroup label="Unraid">
<option value="unraid-1">Unraid - 1 Parity Drive</option>
<option value="unraid-2">Unraid - 2 Parity Drives</option>
</optgroup>
</select>
</div>
<div class="form-group">
<label for="num-drives" class="label">Number of Drives</label>
<div class="input-with-display">
<input type="range" id="num-drives" class="slider" min="2" max="24" value="4">
<span id="num-drives-display" class="value-display">4</span>
</div>
</div>
<div class="form-group">
<label for="drive-size" class="label">Drive Size (TB)</label>
<select id="drive-size" class="select">
<option value="1">1 TB</option>
<option value="2">2 TB</option>
<option value="3">3 TB</option>
<option value="4">4 TB</option>
<option value="6">6 TB</option>
<option value="8">8 TB</option>
<option value="10">10 TB</option>
<option value="12" selected>12 TB</option>
<option value="14">14 TB</option>
<option value="16">16 TB</option>
<option value="18">18 TB</option>
<option value="20">20 TB</option>
<option value="22">22 TB</option>
<option value="24">24 TB</option>
</select>
</div>
<div id="zfs-vdev-config" class="form-group" style="display: none;">
<label for="num-vdevs" class="label">Number of vdevs</label>
<div class="input-with-display">
<input type="range" id="num-vdevs" class="slider" min="1" max="8" value="2">
<span id="num-vdevs-display" class="value-display">2</span>
</div>
<p class="helper-text">Each vdev will use the selected RAID type (mirror/RAIDZ)</p>
</div>
<div id="drives-per-vdev-config" class="form-group" style="display: none;">
<label for="drives-per-vdev" class="label">Drives per vdev</label>
<div class="input-with-display">
<input type="range" id="drives-per-vdev" class="slider" min="2" max="12" value="3">
<span id="drives-per-vdev-display" class="value-display">3</span>
</div>
<p class="helper-text" id="total-drives-text">Total drives: 6</p>
</div>
<div class="form-group">
<label class="label">Mixed Drive Sizes</label>
<div class="toggle-container">
<input type="checkbox" id="mixed-drives" class="toggle-input">
<label for="mixed-drives" class="toggle-label">
<span class="toggle-slider"></span>
</label>
<span class="toggle-text">Enable custom drive configuration</span>
</div>
</div>
<div id="custom-drives-container" class="custom-drives-container" style="display: none;">
<div id="drive-inputs" class="drive-inputs"></div>
</div>
<button id="calculate-btn" class="btn-primary">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Calculate Storage
</button>
</section>
<!-- Results Panel -->
<section class="results-panel card">
<h2 class="section-title">Results</h2>
<div id="results-content" class="results-content">
<div class="empty-state">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p>Configure your RAID array and click "Calculate Storage" to see results</p>
</div>
</div>
</section>
</div>
<!-- Information Cards -->
<div class="info-grid">
<div class="info-card card">
<div class="info-icon info-icon-blue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4m0-4h.01" />
</svg>
</div>
<h3 class="info-title">Accurate Calculations</h3>
<p class="info-text">Accounts for binary vs decimal differences - 12TB drives show actual ~10.9TB
usable capacity</p>
</div>
<div class="info-card card">
<div class="info-icon info-icon-green">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 class="info-title">Fault Tolerance</h3>
<p class="info-text">See exactly how many drives can fail while maintaining data integrity</p>
</div>
<div class="info-card card">
<div class="info-icon info-icon-purple">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="info-title">Performance Insights</h3>
<p class="info-text">Understand read/write performance characteristics of each RAID configuration
</p>
</div>
</div>
</main>
<footer class="footer">
<p>&copy; 2025 Beyond Cloud Technology. Professional storage planning tool.</p>
</footer>
</div>
<script src="script.js"></script>
</body>
</html>

718
script.js Normal file
View File

@@ -0,0 +1,718 @@
// ===================================
// RAID Calculator - Core Logic
// ===================================
// Storage conversion constants
const TB_TO_BYTES_DECIMAL = 1000000000000; // 1 TB = 1,000,000,000,000 bytes (decimal)
const TB_TO_BYTES_BINARY = 1099511627776; // 1 TiB = 1,099,511,627,776 bytes (binary)
// RAID Configuration Metadata
const RAID_CONFIGS = {
raid0: {
name: 'RAID 0',
description: 'Striping - Maximum performance, no redundancy',
minDrives: 2,
faultTolerance: 0,
readPerformance: 'Excellent',
writePerformance: 'Excellent',
efficiency: 1.0
},
raid1: {
name: 'RAID 1',
description: 'Mirroring - 100% redundancy',
minDrives: 2,
faultTolerance: 1,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: 0.5
},
raid5: {
name: 'RAID 5',
description: 'Single parity - Good balance of performance and redundancy',
minDrives: 3,
faultTolerance: 1,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: null // Calculated: (n-1)/n
},
raid6: {
name: 'RAID 6',
description: 'Double parity - Enhanced fault tolerance',
minDrives: 4,
faultTolerance: 2,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: null // Calculated: (n-2)/n
},
raid10: {
name: 'RAID 10',
description: 'Mirrored stripes - Best performance with redundancy',
minDrives: 4,
faultTolerance: 1,
readPerformance: 'Excellent',
writePerformance: 'Good',
efficiency: 0.5
},
shr: {
name: 'Synology Hybrid RAID',
description: 'Flexible RAID with single disk fault tolerance',
minDrives: 2,
faultTolerance: 1,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: null // Calculated based on drive configuration
},
shr2: {
name: 'Synology Hybrid RAID 2',
description: 'Flexible RAID with dual disk fault tolerance',
minDrives: 4,
faultTolerance: 2,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: null // Calculated based on drive configuration
},
'zfs-stripe': {
name: 'ZFS Stripe',
description: 'No redundancy - Maximum capacity',
minDrives: 1,
faultTolerance: 0,
readPerformance: 'Excellent',
writePerformance: 'Excellent',
efficiency: 1.0
},
'zfs-mirror': {
name: 'ZFS Mirror',
description: 'Mirrored vdevs - High redundancy',
minDrives: 2,
faultTolerance: 1,
readPerformance: 'Excellent',
writePerformance: 'Good',
efficiency: 0.5
},
raidz1: {
name: 'RAIDZ1',
description: 'Single parity - ZFS equivalent to RAID 5',
minDrives: 3,
faultTolerance: 1,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: null // Calculated: (n-1)/n
},
raidz2: {
name: 'RAIDZ2',
description: 'Double parity - ZFS equivalent to RAID 6',
minDrives: 4,
faultTolerance: 2,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: null // Calculated: (n-2)/n
},
raidz3: {
name: 'RAIDZ3',
description: 'Triple parity - Maximum fault tolerance',
minDrives: 5,
faultTolerance: 3,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: null // Calculated: (n-3)/n
},
'unraid-1': {
name: 'Unraid (1 Parity)',
description: 'Flexible array with single parity drive - Individual drive access',
minDrives: 2,
faultTolerance: 1,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: null // Calculated: (n-1)/n
},
'unraid-2': {
name: 'Unraid (2 Parity)',
description: 'Flexible array with dual parity drives - Individual drive access',
minDrives: 3,
faultTolerance: 2,
readPerformance: 'Good',
writePerformance: 'Moderate',
efficiency: null // Calculated: (n-2)/n
}
};
// ===================================
// Storage Calculation Functions
// ===================================
/**
* Convert TB (decimal) to actual usable TiB (binary)
* Example: 12 TB = 10.91 TiB usable
*/
function convertTBtoTiB(tb) {
const bytes = tb * TB_TO_BYTES_DECIMAL;
const tib = bytes / TB_TO_BYTES_BINARY;
return tib;
}
/**
* Calculate usable storage for RAID configuration
*/
function calculateRAIDStorage(raidType, drives) {
const numDrives = drives.length;
const config = RAID_CONFIGS[raidType];
// Validate minimum drives
if (numDrives < config.minDrives) {
throw new Error(`${config.name} requires at least ${config.minDrives} drives`);
}
// Convert all drives to TiB (binary)
const drivesInTiB = drives.map(tb => convertTBtoTiB(tb));
let usableCapacity = 0;
let rawCapacity = drivesInTiB.reduce((sum, size) => sum + size, 0);
switch (raidType) {
case 'raid0':
case 'zfs-stripe':
// Sum of all drives
usableCapacity = rawCapacity;
break;
case 'raid1':
case 'zfs-mirror':
// Smallest drive capacity (all drives mirror the smallest)
usableCapacity = Math.min(...drivesInTiB);
break;
case 'raid5':
case 'raidz1':
// (n-1) * smallest drive
usableCapacity = (numDrives - 1) * Math.min(...drivesInTiB);
break;
case 'raid6':
case 'raidz2':
// (n-2) * smallest drive
usableCapacity = (numDrives - 2) * Math.min(...drivesInTiB);
break;
case 'raidz3':
// (n-3) * smallest drive
usableCapacity = (numDrives - 3) * Math.min(...drivesInTiB);
break;
case 'raid10':
// n/2 * smallest drive (assumes even number of drives)
if (numDrives % 2 !== 0) {
throw new Error('RAID 10 requires an even number of drives');
}
usableCapacity = (numDrives / 2) * Math.min(...drivesInTiB);
break;
case 'shr':
// Synology Hybrid RAID calculation
usableCapacity = calculateSHR(drivesInTiB, 1);
break;
case 'shr2':
// Synology Hybrid RAID 2 calculation
usableCapacity = calculateSHR(drivesInTiB, 2);
break;
case 'unraid-1':
// Unraid with 1 parity drive - sum of all drives minus largest (parity)
usableCapacity = calculateUnraid(drivesInTiB, 1);
break;
case 'unraid-2':
// Unraid with 2 parity drives - sum of all drives minus 2 largest (parity)
usableCapacity = calculateUnraid(drivesInTiB, 2);
break;
}
return {
usableCapacity,
rawCapacity,
efficiency: usableCapacity / rawCapacity,
wastedSpace: rawCapacity - usableCapacity
};
}
/**
* Calculate Synology Hybrid RAID capacity
* SHR optimizes storage by using different RAID levels based on drive sizes
*/
function calculateSHR(drivesInTiB, parityDisks) {
const sorted = [...drivesInTiB].sort((a, b) => a - b);
const numDrives = sorted.length;
if (numDrives < parityDisks + 1) {
throw new Error(`SHR-${parityDisks} requires at least ${parityDisks + 1} drives`);
}
let usableCapacity = 0;
// SHR algorithm: Build up capacity by size tiers
for (let i = 0; i < numDrives; i++) {
const currentSize = sorted[i];
const availableDrives = numDrives - i;
if (availableDrives > parityDisks) {
// Calculate contribution of this drive
if (i === 0) {
// First (smallest) drives contribute their full capacity minus parity
usableCapacity += currentSize * (availableDrives - parityDisks) / availableDrives;
} else {
// Larger drives contribute the difference from the previous tier
const difference = currentSize - sorted[i - 1];
usableCapacity += difference * (availableDrives - parityDisks) / availableDrives;
}
}
}
return usableCapacity;
}
/**
* Calculate Unraid capacity
* Unraid uses individual drives with parity - largest drive(s) become parity
* Usable capacity = sum of all drives minus the largest N drives (where N = parity count)
*/
function calculateUnraid(drivesInTiB, parityCount) {
const sorted = [...drivesInTiB].sort((a, b) => b - a); // Sort descending
const numDrives = sorted.length;
if (numDrives < parityCount + 1) {
throw new Error(`Unraid with ${parityCount} parity requires at least ${parityCount + 1} drives`);
}
// Remove the largest N drives (parity drives)
const dataDrives = sorted.slice(parityCount);
// Sum remaining drives for usable capacity
return dataDrives.reduce((sum, size) => sum + size, 0);
}
/**
* Calculate ZFS storage with multiple vdevs
* Each vdev uses the specified RAID type, and total capacity is the sum of all vdevs
*/
function calculateZFSVdevStorage(raidType, allDrives, numVdevs, drivesPerVdev) {
const totalDrives = allDrives.length;
if (totalDrives !== numVdevs * drivesPerVdev) {
throw new Error(`Total drives (${totalDrives}) must equal vdevs (${numVdevs}) × drives per vdev (${drivesPerVdev})`);
}
// Convert all drives to TiB
const drivesInTiB = allDrives.map(tb => convertTBtoTiB(tb));
let totalUsableCapacity = 0;
const rawCapacity = drivesInTiB.reduce((sum, size) => sum + size, 0);
// Calculate capacity for each vdev
for (let vdevIndex = 0; vdevIndex < numVdevs; vdevIndex++) {
const startIdx = vdevIndex * drivesPerVdev;
const endIdx = startIdx + drivesPerVdev;
const vdevDrives = drivesInTiB.slice(startIdx, endIdx);
const smallestInVdev = Math.min(...vdevDrives);
let vdevCapacity = 0;
switch (raidType) {
case 'zfs-mirror':
// Mirror: capacity of smallest drive in vdev
vdevCapacity = smallestInVdev;
break;
case 'raidz1':
// RAIDZ1: (n-1) * smallest drive
vdevCapacity = (drivesPerVdev - 1) * smallestInVdev;
break;
case 'raidz2':
// RAIDZ2: (n-2) * smallest drive
vdevCapacity = (drivesPerVdev - 2) * smallestInVdev;
break;
case 'raidz3':
// RAIDZ3: (n-3) * smallest drive
vdevCapacity = (drivesPerVdev - 3) * smallestInVdev;
break;
}
totalUsableCapacity += vdevCapacity;
}
return {
usableCapacity: totalUsableCapacity,
rawCapacity,
efficiency: totalUsableCapacity / rawCapacity,
wastedSpace: rawCapacity - totalUsableCapacity
};
}
/**
* Format capacity for display
*/
function formatCapacity(tib, showBoth = true) {
const tb = (tib * TB_TO_BYTES_BINARY) / TB_TO_BYTES_DECIMAL;
if (showBoth) {
return `${tib.toFixed(2)} TiB (${tb.toFixed(2)} TB)`;
}
return `${tib.toFixed(2)} TiB`;
}
/**
* Format percentage
*/
function formatPercentage(value) {
return `${(value * 100).toFixed(1)}%`;
}
// ===================================
// UI Interaction Functions
// ===================================
class RAIDCalculator {
constructor() {
this.initializeElements();
this.attachEventListeners();
this.updateDriveDisplay();
this.handleRaidTypeChange();
}
initializeElements() {
this.raidTypeSelect = document.getElementById('raid-type');
this.numDrivesSlider = document.getElementById('num-drives');
this.numDrivesDisplay = document.getElementById('num-drives-display');
this.driveSizeSelect = document.getElementById('drive-size');
this.mixedDrivesToggle = document.getElementById('mixed-drives');
this.customDrivesContainer = document.getElementById('custom-drives-container');
this.driveInputsContainer = document.getElementById('drive-inputs');
this.calculateBtn = document.getElementById('calculate-btn');
this.resultsContent = document.getElementById('results-content');
// ZFS vdev elements
this.zfsVdevConfig = document.getElementById('zfs-vdev-config');
this.numVdevsSlider = document.getElementById('num-vdevs');
this.numVdevsDisplay = document.getElementById('num-vdevs-display');
this.drivesPerVdevConfig = document.getElementById('drives-per-vdev-config');
this.drivesPerVdevSlider = document.getElementById('drives-per-vdev');
this.drivesPerVdevDisplay = document.getElementById('drives-per-vdev-display');
this.totalDrivesText = document.getElementById('total-drives-text');
}
attachEventListeners() {
this.numDrivesSlider.addEventListener('input', () => this.updateDriveDisplay());
this.mixedDrivesToggle.addEventListener('change', () => this.toggleMixedDrives());
this.calculateBtn.addEventListener('click', () => this.calculate());
this.raidTypeSelect.addEventListener('change', () => this.handleRaidTypeChange());
// ZFS vdev listeners
this.numVdevsSlider.addEventListener('input', () => this.updateVdevDisplay());
this.drivesPerVdevSlider.addEventListener('input', () => this.updateVdevDisplay());
}
handleRaidTypeChange() {
const raidType = this.raidTypeSelect.value;
const isZFSVdev = ['zfs-mirror', 'raidz1', 'raidz2', 'raidz3'].includes(raidType);
// Show/hide ZFS vdev configuration
this.zfsVdevConfig.style.display = isZFSVdev ? 'block' : 'none';
this.drivesPerVdevConfig.style.display = isZFSVdev ? 'block' : 'none';
// Show/hide regular drive count slider
const numDrivesGroup = this.numDrivesSlider.closest('.form-group');
numDrivesGroup.style.display = isZFSVdev ? 'none' : 'block';
if (isZFSVdev) {
this.updateVdevDisplay();
} else {
this.validateConfiguration();
}
}
updateVdevDisplay() {
const numVdevs = parseInt(this.numVdevsSlider.value);
const drivesPerVdev = parseInt(this.drivesPerVdevSlider.value);
const totalDrives = numVdevs * drivesPerVdev;
this.numVdevsDisplay.textContent = numVdevs;
this.drivesPerVdevDisplay.textContent = drivesPerVdev;
this.totalDrivesText.textContent = `Total drives: ${totalDrives}`;
// Update the main drive slider to match (for mixed drives feature)
this.numDrivesSlider.value = totalDrives;
this.numDrivesDisplay.textContent = totalDrives;
if (this.mixedDrivesToggle.checked) {
this.generateDriveInputs(totalDrives);
}
this.validateConfiguration();
}
updateDriveDisplay() {
const numDrives = parseInt(this.numDrivesSlider.value);
this.numDrivesDisplay.textContent = numDrives;
if (this.mixedDrivesToggle.checked) {
this.generateDriveInputs(numDrives);
}
this.validateConfiguration();
}
toggleMixedDrives() {
const isEnabled = this.mixedDrivesToggle.checked;
this.customDrivesContainer.style.display = isEnabled ? 'block' : 'none';
this.driveSizeSelect.disabled = isEnabled;
if (isEnabled) {
const raidType = this.raidTypeSelect.value;
const isZFSVdev = ['zfs-mirror', 'raidz1', 'raidz2', 'raidz3'].includes(raidType);
const numDrives = isZFSVdev ?
parseInt(this.numVdevsSlider.value) * parseInt(this.drivesPerVdevSlider.value) :
parseInt(this.numDrivesSlider.value);
this.generateDriveInputs(numDrives);
}
}
generateDriveInputs(numDrives) {
this.driveInputsContainer.innerHTML = '';
const driveSizes = [1, 2, 3, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24];
for (let i = 0; i < numDrives; i++) {
const row = document.createElement('div');
row.className = 'drive-input-row';
const label = document.createElement('span');
label.className = 'drive-label';
label.textContent = `Drive ${i + 1}:`;
const select = document.createElement('select');
select.className = 'drive-select';
select.id = `drive-${i}`;
driveSizes.forEach(size => {
const option = document.createElement('option');
option.value = size;
option.textContent = `${size} TB`;
if (size === 12) option.selected = true;
select.appendChild(option);
});
row.appendChild(label);
row.appendChild(select);
this.driveInputsContainer.appendChild(row);
}
}
validateConfiguration() {
const raidType = this.raidTypeSelect.value;
const isZFSVdev = ['zfs-mirror', 'raidz1', 'raidz2', 'raidz3'].includes(raidType);
const numDrives = isZFSVdev ?
parseInt(this.numVdevsSlider.value) * parseInt(this.drivesPerVdevSlider.value) :
parseInt(this.numDrivesSlider.value);
const config = RAID_CONFIGS[raidType];
if (numDrives < config.minDrives) {
this.calculateBtn.disabled = true;
this.calculateBtn.textContent = `Requires ${config.minDrives}+ drives`;
} else if (raidType === 'raid10' && numDrives % 2 !== 0) {
this.calculateBtn.disabled = true;
this.calculateBtn.textContent = 'RAID 10 requires even number of drives';
} else {
this.calculateBtn.disabled = false;
this.calculateBtn.innerHTML = `
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
Calculate Storage
`;
}
}
getDriveSizes() {
const raidType = this.raidTypeSelect.value;
const isZFSVdev = ['zfs-mirror', 'raidz1', 'raidz2', 'raidz3'].includes(raidType);
const numDrives = isZFSVdev ?
parseInt(this.numVdevsSlider.value) * parseInt(this.drivesPerVdevSlider.value) :
parseInt(this.numDrivesSlider.value);
if (this.mixedDrivesToggle.checked) {
const drives = [];
for (let i = 0; i < numDrives; i++) {
const select = document.getElementById(`drive-${i}`);
drives.push(parseFloat(select.value));
}
return drives;
} else {
const driveSize = parseFloat(this.driveSizeSelect.value);
return Array(numDrives).fill(driveSize);
}
}
calculate() {
try {
const raidType = this.raidTypeSelect.value;
const drives = this.getDriveSizes();
const config = RAID_CONFIGS[raidType];
// Check if this is a ZFS vdev configuration
const isZFSVdev = ['zfs-mirror', 'raidz1', 'raidz2', 'raidz3'].includes(raidType);
let result;
if (isZFSVdev) {
const numVdevs = parseInt(this.numVdevsSlider.value);
const drivesPerVdev = parseInt(this.drivesPerVdevSlider.value);
result = calculateZFSVdevStorage(raidType, drives, numVdevs, drivesPerVdev);
result.numVdevs = numVdevs;
result.drivesPerVdev = drivesPerVdev;
} else {
result = calculateRAIDStorage(raidType, drives);
}
this.displayResults(raidType, config, drives, result);
} catch (error) {
this.displayError(error.message);
}
}
displayResults(raidType, config, drives, result) {
const { usableCapacity, rawCapacity, efficiency, wastedSpace } = result;
const html = `
<div class="result-stats fade-in">
<div class="stat-card">
<div class="stat-label">Usable Capacity</div>
<div class="stat-value primary">${formatCapacity(usableCapacity)}</div>
<div class="stat-subtext">Actual storage available for data</div>
</div>
<div class="stat-card">
<div class="stat-label">Raw Capacity</div>
<div class="stat-value">${formatCapacity(rawCapacity)}</div>
<div class="stat-subtext">Total physical storage</div>
</div>
<div class="stat-card">
<div class="stat-label">Storage Efficiency</div>
<div class="stat-value">${formatPercentage(efficiency)}</div>
<div class="stat-subtext">${formatCapacity(wastedSpace)} used for redundancy</div>
</div>
<div class="capacity-visualization">
<div class="capacity-bar">
<div class="capacity-fill" style="width: ${efficiency * 100}%">
${formatPercentage(efficiency)} Usable
</div>
</div>
<div class="capacity-legend">
<span>0 TiB</span>
<span>${formatCapacity(rawCapacity, false)}</span>
</div>
</div>
<ul class="feature-list">
<li class="feature-item">
<svg class="feature-icon info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="feature-text">
<div class="feature-title">${config.name}</div>
<div class="feature-description">${config.description}</div>
</div>
</li>
<li class="feature-item">
<svg class="feature-icon ${config.faultTolerance > 0 ? 'success' : 'warning'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<div class="feature-text">
<div class="feature-title">Fault Tolerance</div>
<div class="feature-description">
${config.faultTolerance > 0
? `Can survive ${config.faultTolerance} drive failure${config.faultTolerance > 1 ? 's' : ''} without data loss`
: 'No redundancy - any drive failure results in data loss'}
</div>
</div>
</li>
<li class="feature-item">
<svg class="feature-icon info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<div class="feature-text">
<div class="feature-title">Performance</div>
<div class="feature-description">
Read: ${config.readPerformance} | Write: ${config.writePerformance}
</div>
</div>
</li>
<li class="feature-item">
<svg class="feature-icon success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="4" rx="1"/>
<rect x="2" y="10" width="20" height="4" rx="1"/>
<rect x="2" y="17" width="20" height="4" rx="1"/>
</svg>
<div class="feature-text">
<div class="feature-title">Drive Configuration</div>
<div class="feature-description">
${drives.length} drive${drives.length > 1 ? 's' : ''}: ${this.formatDriveList(drives)}
${result.numVdevs ? `<br><strong>${result.numVdevs} vdevs</strong> × ${result.drivesPerVdev} drives each` : ''}
</div>
</div>
</li>
<li class="feature-item">
<svg class="feature-icon info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
</svg>
<div class="feature-text">
<div class="feature-title">Binary vs Decimal</div>
<div class="feature-description">
Calculations account for the difference between advertised TB (decimal) and actual TiB (binary) capacity
</div>
</div>
</li>
</ul>
</div>
`;
this.resultsContent.innerHTML = html;
}
formatDriveList(drives) {
const counts = {};
drives.forEach(size => {
counts[size] = (counts[size] || 0) + 1;
});
return Object.entries(counts)
.map(([size, count]) => `${count}x ${size}TB`)
.join(', ');
}
displayError(message) {
this.resultsContent.innerHTML = `
<div class="empty-state fade-in">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="stroke: var(--color-accent-danger);">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<p style="color: var(--color-accent-danger);">${message}</p>
</div>
`;
}
}
// ===================================
// Initialize Application
// ===================================
document.addEventListener('DOMContentLoaded', () => {
new RAIDCalculator();
});

799
styles.css Normal file
View File

@@ -0,0 +1,799 @@
/* ===================================
Design System & CSS Variables
=================================== */
:root {
/* Color Palette - Modern Dark Theme */
--color-bg-primary: #0a0e1a;
--color-bg-secondary: #111827;
--color-bg-tertiary: #1a2332;
--color-bg-card: rgba(30, 41, 59, 0.6);
--color-bg-card-hover: rgba(30, 41, 59, 0.8);
/* Accent Colors */
--color-accent-primary: #3b82f6;
--color-accent-secondary: #8b5cf6;
--color-accent-success: #10b981;
--color-accent-warning: #f59e0b;
--color-accent-danger: #ef4444;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-secondary: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
--gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);
--gradient-card: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
/* Text Colors */
--color-text-primary: #f8fafc;
--color-text-secondary: #cbd5e1;
--color-text-tertiary: #94a3b8;
--color-text-muted: #64748b;
/* Border & Shadow */
--color-border: rgba(148, 163, 184, 0.1);
--color-border-focus: rgba(59, 130, 246, 0.5);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.3);
/* Spacing */
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Typography */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ===================================
Base Styles & Reset
=================================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background: var(--color-bg-primary);
color: var(--color-text-primary);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
/* Animated Background */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(16, 185, 129, 0.05) 0%, transparent 50%);
z-index: -1;
animation: backgroundShift 20s ease-in-out infinite;
}
@keyframes backgroundShift {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
/* ===================================
Layout Components
=================================== */
.container {
max-width: 1400px;
margin: 0 auto;
padding: var(--spacing-xl) var(--spacing-lg);
}
/* Header */
.header {
text-align: center;
margin-bottom: var(--spacing-lg);
animation: fadeInDown 0.6s ease-out;
}
.header-content {
position: relative;
}
.title {
font-size: var(--font-size-3xl);
font-weight: 700;
background: var(--gradient-secondary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: var(--spacing-xs);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
line-height: 1.2;
}
.title-icon {
width: 2.5rem;
height: 2.5rem;
filter: drop-shadow(0 0 10px rgba(59, 130, 246, 0.5));
margin-bottom: var(--spacing-xs);
}
.subtitle {
font-size: var(--font-size-base);
color: var(--color-text-secondary);
font-weight: 300;
margin-bottom: var(--spacing-sm);
}
/* YouTube Link */
.youtube-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: linear-gradient(135deg, #FF0000 0%, #CC0000 100%);
color: white;
text-decoration: none;
border-radius: var(--radius-md);
font-weight: 600;
font-size: var(--font-size-sm);
box-shadow: 0 2px 8px rgba(255, 0, 0, 0.3);
transition: all var(--transition-base);
animation: pulse-glow 2s ease-in-out infinite;
margin-top: var(--spacing-md);
}
.youtube-link:hover {
transform: translateY(-2px) scale(1.03);
box-shadow: 0 4px 15px rgba(255, 0, 0, 0.5);
animation: none;
}
.youtube-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 2px 8px rgba(255, 0, 0, 0.3);
}
50% {
box-shadow: 0 2px 15px rgba(255, 0, 0, 0.5);
}
}
/* Main Content */
.main-content {
animation: fadeInUp 0.6s ease-out 0.2s both;
}
/* Card Component */
.card {
background: var(--color-bg-card);
backdrop-filter: blur(10px);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--spacing-xl);
box-shadow: var(--shadow-lg);
transition: all var(--transition-base);
}
.card:hover {
background: var(--color-bg-card-hover);
box-shadow: var(--shadow-xl);
transform: translateY(-2px);
}
/* Calculator Grid */
.calculator-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-xl);
margin-bottom: var(--spacing-2xl);
}
@media (max-width: 968px) {
.calculator-grid {
grid-template-columns: 1fr;
}
}
/* Section Title */
.section-title {
font-size: var(--font-size-2xl);
font-weight: 600;
margin-bottom: var(--spacing-lg);
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.section-title::before {
content: '';
width: 4px;
height: 1.5rem;
background: var(--gradient-secondary);
border-radius: var(--radius-sm);
}
/* ===================================
Form Components
=================================== */
.form-group {
margin-bottom: var(--spacing-lg);
}
.label {
display: block;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: var(--spacing-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Select Input */
.select {
width: 100%;
padding: var(--spacing-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: var(--font-size-base);
font-family: var(--font-family);
cursor: pointer;
transition: all var(--transition-fast);
}
.select:hover {
border-color: var(--color-accent-primary);
}
.select:focus {
outline: none;
border-color: var(--color-accent-primary);
box-shadow: 0 0 0 3px var(--color-border-focus);
}
.select option {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
/* Slider Input */
.input-with-display {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 8px;
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
outline: none;
transition: all var(--transition-fast);
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
background: var(--gradient-secondary);
border-radius: 50%;
cursor: pointer;
box-shadow: var(--shadow-md);
transition: all var(--transition-fast);
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: var(--shadow-glow);
}
.slider::-moz-range-thumb {
width: 24px;
height: 24px;
background: var(--gradient-secondary);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: var(--shadow-md);
transition: all var(--transition-fast);
}
.slider::-moz-range-thumb:hover {
transform: scale(1.2);
box-shadow: var(--shadow-glow);
}
.value-display {
min-width: 3rem;
text-align: center;
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-accent-primary);
}
/* Toggle Switch */
.toggle-container {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.toggle-input {
display: none;
}
.toggle-label {
position: relative;
width: 50px;
height: 26px;
background: var(--color-bg-secondary);
border-radius: 13px;
cursor: pointer;
transition: all var(--transition-base);
border: 1px solid var(--color-border);
}
.toggle-slider {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: var(--color-text-tertiary);
border-radius: 50%;
transition: all var(--transition-base);
}
.toggle-input:checked+.toggle-label {
background: var(--gradient-secondary);
border-color: var(--color-accent-primary);
}
.toggle-input:checked+.toggle-label .toggle-slider {
transform: translateX(24px);
background: white;
}
.toggle-text {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* Helper Text */
.helper-text {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-top: var(--spacing-xs);
font-style: italic;
}
/* Custom Drives Container */
.custom-drives-container {
margin-top: var(--spacing-md);
padding: var(--spacing-md);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.drive-inputs {
display: grid;
gap: var(--spacing-sm);
}
.drive-input-row {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-md);
align-items: center;
}
.drive-label {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
min-width: 80px;
}
.drive-select {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-family: var(--font-family);
}
/* Primary Button */
.btn-primary {
width: 100%;
padding: var(--spacing-md) var(--spacing-xl);
background: var(--gradient-secondary);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: var(--font-size-lg);
font-weight: 600;
font-family: var(--font-family);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
box-shadow: var(--shadow-md);
transition: all var(--transition-base);
margin-top: var(--spacing-xl);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow), var(--shadow-lg);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-icon {
width: 1.5rem;
height: 1.5rem;
}
/* ===================================
Results Panel
=================================== */
.results-content {
min-height: 400px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
color: var(--color-text-tertiary);
}
.empty-icon {
width: 4rem;
height: 4rem;
margin-bottom: var(--spacing-lg);
opacity: 0.5;
}
/* Result Stats */
.result-stats {
display: grid;
gap: var(--spacing-lg);
}
.stat-card {
background: var(--color-bg-secondary);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
transition: all var(--transition-base);
}
.stat-card:hover {
border-color: var(--color-accent-primary);
transform: translateX(4px);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--spacing-xs);
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: var(--spacing-xs);
}
.stat-value.primary {
background: var(--gradient-secondary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-subtext {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
/* Capacity Bar */
.capacity-visualization {
margin: var(--spacing-xl) 0;
}
.capacity-bar {
height: 40px;
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
overflow: hidden;
position: relative;
border: 1px solid var(--color-border);
}
.capacity-fill {
height: 100%;
background: var(--gradient-success);
transition: width var(--transition-slow);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
}
.capacity-legend {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-sm);
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
}
/* Feature List */
.feature-list {
list-style: none;
margin-top: var(--spacing-lg);
}
.feature-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-sm);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
transition: all var(--transition-fast);
}
.feature-item:hover {
border-color: var(--color-accent-primary);
background: var(--color-bg-tertiary);
}
.feature-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
margin-top: 2px;
}
.feature-icon.success {
stroke: var(--color-accent-success);
}
.feature-icon.warning {
stroke: var(--color-accent-warning);
}
.feature-icon.info {
stroke: var(--color-accent-primary);
}
.feature-text {
flex: 1;
}
.feature-title {
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--spacing-xs);
}
.feature-description {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
}
/* ===================================
Info Grid
=================================== */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-lg);
margin-top: var(--spacing-2xl);
}
.info-card {
text-align: center;
padding: var(--spacing-xl);
}
.info-icon {
width: 3rem;
height: 3rem;
margin: 0 auto var(--spacing-lg);
padding: var(--spacing-md);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
}
.info-icon svg {
width: 100%;
height: 100%;
}
.info-icon-blue {
background: rgba(59, 130, 246, 0.1);
color: var(--color-accent-primary);
}
.info-icon-green {
background: rgba(16, 185, 129, 0.1);
color: var(--color-accent-success);
}
.info-icon-purple {
background: rgba(139, 92, 246, 0.1);
color: var(--color-accent-secondary);
}
.info-title {
font-size: var(--font-size-xl);
font-weight: 600;
margin-bottom: var(--spacing-sm);
color: var(--color-text-primary);
}
.info-text {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
line-height: 1.6;
}
/* ===================================
Footer
=================================== */
.footer {
text-align: center;
margin-top: var(--spacing-2xl);
padding: var(--spacing-xl) 0;
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
/* ===================================
Animations
=================================== */
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* ===================================
Responsive Design
=================================== */
@media (max-width: 768px) {
.container {
padding: var(--spacing-md);
}
.title {
font-size: var(--font-size-3xl);
}
.subtitle {
font-size: var(--font-size-base);
}
.info-grid {
grid-template-columns: 1fr;
}
.stat-value {
font-size: var(--font-size-2xl);
}
}
/* ===================================
Utility Classes
=================================== */
.hidden {
display: none !important;
}