| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Caliceo Affluence</title> |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> |
| <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3"></script> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f7fa; color: #1a1a2e; } |
| header { background: linear-gradient(135deg, #0077b6, #00b4d8); color: #fff; padding: 1.5rem 2rem; } |
| header h1 { font-size: 1.5rem; font-weight: 600; } |
| header p { font-size: 0.9rem; opacity: 0.85; margin-top: 0.25rem; } |
| .container { max-width: 1200px; margin: 2rem auto; padding: 0 1.5rem; } |
| .card { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 1.5rem; } |
| .stats { display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; } |
| .stat { background: #f0f4f8; border-radius: 8px; padding: 0.75rem 1.25rem; flex: 1; min-width: 140px; } |
| .stat .label { font-size: 0.75rem; text-transform: uppercase; color: #666; letter-spacing: 0.05em; } |
| .stat .value { font-size: 1.4rem; font-weight: 700; color: #0077b6; margin-top: 0.15rem; } |
| .chart-wrapper { position: relative; height: 400px; } |
| .controls { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; } |
| .controls button { padding: 0.4rem 1rem; border: 1px solid #ddd; border-radius: 6px; background: #fff; cursor: pointer; font-size: 0.85rem; transition: all 0.15s; } |
| .controls button:hover { border-color: #0077b6; color: #0077b6; } |
| .controls button.active { background: #0077b6; color: #fff; border-color: #0077b6; } |
| .controls button.predict { background: #9b59b6; color: #fff; border-color: #9b59b6; } |
| .controls button.predict:hover { background: #8e44ad; border-color: #8e44ad; color: #fff; } |
| .controls button.predict:disabled { opacity: 0.6; cursor: wait; } |
| </style> |
| <script>if (window.self !== window.top) document.documentElement.classList.add('embedded');</script> |
| <style>.embedded header { display: none; } .embedded .container { margin-top: 1rem; }</style> |
| </head> |
| <body> |
| <header> |
| <h1>Caliceo Affluence</h1> |
| <p>Time-series data from julien-c/caliceo dataset</p> |
| </header> |
| <div class="container"> |
| <div class="stats" id="stats"></div> |
| <div class="card"> |
| <div class="controls" id="controls"></div> |
| <div class="chart-wrapper"> |
| <canvas id="chart"></canvas> |
| </div> |
| </div> |
| <p style="margin-top: 0.75rem; font-size: 0.75rem; color: #999;">*PatchTST using transformers.js</p> |
| </div> |
| <script type="module"> |
| import { PatchTSTForPrediction, Tensor } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'; |
| |
| let chart; |
| let allData = []; |
| let predictions = null; |
| |
| async function loadCSV() { |
| const res = await fetch('https://huggingface.co/datasets/julien-c/caliceo/resolve/main/affluence.csv'); |
| const text = await res.text(); |
| const lines = text.trim().split('\n').slice(1); |
| allData = lines.map(line => { |
| const [timestamp, affluence] = line.split(','); |
| return { x: new Date(timestamp), y: parseInt(affluence, 10) }; |
| }); |
| renderStats(allData); |
| buildControls(); |
| renderChart(allData); |
| } |
| |
| function renderStats(data) { |
| const values = data.map(d => d.y); |
| const max = Math.max(...values); |
| const min = Math.min(...values); |
| const avg = (values.reduce((a, b) => a + b, 0) / values.length).toFixed(1); |
| const first = data[0].x.toLocaleDateString(); |
| const last = data[data.length - 1].x.toLocaleDateString(); |
| document.getElementById('stats').innerHTML = ` |
| <div class="stat"><div class="label">Data points</div><div class="value">${data.length.toLocaleString()}</div></div> |
| <div class="stat"><div class="label">Peak</div><div class="value">${max} %</div></div> |
| <div class="stat"><div class="label">Low</div><div class="value">${min} %</div></div> |
| <div class="stat"><div class="label">Average</div><div class="value">${avg} %</div></div> |
| <div class="stat"><div class="label">Period</div><div class="value" style="font-size:0.95rem">${first} – ${last}</div></div> |
| <div class="stat"><div class="label">Last data point</div><div class="value" style="font-size:0.95rem">${data[data.length - 1].x.toLocaleString()}</div></div> |
| `; |
| } |
| |
| function buildControls() { |
| const ranges = [ |
| { label: 'All', days: null }, |
| { label: '1d', days: 1 }, |
| { label: '7d', days: 7 }, |
| { label: '14d', days: 14 }, |
| { label: '30d', days: 30 }, |
| ]; |
| const container = document.getElementById('controls'); |
| ranges.forEach((r, i) => { |
| const btn = document.createElement('button'); |
| btn.textContent = r.label; |
| if (i === 0) btn.classList.add('active'); |
| btn.addEventListener('click', () => { |
| container.querySelectorAll('.range').forEach(b => b.classList.remove('active')); |
| btn.classList.add('active'); |
| const filtered = filterData(r.days); |
| renderStats(filtered); |
| renderChart(filtered); |
| }); |
| btn.classList.add('range'); |
| container.appendChild(btn); |
| }); |
| |
| const predictBtn = document.createElement('button'); |
| predictBtn.textContent = 'Predict next hours*'; |
| predictBtn.classList.add('predict'); |
| predictBtn.addEventListener('click', () => runPrediction(predictBtn)); |
| container.appendChild(predictBtn); |
| } |
| |
| async function runPrediction(btn) { |
| if (predictions) return; |
| btn.disabled = true; |
| btn.textContent = 'Loading model (~2.5 MB)...'; |
| |
| const model = await PatchTSTForPrediction.from_pretrained( |
| 'onnx-community/granite-timeseries-patchtst', |
| { dtype: 'fp32' }, |
| ); |
| |
| btn.textContent = 'Predicting...'; |
| |
| |
| const values = allData.map(d => d.y); |
| const context = values.slice(-512); |
| |
| |
| const mean = context.reduce((a, b) => a + b, 0) / context.length; |
| const std = Math.sqrt(context.reduce((a, b) => a + (b - mean) ** 2, 0) / context.length) || 1; |
| const normalized = context.map(v => (v - mean) / std); |
| |
| |
| |
| const channelData = new Float32Array(512 * 7); |
| for (let i = 0; i < 512; i++) { |
| for (let c = 0; c < 7; c++) { |
| channelData[i * 7 + c] = normalized[i]; |
| } |
| } |
| const past_values = new Tensor('float32', channelData, [1, 512, 7]); |
| const { prediction_outputs } = await model({ past_values }); |
| |
| |
| const predData = prediction_outputs.data; |
| const numChannels = 7; |
| const lastTime = allData[allData.length - 1].x.getTime(); |
| const interval = 10 * 60 * 1000; |
| |
| |
| const predictionLength = 96; |
| predictions = []; |
| for (let i = 0; i < predictionLength; i++) { |
| const raw = predData[i * numChannels] * std + mean; |
| predictions.push({ |
| x: new Date(lastTime + (i + 1) * interval), |
| y: Math.round(Math.max(0, raw)), |
| }); |
| } |
| |
| btn.textContent = 'Predicted!'; |
| |
| |
| const oneDayBtn = document.querySelector('.controls .range:nth-child(2)'); |
| document.querySelectorAll('.controls .range').forEach(b => b.classList.remove('active')); |
| oneDayBtn.classList.add('active'); |
| const filtered = filterData(1); |
| renderStats(filtered); |
| renderChart(filtered); |
| } |
| |
| function filterData(days) { |
| if (!days) return allData; |
| const cutoff = new Date(allData[allData.length - 1].x); |
| cutoff.setDate(cutoff.getDate() - days); |
| return allData.filter(d => d.x >= cutoff); |
| } |
| |
| const weekendBandsPlugin = { |
| id: 'weekendBands', |
| beforeDraw(chart) { |
| const { ctx, chartArea: { left, right, top, bottom }, scales: { x } } = chart; |
| if (!x) return; |
| const min = x.min; |
| const max = x.max; |
| |
| const start = new Date(min); |
| start.setHours(0, 0, 0, 0); |
| while (start.getDay() !== 6) start.setDate(start.getDate() - 1); |
| |
| ctx.save(); |
| ctx.fillStyle = 'rgba(255, 140, 66, 0.1)'; |
| const d = new Date(start); |
| while (d.getTime() <= max) { |
| const satStart = d.getTime(); |
| const sunEnd = satStart + 2 * 24 * 60 * 60 * 1000; |
| const x0 = Math.max(x.getPixelForValue(satStart), left); |
| const x1 = Math.min(x.getPixelForValue(sunEnd), right); |
| if (x1 > left && x0 < right) { |
| ctx.fillRect(x0, top, x1 - x0, bottom - top); |
| } |
| d.setDate(d.getDate() + 7); |
| } |
| ctx.restore(); |
| } |
| }; |
| |
| function isWeekend(date) { |
| const day = date.getDay(); |
| return day === 0 || day === 6; |
| } |
| |
| function renderChart(data) { |
| if (chart) chart.destroy(); |
| const ctx = document.getElementById('chart').getContext('2d'); |
| |
| const gradient = ctx.createLinearGradient(0, 0, 0, 400); |
| gradient.addColorStop(0, 'rgba(0, 119, 182, 0.3)'); |
| gradient.addColorStop(1, 'rgba(0, 119, 182, 0.01)'); |
| |
| const weekendGradient = ctx.createLinearGradient(0, 0, 0, 400); |
| weekendGradient.addColorStop(0, 'rgba(255, 140, 66, 0.3)'); |
| weekendGradient.addColorStop(1, 'rgba(255, 140, 66, 0.01)'); |
| |
| const datasets = [{ |
| label: 'Affluence', |
| data: data, |
| segment: { |
| borderColor: ctx2 => isWeekend(data[ctx2.p0DataIndex].x) && isWeekend(data[ctx2.p1DataIndex].x) ? '#e07020' : '#0077b6', |
| backgroundColor: ctx2 => isWeekend(data[ctx2.p0DataIndex].x) && isWeekend(data[ctx2.p1DataIndex].x) ? weekendGradient : gradient, |
| }, |
| borderColor: '#0077b6', |
| backgroundColor: gradient, |
| borderWidth: 1.5, |
| fill: true, |
| pointRadius: 0, |
| pointHitRadius: 8, |
| tension: 0.3, |
| }]; |
| |
| if (predictions) { |
| |
| const bridge = [data[data.length - 1], ...predictions]; |
| datasets.push({ |
| label: 'Prediction (median)', |
| data: bridge, |
| borderColor: '#9b59b6', |
| borderWidth: 2.5, |
| borderDash: [6, 4], |
| pointRadius: (ctx2) => ctx2.dataIndex === 0 ? 0 : 5, |
| pointBackgroundColor: '#9b59b6', |
| fill: false, |
| tension: 0.3, |
| }); |
| } |
| |
| chart = new Chart(ctx, { |
| type: 'line', |
| data: { datasets }, |
| plugins: [weekendBandsPlugin], |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| interaction: { mode: 'index', intersect: false }, |
| scales: { |
| x: { |
| type: 'time', |
| time: { tooltipFormat: 'PPP p' }, |
| grid: { display: false }, |
| ticks: { maxTicksLimit: 10 }, |
| }, |
| y: { |
| beginAtZero: true, |
| title: { display: true, text: 'Affluence' }, |
| grid: { color: '#eee' }, |
| } |
| }, |
| plugins: { |
| legend: { display: !!predictions, labels: { usePointStyle: true } }, |
| tooltip: { |
| backgroundColor: 'rgba(0,0,0,0.8)', |
| padding: 10, |
| cornerRadius: 8, |
| callbacks: { |
| afterTitle: (items) => { |
| if (items.length && isWeekend(data[items[0].dataIndex].x)) return '(Weekend)'; |
| return ''; |
| }, |
| label: (item) => ` Affluence: ${item.formattedValue} %` |
| } |
| } |
| } |
| } |
| }); |
| } |
| |
| loadCSV(); |
| </script> |
| </body> |
| </html> |
|
|