Skip to content

Instantly share code, notes, and snippets.

@duckida
Created April 26, 2026 12:04
Show Gist options
  • Select an option

  • Save duckida/2f876b475934a99a34912fa9701eb435 to your computer and use it in GitHub Desktop.

Select an option

Save duckida/2f876b475934a99a34912fa9701eb435 to your computer and use it in GitHub Desktop.
S&P 500 Stock Heatmap (programmed by duckida/nanoloop)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stock Heatmap</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #1a1a1a;
color: #fff;
padding: 20px;
min-height: 100vh;
transition: background-color 0.3s, color 0.3s;
}
body.light-theme {
background-color: #f5f5f5;
color: #1a1a1a;
}
header {
text-align: center;
margin-bottom: 30px;
}
h1 {
font-size: 2.5rem;
font-weight: 300;
margin-bottom: 10px;
color: #fff;
}
body.light-theme h1 {
color: #1a1a1a;
}
.subtitle {
color: #aaa;
font-size: 1rem;
margin-bottom: 20px;
}
body.light-theme .subtitle {
color: #666;
}
.theme-toggle {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.1);
border: none;
padding: 10px 15px;
border-radius: 20px;
cursor: pointer;
font-size: 1.2rem;
transition: background-color 0.3s;
}
body.light-theme .theme-toggle {
background: rgba(0, 0, 0, 0.1);
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.2);
}
body.light-theme .theme-toggle:hover {
background: rgba(0, 0, 0, 0.2);
}
.status-bar {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.status-item {
background: rgba(255, 255, 255, 0.1);
padding: 8px 16px;
border-radius: 20px;
font-size: 0.9rem;
transition: transform 0.15s ease, background-color 0.3s;
}
body.light-theme .status-item {
background: rgba(0, 0, 0, 0.1);
}
.status-item.up {
color: #4caf50;
}
.status-item.down {
color: #f44336;
}
.status-item.total {
color: #2196f3;
}
.grid-container {
max-width: 1400px;
margin: 0 auto;
overflow-x: auto;
}
#grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 4px;
}
.stock-cell {
aspect-ratio: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 500;
transition: transform 0.2s, box-shadow 0.2s, background-color 0.3s, opacity 0.3s;
cursor: pointer;
position: relative;
overflow: hidden;
}
.stock-cell:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10;
}
.stock-cell.up {
background-color: #2e7d32;
color: #fff;
}
.stock-cell.down {
background-color: #c62828;
color: #fff;
}
.stock-cell.flat {
background-color: #555;
color: #fff;
}
body.light-theme .stock-cell.up {
background-color: #4caf50;
}
body.light-theme .stock-cell.down {
background-color: #f44336;
}
body.light-theme .stock-cell.flat {
background-color: #9e9e9e;
}
.ticker {
font-weight: 700;
font-size: 0.8rem;
margin-bottom: 2px;
}
.change {
font-size: 0.7rem;
opacity: 0.9;
}
.loading {
text-align: center;
padding: 40px;
font-size: 1.2rem;
color: #aaa;
}
body.light-theme .loading {
color: #666;
}
.error {
text-align: center;
padding: 20px;
background: rgba(244, 67, 54, 0.2);
border-radius: 8px;
margin: 20px auto;
max-width: 600px;
}
body.light-theme .error {
background: rgba(244, 67, 54, 0.1);
color: #c62828;
}
.last-updated {
text-align: center;
margin-top: 30px;
color: #777;
font-size: 0.9rem;
}
body.light-theme .last-updated {
color: #999;
}
/* Modal styles */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
body.light-theme .modal-overlay {
background: rgba(0, 0, 0, 0.5);
}
.modal-overlay.active {
display: flex;
}
.modal {
background: #2a2a2a;
border-radius: 12px;
padding: 30px;
min-width: 300px;
max-width: 400px;
text-align: center;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
position: relative;
}
body.light-theme .modal {
background: #fff;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.modal-close {
position: absolute;
top: 10px;
right: 15px;
font-size: 1.5rem;
cursor: pointer;
color: #aaa;
background: none;
border: none;
}
.modal-close:hover {
color: #fff;
}
body.light-theme .modal-close:hover {
color: #1a1a1a;
}
.modal-ticker {
font-size: 2rem;
font-weight: 700;
margin-bottom: 10px;
}
.modal-company {
font-size: 1rem;
color: #aaa;
margin-bottom: 15px;
}
body.light-theme .modal-company {
color: #666;
}
.modal-price {
font-size: 2.5rem;
font-weight: 300;
margin-bottom: 15px;
}
.modal-change {
font-size: 1.2rem;
margin-bottom: 20px;
}
.modal-change.up {
color: #4caf50;
}
.modal-change.down {
color: #f44336;
}
.modal-details {
font-size: 0.9rem;
color: #aaa;
border-top: 1px solid #444;
padding-top: 15px;
margin-top: 15px;
}
body.light-theme .modal-details {
color: #666;
border-top-color: #ddd;
}
@media (max-width: 768px) {
body {
padding: 10px;
}
h1 {
font-size: 1.8rem;
}
#grid {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 3px;
}
.stock-cell {
font-size: 0.6rem;
}
.ticker {
font-size: 0.7rem;
}
.change {
font-size: 0.6rem;
}
}
@media (max-width: 480px) {
#grid {
grid-template-columns: repeat(auto-fill, minmax(50px, 1fr));
gap: 2px;
}
.stock-cell {
font-size: 0.55rem;
}
}
</style>
</head>
<body>
<button class="theme-toggle" id="theme-toggle">🌙</button>
<header>
<h1>Stock Market Heatmap</h1>
<div class="subtitle">Real-time performance of S&P 500 stocks</div>
<div class="status-bar">
<div class="status-item up" id="up-count">Up: 0</div>
<div class="status-item down" id="down-count">Down: 0</div>
<div class="status-item total" id="total-count">Total: 0</div>
</div>
</header>
<!-- Modal for stock details -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<button class="modal-close" id="modal-close">&times;</button>
<div class="modal-ticker" id="modal-ticker">AAPL</div>
<div class="modal-company" id="modal-company">Apple Inc.</div>
<div class="modal-price" id="modal-price">$0.00</div>
<div class="modal-change" id="modal-change">+0.00%</div>
<div class="modal-details">
<div>Previous Close: <span id="modal-prev-close">$0.00</span></div>
</div>
</div>
</div>
<div class="grid-container">
<div id="grid"></div>
<div class="loading" id="loading">Loading stock data...</div>
<div class="error" id="error" style="display: none;"></div>
</div>
<div class="last-updated" id="last-updated"></div>
<script>
// Theme toggle
const themeToggle = document.getElementById('theme-toggle');
const body = document.body;
// Load saved theme preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light') {
body.classList.add('light-theme');
themeToggle.textContent = '☀️';
}
themeToggle.addEventListener('click', () => {
body.classList.toggle('light-theme');
const isLight = body.classList.contains('light-theme');
themeToggle.textContent = isLight ? '☀️' : '🌙';
localStorage.setItem('theme', isLight ? 'light' : 'dark');
});
// Modal functionality
const modalOverlay = document.getElementById('modal-overlay');
const modalClose = document.getElementById('modal-close');
const modalTicker = document.getElementById('modal-ticker');
const modalCompany = document.getElementById('modal-company');
const modalPrice = document.getElementById('modal-price');
const modalChange = document.getElementById('modal-change');
const modalPrevClose = document.getElementById('modal-prev-close');
// Store stock data for modal
let stockData = {};
function showModal(ticker) {
const stock = stockData[ticker];
if (!stock) return;
const pct = ((stock.current / stock.prevClose - 1) * 100).toFixed(2);
const sign = pct >= 0 ? '+' : '';
const changeClass = stock.current > stock.prevClose ? 'up' :
stock.current < stock.prevClose ? 'down' : '';
modalTicker.textContent = stock.ticker;
modalPrice.textContent = `$${stock.current.toFixed(2)}`;
modalCompany.textContent = stock.name || stock.ticker;
modalChange.textContent = `${sign}${pct}%`;
modalChange.className = `modal-change ${changeClass}`;
modalPrevClose.textContent = `$${stock.prevClose.toFixed(2)}`;
modalOverlay.classList.add('active');
}
function hideModal() {
modalOverlay.classList.remove('active');
}
modalClose.addEventListener('click', hideModal);
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) {
hideModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hideModal();
}
});
// Tickers list (deduplicated)
const tickers = [
"AAPL","MSFT","NVDA","AMZN","GOOGL","GOOG","META","BRK-B","TSLA","AVGO",
"LLY","WMT","JPM","V","XOM","UNH","MA","ORCL","COST","HD",
"PG","NFLX","JNJ","ABBV","CRM","BAC","KO","CVX","MRK","ADBE",
"AMD","PEP","TMO","LIN","WFC","CSCO","ACN","MCD","ABT","DIS",
"GE","PM","INTU","TXN","DHR","CAT","AXP","VZ","AMAT","PLTR",
"QCOM","PFE","UBER","IBM","UNP","AMGN","ISRG","NOW","LOW","SPGI",
"RTX","HON","COP","NEE","GS","DELL","BKNG","T","ELV","SYK",
"C","TJX","PGR","LRCX","VRTX","MS","LMT","ETN","BLK","BSX",
"BA","REGN","CI","ADP","MMC","CB","PLD","PANW","MDLZ","ADI",
"AMT","SBUX","GILD","MU","DE","ZTS","CIEN","SYY","MELI",
"MO","AEE","AEP","AIG","AMP","AME","APH","AON","APA","APO",
"MMM","AOS","AES","AFL","A","APD","ABNB","AKAM","ALB","ARE",
"ALGN","ALLE","LNT","ALL","AMCR","AMG","ANSS","AVY","BALL","BBY",
"BDX","BEN","BF-B","BG","BIIB","BIO","BK","BKR","BLDR","BMY",
"BR","BRO","BWA","BX","BXP","CAG","CAH","CARR","CBRE","CCI",
"CCL","CDNS","CDW","CE","CEG","CF","CFG","CHD","CHRW","CHTR",
"CINF","CL","CLX","CMA","CMCSA","CME","CMG","CMI","CMS","CNC",
"CNP","COF","CPAY","CPB","CPRT","CPT","CRL","CTAS","CTRA","CTSH",
"CTVA","CVS","D","DAL","DAY","DD","DECK","DFS","DG","DGX",
"DHI","DISH","DLTR","DOC","DOV","DOW","DPZ","DRI","DTE","DUK",
"DVA","DVN","DXCM","EA","EBAY","ECL","ED","EFX","EG","EIX",
"EL","EMN","EMR","ENPH","EOG","EPAM","EQIX","EQR","EQT","ERIE",
"ES","ESS","ETR","ETSY","EVRG","EW","EXC","EXPD","EXPE","EXR",
"F","FANG","FAST","FCX","FDS","FDX","FE","FFIV","FI","FICO",
"FIS","FITB","FMC","FOXA","FOX","FRT","FSLR","FTNT","FTV","GD",
"GDDY","GEHC","GEN","GEV","GH","GL","GLW","GM","GNRC","GPC",
"GPK","GRMN","GWW","HAL","HAS","HBAN","HCA","HES","HIG","HII",
"HLT","HOLX","HRL","HSIC","HST","HSY","HUM","HWM","ICE","IDXX",
"IEX","IFF","ILMN","INCY","INTC","IP","IPG","IQV","IR","IRM",
"IT","ITW","IVZ","J","JBHT","JBL","JKHY","K","KDP","KEY",
"KEYS","KHC","KIM","KLAC","KMB","KMI","KMX","KR","L","LDOS",
"LEN","LH","LHX","LKQ","LULU","LUV","LVS","LW","LYB","LYV",
"M","MAA","MAR","MAS","PAYC","PAYX","PCAR","PCG","PEAK","PEG",
"PENN","PFG","PH","PHM","PKG","PKI","PNR","PNW","POOL","PPG",
"PPL","PRU","PSA","PSX","PTC","PWR","PYPL","QRVO","RCL","RE",
"REG","RF","RHI","RJF","RL","RMD","ROK","ROL","ROP","ROST",
"RSG","RVTY","SBAC","SCHW","SHW","SJM","SLB","SNA","SNPS","SO",
"SPG","SRE","STE","STT","STX","STZ","SWK","SWKS","SYF","TAP",
"TDG","TDY","TECH","TEL","TER","TFC","TFX","TGT","TMUS","TPR",
"TRGP","TRMB","TROW","TRV","TSCO","TSN","TT","TTWO","TXT","TYL",
"UAL","UDR","UHS","ULTA","UNM","UPS","URI","USB","VFC","VICI",
"VLO","VMC","VNO","VRSK","VRSN","VTR","VTRS","WAB","WAT","WBA",
"WBD","WDC","WEC","WELL","WES","WFRD","WHR","WM","WMB","WST",
"WTW","WY","WYNN","XEL","XLY","XRAY","XYL","YUM","ZBH","ZBRA"
];
// Deduplicate
const uniqueTickers = [...new Set(tickers)];
const grid = document.getElementById('grid');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const upCountEl = document.getElementById('up-count');
const downCountEl = document.getElementById('down-count');
const totalCountEl = document.getElementById('total-count');
const lastUpdatedEl = document.getElementById('last-updated');
async function fetchData(isInitialLoad = false) {
try {
if (isInitialLoad) {
loading.style.display = 'block';
}
error.style.display = 'none';
// Don't clear the grid - we'll update it smoothly
// Split tickers into chunks to avoid URL length limits
const chunkSize = 50;
let allResults = [];
for (let i = 0; i < uniqueTickers.length; i += chunkSize) {
const chunk = uniqueTickers.slice(i, i + chunkSize);
const url = `https://stock-prices.on99.app/quotes?symbols=${chunk.join(',')}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const text = await response.text();
const lines = text.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
// Skip header line
if (line.startsWith('quotes[')) continue;
const parts = parseCSVLine(line);
if (parts.length < 11) continue;
try {
const ticker = parts[0];
const name = parts[1];
const current = parseFloat(parts[3]);
const prevClose = parseFloat(parts[10]);
if (!isNaN(current) && !isNaN(prevClose) && prevClose > 0) {
allResults.push({ ticker, name, current, prevClose });
}
} catch (e) {}
}
} catch (e) {
console.error('Fetch error for chunk:', e);
}
}
// Sort into up and down
const up = [];
const down = [];
for (const stock of allResults) {
if (stock.current > stock.prevClose) {
up.push(stock);
} else if (stock.current < stock.prevClose) {
down.push(stock);
}
}
// Sort by percentage change
const pctSort = (a, b) => (b.current / b.prevClose) - (a.current / a.prevClose);
up.sort(pctSort);
down.sort(pctSort);
// Update counts with animation
if (upCountEl.textContent !== `Up: ${up.length}`) {
upCountEl.style.transform = 'scale(1.1)';
upCountEl.textContent = `Up: ${up.length}`;
setTimeout(() => { upCountEl.style.transform = 'scale(1)'; }, 150);
}
if (downCountEl.textContent !== `Down: ${down.length}`) {
downCountEl.style.transform = 'scale(1.1)';
downCountEl.textContent = `Down: ${down.length}`;
setTimeout(() => { downCountEl.style.transform = 'scale(1)'; }, 150);
}
if (totalCountEl.textContent !== `Total: ${up.length + down.length}`) {
totalCountEl.style.transform = 'scale(1.1)';
totalCountEl.textContent = `Total: ${up.length + down.length}`;
setTimeout(() => { totalCountEl.style.transform = 'scale(1)'; }, 150);
}
// Store stock data for modal
stockData = {};
for (const stock of [...up, ...down]) {
stockData[stock.ticker] = stock;
}
// Update grid smoothly
updateGridSmooth([...up, ...down]);
loading.style.display = 'none';
// Update timestamp
const now = new Date();
lastUpdatedEl.textContent = `Last updated: ${now.toLocaleTimeString()}`;
} catch (e) {
loading.style.display = 'none';
error.style.display = 'block';
let errorMsg = `Error fetching data: ${e.message}. Retrying in 10 seconds...`;
error.textContent = errorMsg;
console.error('Fetch error:', e);
}
}
function updateGridSmooth(newStocks) {
// Get existing cells
const existingCells = {};
document.querySelectorAll('.stock-cell').forEach(cell => {
existingCells[cell.getAttribute('data-ticker')] = cell;
});
// Create map of new stocks
const newStockMap = {};
for (const stock of newStocks) {
newStockMap[stock.ticker] = stock;
}
// Update existing cells
for (const ticker in existingCells) {
if (newStockMap[ticker]) {
const stock = newStockMap[ticker];
const pct = ((stock.current / stock.prevClose - 1) * 100).toFixed(2);
const sign = pct >= 0 ? '+' : '';
const newCls = stock.current > stock.prevClose ? 'up' :
stock.current < stock.prevClose ? 'down' : 'flat';
const cell = existingCells[ticker];
const changeEl = cell.querySelector('.change');
const oldPct = changeEl.textContent;
const newPct = `${sign}${pct}%`;
// Update class if changed
cell.classList.remove('up', 'down', 'flat');
cell.classList.add(newCls);
// Animate if value changed
if (oldPct !== newPct) {
changeEl.style.transform = 'scale(1.2)';
changeEl.style.transition = 'transform 0.2s ease';
changeEl.textContent = newPct;
setTimeout(() => {
changeEl.style.transform = 'scale(1)';
}, 200);
}
// Update title
cell.title = `${stock.ticker}: ${sign}${pct}%`;
} else {
// Remove cell with fade out
existingCells[ticker].style.opacity = '0';
existingCells[ticker].style.transform = 'scale(0.8)';
setTimeout(() => {
existingCells[ticker].remove();
}, 300);
}
}
// Add new cells
const fragment = document.createDocumentFragment();
for (const stock of newStocks) {
if (!existingCells[stock.ticker]) {
const pct = ((stock.current / stock.prevClose - 1) * 100).toFixed(2);
const sign = pct >= 0 ? '+' : '';
const cls = stock.current > stock.prevClose ? 'up' :
stock.current < stock.prevClose ? 'down' : 'flat';
const cell = document.createElement('div');
cell.className = `stock-cell ${cls}`;
cell.setAttribute('data-ticker', stock.ticker);
cell.title = `${stock.ticker}: ${sign}${pct}%`;
cell.innerHTML = `
<span class="ticker">${stock.ticker}</span>
<span class="change">${sign}${pct}%</span>
`;
// Add fade in animation
cell.style.opacity = '0';
cell.style.transform = 'scale(0.8)';
cell.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
// Add click handler
cell.addEventListener('click', () => {
showModal(stock.ticker);
});
fragment.appendChild(cell);
}
}
// Append new cells
if (fragment.children.length > 0) {
grid.appendChild(fragment);
// Trigger animation
requestAnimationFrame(() => {
grid.querySelectorAll('.stock-cell[style*="opacity: 0"]').forEach(cell => {
cell.style.opacity = '1';
cell.style.transform = 'scale(1)';
});
});
}
// Reorder cells to match the sorted order
const orderedStocks = newStocks.map(s => s.ticker);
const cellMap = {};
grid.querySelectorAll('.stock-cell').forEach(cell => {
cellMap[cell.getAttribute('data-ticker')] = cell;
});
orderedStocks.forEach((ticker, index) => {
const cell = cellMap[ticker];
if (cell) {
grid.appendChild(cell);
}
});
}
function parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
// Fetch on load and refresh every 10 seconds
fetchData(true);
setInterval(() => fetchData(false), 10000);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment