Spaces:
Configuration error
Configuration error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Shingle Geek — Open Roofing Cost Data</title> | |
| <meta name="description" content="Open-source roofing cost transparency data for 400+ US cities. Independent pricing analysis by Shingle Geek."> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| :root { | |
| --accent: #ea5f1a; | |
| --accent-light: #ff7a3d; | |
| --bg: #0f1116; | |
| --bg-2: #181b22; | |
| --bg-3: #1e222b; | |
| --text: #e8e8e6; | |
| --text-2: #9a9a96; | |
| --card-border: #2a2e38; | |
| --line: #2a2e38; | |
| } | |
| body { | |
| font-family: 'Inter Tight', system-ui, sans-serif; | |
| background: var(--bg) ; | |
| color: var(--text) ; | |
| overflow-x: hidden; | |
| } | |
| /* ── Hero ─────────────────────────────────────────── */ | |
| .hero { | |
| background: var(--bg); | |
| padding: 64px 24px 48px; | |
| text-align: center; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .hero::before { | |
| content: ''; | |
| position: absolute; | |
| top: -50%; left: -50%; | |
| width: 200%; height: 200%; | |
| background: radial-gradient(circle at 30% 50%, rgba(234,95,26,0.1) 0%, transparent 50%), | |
| radial-gradient(circle at 70% 80%, rgba(234,95,26,0.06) 0%, transparent 40%); | |
| animation: pulse 8s ease-in-out infinite alternate; | |
| } | |
| @keyframes pulse { | |
| from { transform: scale(1) rotate(0deg); } | |
| to { transform: scale(1.05) rotate(2deg); } | |
| } | |
| .hero-content { position: relative; z-index: 1; max-width: 640px; margin: 0 auto; } | |
| .hero-badge { | |
| display: inline-flex; align-items: center; gap: 6px; | |
| background: rgba(234,95,26,0.15); border: 1px solid rgba(234,95,26,0.3); | |
| color: var(--accent-light); font-size: 12px; font-weight: 600; | |
| letter-spacing: 0.08em; text-transform: uppercase; | |
| padding: 6px 14px; border-radius: 100px; margin-bottom: 24px; | |
| } | |
| .hero h1 { | |
| font-size: clamp(32px, 5vw, 48px); font-weight: 900; | |
| letter-spacing: -0.03em; line-height: 1.1; margin-bottom: 14px; | |
| } | |
| .hero h1 span { color: #ea5f1a ; } | |
| .hero p { | |
| font-size: 16px; line-height: 1.6; color: var(--text-2); | |
| max-width: 500px; margin: 0 auto 28px; | |
| } | |
| .hero-links { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; } | |
| .hero-links a { | |
| display: inline-flex; align-items: center; gap: 8px; | |
| padding: 12px 24px; border-radius: 10px; font-size: 14px; | |
| font-weight: 600; text-decoration: none; transition: all 0.2s ease; | |
| } | |
| .btn-primary { background: var(--accent); color: white; } | |
| .btn-primary:hover { background: var(--accent-light); transform: translateY(-2px); box-shadow: 0 8px 24px rgba(234,95,26,0.3); } | |
| .btn-outline { background: transparent; color: var(--text); border: 1px solid var(--card-border); } | |
| .btn-outline:hover { border-color: var(--accent); background: rgba(234,95,26,0.05); transform: translateY(-2px); } | |
| /* ── Stats ─────────────────────────────────────────── */ | |
| .stats { | |
| display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; | |
| background: var(--line); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); | |
| } | |
| @media (max-width: 600px) { .stats { grid-template-columns: repeat(2, 1fr); } } | |
| .stat { background: var(--bg-2); padding: 24px 16px; text-align: center; } | |
| .stat-value { font-size: 30px; font-weight: 900; letter-spacing: -0.03em; color: #ea5f1a ; line-height: 1; margin-bottom: 4px; } | |
| .stat-label { font-size: 12px; font-weight: 500; color: var(--text-2); } | |
| /* ── Table Section ─────────────────────────────────── */ | |
| .section { | |
| max-width: 800px; margin: 0 auto; padding: 40px 24px; | |
| } | |
| .section h2 { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; margin-bottom: 6px; } | |
| .section h2 span { color: #ea5f1a ; } | |
| .section-sub { font-size: 14px; color: var(--text-2); line-height: 1.6; margin-bottom: 20px; } | |
| .preview-table { | |
| width: 100%; border-collapse: collapse; font-size: 14px; | |
| background: var(--bg-3); border-radius: 12px; overflow: hidden; | |
| border: 1px solid var(--card-border); | |
| } | |
| .preview-table th { | |
| background: var(--bg-2); color: var(--text-2); padding: 10px 16px; | |
| text-align: left; font-weight: 600; font-size: 11px; | |
| letter-spacing: 0.06em; text-transform: uppercase; | |
| } | |
| .preview-table td { padding: 10px 16px; border-bottom: 1px solid var(--line); color: var(--text); } | |
| .preview-table tr:last-child td { border-bottom: none; } | |
| .preview-table tr:hover td { background: rgba(234,95,26,0.04); } | |
| .cost-fair { color: #4ade80; font-weight: 600; } | |
| .cost-markup { color: #f87171; font-weight: 600; } | |
| .preview-table a { color: #ea5f1a ; text-decoration: none; font-weight: 500; } | |
| .preview-table a:hover { text-decoration: underline; } | |
| /* ── Footer ────────────────────────────────────────── */ | |
| .footer { | |
| background: var(--bg-2); border-top: 1px solid var(--line); | |
| color: var(--text-2); text-align: center; padding: 24px; font-size: 13px; | |
| } | |
| .footer a { color: var(--accent-light); text-decoration: none; } | |
| .footer a:hover { text-decoration: underline; } | |
| /* ── Search Bar ────────────────────────────────────── */ | |
| .search-container { | |
| margin-bottom: 24px; | |
| position: relative; | |
| } | |
| .search-input { | |
| width: 100%; | |
| padding: 14px 16px 14px 44px; | |
| background: var(--bg-3); | |
| border: 1px solid var(--card-border); | |
| border-radius: 10px; | |
| color: var(--text); | |
| font-family: inherit; | |
| font-size: 14px; | |
| outline: none; | |
| transition: all 0.2s ease; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.1); | |
| } | |
| .search-input:focus { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 0 3px rgba(234, 95, 26, 0.15), 0 4px 12px rgba(0,0,0,0.15); | |
| } | |
| .search-input::placeholder { | |
| color: var(--text-2); | |
| } | |
| .search-input:disabled { | |
| opacity: 0.6; | |
| cursor: not-allowed; | |
| } | |
| .search-icon { | |
| position: absolute; | |
| left: 16px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-size: 16px; | |
| pointer-events: none; | |
| user-select: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="hero"> | |
| <div class="hero-content"> | |
| <div class="hero-badge">🏠 Open Data Initiative</div> | |
| <h1>See What Your Roof <span>Actually Costs</span></h1> | |
| <p>Independent, city-level roofing cost data — so homeowners can compare fair contractor pricing against inflated sales quotes.</p> | |
| <div class="hero-links"> | |
| <a href="https://www.shinglegeek.com" class="btn-primary" target="_blank">🌐 Visit Shingle Geek</a> | |
| <a href="https://huggingface.co/datasets/ShingleGeek/roofing-cost-index" class="btn-outline" target="_blank">📊 Download Dataset</a> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="stats"> | |
| <div class="stat"><div class="stat-value" id="city-count">505</div><div class="stat-label">US Cities Tracked</div></div> | |
| <div class="stat"><div class="stat-value" id="state-count">50</div><div class="stat-label">States Covered</div></div> | |
| <div class="stat"><div class="stat-value">$0</div><div class="stat-label">Hidden Fees</div></div> | |
| <div class="stat"><div class="stat-value">Weekly</div><div class="stat-label">Data Refresh</div></div> | |
| </div> | |
| <div class="section"> | |
| <h2>Dataset <span>Explorer</span></h2> | |
| <p class="section-sub">Fair contractor estimate vs. sales company price. Use the interactive search below to look up any tracked city.</p> | |
| <div class="search-container"> | |
| <span class="search-icon">🔍</span> | |
| <input type="text" id="city-search" class="search-input" placeholder="Loading interactive search..." disabled> | |
| </div> | |
| <table class="preview-table"> | |
| <thead> | |
| <tr><th>City</th><th>State</th><th>Fair Estimate</th><th>Sales Price</th><th>Source</th></tr> | |
| </thead> | |
| <tbody> | |
| <tr><td>Houston</td><td>TX</td><td class="cost-fair">$11,900</td><td class="cost-markup">$15,470</td><td><a href="https://www.shinglegeek.com/cost/houston-tx" target="_blank">View →</a></td></tr> | |
| <tr><td>Nashville</td><td>TN</td><td class="cost-fair">$11,900</td><td class="cost-markup">$15,470</td><td><a href="https://www.shinglegeek.com/cost/nashville-tn" target="_blank">View →</a></td></tr> | |
| <tr><td>Beverly Hills</td><td>CA</td><td class="cost-fair">$12,100</td><td class="cost-markup">$15,730</td><td><a href="https://www.shinglegeek.com/cost/beverly-hills-ca" target="_blank">View →</a></td></tr> | |
| <tr><td>Kirkland</td><td>WA</td><td class="cost-fair">$10,750</td><td class="cost-markup">$13,975</td><td><a href="https://www.shinglegeek.com/cost/kirkland-wa" target="_blank">View →</a></td></tr> | |
| <tr><td>Naples</td><td>FL</td><td class="cost-fair">$11,900</td><td class="cost-markup">$15,470</td><td><a href="https://www.shinglegeek.com/cost/naples-fl" target="_blank">View →</a></td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div class="footer"> | |
| <p>© 2026 <a href="https://www.shinglegeek.com" target="_blank">Shingle Geek</a> · CC BY 4.0 · JSONL + CSV formats available</p> | |
| </div> | |
| <script> | |
| let allCities = []; | |
| const defaultCities = [ | |
| { city: 'Houston', state: 'TX', fairEstimate: 11900, salesEstimate: 15470, slug: 'houston-tx' }, | |
| { city: 'Nashville', state: 'TN', fairEstimate: 11900, salesEstimate: 15470, slug: 'nashville-tn' }, | |
| { city: 'Beverly Hills', state: 'CA', fairEstimate: 12100, salesEstimate: 15730, slug: 'beverly-hills-ca' }, | |
| { city: 'Kirkland', state: 'WA', fairEstimate: 10750, salesEstimate: 13975, slug: 'kirkland-wa' }, | |
| { city: 'Naples', state: 'FL', fairEstimate: 11900, salesEstimate: 15470, slug: 'naples-fl' } | |
| ]; | |
| async function initDynamicData() { | |
| const searchInput = document.getElementById('city-search'); | |
| const cityCountEl = document.getElementById('city-count'); | |
| const stateCountEl = document.getElementById('state-count'); | |
| try { | |
| const csvUrl = 'https://huggingface.co/datasets/ShingleGeek/roofing-cost-index/resolve/main/roofing_cost_index_2026.csv'; | |
| const response = await fetch(csvUrl); | |
| if (!response.ok) throw new Error('Failed to fetch dataset'); | |
| const csvText = await response.text(); | |
| const lines = csvText.split('\n'); | |
| const parsedData = []; | |
| const uniqueStates = new Set(); | |
| for (let i = 1; i < lines.length; i++) { | |
| const line = lines[i].trim(); | |
| if (!line) continue; | |
| const parts = line.split(','); | |
| if (parts.length >= 5) { | |
| const city = parts[0].replace(/"/g, '').trim(); | |
| const state = parts[1].replace(/"/g, '').trim(); | |
| const fairEstimate = parseInt(parts[2].trim(), 10); | |
| const salesEstimate = parseInt(parts[3].trim(), 10); | |
| const sourceUrl = parts.slice(4).join(',').replace(/"/g, '').trim(); | |
| const slug = sourceUrl.split('/').pop(); | |
| if (city && state && !isNaN(fairEstimate)) { | |
| parsedData.push({ city, state, fairEstimate, salesEstimate, slug }); | |
| uniqueStates.add(state); | |
| } | |
| } | |
| } | |
| if (parsedData.length > 0) { | |
| allCities = parsedData; | |
| cityCountEl.textContent = allCities.length; | |
| stateCountEl.textContent = uniqueStates.size; | |
| searchInput.placeholder = `🔍 Search ${allCities.length} cities...`; | |
| searchInput.disabled = false; | |
| } | |
| } catch (error) { | |
| console.warn('Falling back to static defaults:', error); | |
| searchInput.placeholder = '🔍 Search 500+ cities...'; | |
| searchInput.disabled = false; // keep search enabled but we will search locally compiled defaults or show warning | |
| } | |
| } | |
| function handleSearch(e) { | |
| const query = e.target.value.toLowerCase().trim(); | |
| const dataset = allCities.length > 0 ? allCities : defaultCities; | |
| if (!query) { | |
| renderRows(defaultCities); | |
| return; | |
| } | |
| const filtered = dataset.filter(c => | |
| c.city.toLowerCase().includes(query) || | |
| c.state.toLowerCase() === query | |
| ); | |
| renderRows(filtered.slice(0, 10), query, filtered.length); | |
| } | |
| function renderRows(items, query = '', totalCount = 0) { | |
| const tableBody = document.querySelector('.preview-table tbody'); | |
| if (items.length === 0) { | |
| tableBody.innerHTML = `<tr><td colspan="5" style="text-align: center; color: var(--text-2); padding: 32px 16px;">No cities found matching "${query}"</td></tr>`; | |
| return; | |
| } | |
| let html = ''; | |
| items.forEach(item => { | |
| html += `<tr> | |
| <td>${item.city}</td> | |
| <td>${item.state}</td> | |
| <td class="cost-fair">$${item.fairEstimate.toLocaleString()}</td> | |
| <td class="cost-markup">$${item.salesEstimate.toLocaleString()}</td> | |
| <td><a href="https://www.shinglegeek.com/cost/${item.slug}" target="_blank">View →</a></td> | |
| </tr>`; | |
| }); | |
| if (totalCount > 10) { | |
| html += `<tr><td colspan="5" style="text-align: center; color: var(--text-2); font-size: 12px; padding: 12px; border-top: 1px solid var(--line);">Showing top 10 of ${totalCount} matching cities.</td></tr>`; | |
| } | |
| tableBody.innerHTML = html; | |
| } | |
| document.getElementById('city-search').addEventListener('input', handleSearch); | |
| window.addEventListener('DOMContentLoaded', initDynamicData); | |
| </script> | |
| </body> | |
| </html> | |