Added the ability to download the data in CSV and refactored the codebase

This commit is contained in:
2026-05-08 00:48:04 +02:00
parent 02c1a4c58c
commit 6ccc856edc
10 changed files with 423 additions and 68 deletions
+6 -6
View File
@@ -18,7 +18,7 @@
}); });
</script> </script>
<div class="pagina"> <div class="page">
<header> <header>
<h1>Centraline</h1> <h1>Centraline</h1>
<p>Scarica i dati storici delle stazioni di monitoraggio ambientale di <a href="http://37100lab.it:8101/" target="_blank" rel="noreferrer">37100Lab</a>.</p> <p>Scarica i dati storici delle stazioni di monitoraggio ambientale di <a href="http://37100lab.it:8101/" target="_blank" rel="noreferrer">37100Lab</a>.</p>
@@ -26,9 +26,9 @@
<main> <main>
{#if loading} {#if loading}
<p class="stato">Caricamento…</p> <p class="status">Caricamento…</p>
{:else if error} {:else if error}
<p class="stato errore">{error}</p> <p class="status error">{error}</p>
{:else} {:else}
<StationList {stations} /> <StationList {stations} />
{/if} {/if}
@@ -45,7 +45,7 @@
</div> </div>
<style> <style>
.pagina { .page {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 7rem 0.5rem 0; padding: 7rem 0.5rem 0;
@@ -89,12 +89,12 @@
text-decoration: underline; text-decoration: underline;
} }
.stato { .status {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-dim); color: var(--text-dim);
} }
.errore { .error {
color: #dc2626; color: #dc2626;
} }
+18 -16
View File
@@ -4,34 +4,36 @@
export let stations = []; export let stations = [];
// Set per lookup O(1): con molte centraline, includes() su array per ogni riga sarebbe O(n²).
const groupedIds = new Set(groups.flatMap(g => g.ids)); const groupedIds = new Set(groups.flatMap(g => g.ids));
function ordina(stazioni) { // Le attive appaiono prima delle terminate per dare prominenza alle stazioni in funzione.
return [...stazioni].sort((a, b) => { function sortStations(items) {
return [...items].sort((a, b) => {
if (a.ended_at === null && b.ended_at !== null) return -1; if (a.ended_at === null && b.ended_at !== null) return -1;
if (a.ended_at !== null && b.ended_at === null) return 1; if (a.ended_at !== null && b.ended_at === null) return 1;
return new Date(b.created_at) - new Date(a.created_at); return new Date(b.created_at) - new Date(a.created_at);
}); });
} }
const sezioni = [ const sections = [
...groups.map(g => ({ ...groups.map(g => ({
nome: g.nome, name: g.name,
stazioni: ordina(stations.filter(s => g.ids.includes(s.pk))), stations: sortStations(stations.filter(s => g.ids.includes(s.pk))),
})).filter(g => g.stazioni.length > 0), })).filter(s => s.stations.length > 0),
{ {
nome: 'Altre', name: 'Altre',
stazioni: ordina(stations.filter(s => !groupedIds.has(s.pk))), stations: sortStations(stations.filter(s => !groupedIds.has(s.pk))),
}, },
].filter(s => s.stazioni.length > 0); ].filter(s => s.stations.length > 0);
</script> </script>
<div class="contenuto"> <div class="content">
{#each sezioni as sezione} {#each sections as section}
<section> <section>
<h2>{sezione.nome}</h2> <h2>{section.name}</h2>
<div class="lista"> <div class="list">
{#each sezione.stazioni as station (station.pk)} {#each section.stations as station (station.pk)}
<StationRow {station} /> <StationRow {station} />
{/each} {/each}
</div> </div>
@@ -40,7 +42,7 @@
</div> </div>
<style> <style>
.contenuto { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4rem; gap: 4rem;
@@ -57,7 +59,7 @@
color: var(--text-dim); color: var(--text-dim);
} }
.lista { .list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.65rem; gap: 0.65rem;
+195 -35
View File
@@ -1,52 +1,153 @@
<script> <script>
import { onDestroy } from 'svelte';
import { getDayRange, formatDayParam } from '../lib/timezone.js';
import { fetchDay } from '../lib/api.js';
import { downloadCsv, buildFilename } from '../lib/csv.js';
import { buildCsvAsync } from '../lib/csv-pool.js';
import { enqueue } from '../lib/download-pool.js';
export let station; export let station;
const attiva = station.ended_at === null; const isActive = station.ended_at === null;
const fmt = d => new Intl.DateTimeFormat('it-IT', { const formatDate = d => new Intl.DateTimeFormat('it-IT', {
timeZone: 'Europe/Rome', timeZone: 'Europe/Rome',
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
}).format(new Date(d)); }).format(new Date(d));
const dataCreazione = fmt(station.created_at); const createdDate = formatDate(station.created_at);
const dataFine = attiva ? null : fmt(station.ended_at); const endDate = isActive ? null : formatDate(station.ended_at);
let tooltipVisibile = false; let showTooltip = false;
let downloading = false;
let cancelling = false;
let completed = false;
let controller = null;
let currentDay = 0;
let totalDays = 0;
$: pct = totalDays > 0 ? (currentDay / totalDays) * 100 : 0;
async function startDownload() {
// Un controller per sessione: abort() cancella tutte le fetch di questa centralina
// senza interferire con i download paralleli di altre stazioni.
controller = new AbortController();
const { signal } = controller;
downloading = true;
const days = getDayRange(station.created_at, station.ended_at);
totalDays = days.length;
currentDay = 0;
// Array pre-allocato per indice: le fetch partono in parallelo e completano fuori ordine,
// ma resultsByDay[idx] garantisce che il CSV rispetti la sequenza temporale.
const resultsByDay = new Array(days.length);
await Promise.all(days.map((day, idx) =>
enqueue(async () => {
if (signal.aborted) return;
resultsByDay[idx] = await fetchDay(station.pk, formatDayParam(day), signal);
currentDay++;
})
));
if (!signal.aborted) {
const measurements = resultsByDay.flat();
currentDay = totalDays;
// Breve pausa prima del reset: l'utente deve poter vedere la barra al 100%.
await new Promise(r => setTimeout(r, 900));
if (!signal.aborted && measurements.length > 0) {
const csv = await buildCsvAsync(measurements);
downloadCsv(csv, buildFilename(station.title, station.pk, new Date()));
completed = true;
// Il segno di spunta deve restare visibile abbastanza a lungo da essere notato.
await new Promise(r => setTimeout(r, 2000));
}
} else {
await new Promise(r => setTimeout(r, 700));
}
downloading = false;
cancelling = false;
completed = false;
controller = null;
}
function cancel() {
cancelling = true;
controller?.abort();
}
// Pulisce le fetch pendenti se il componente viene smontato durante un download.
onDestroy(() => controller?.abort());
</script> </script>
<div class="card"> <div class="card">
<div class="sinistra"> <div class="left">
<a class="nome" href="http://37100lab.it:8101/campagna/{station.pk}" target="_blank" rel="noreferrer">{station.title}</a> <a class="name" href="http://37100lab.it:8101/campagna/{station.pk}" target="_blank" rel="noreferrer">{station.title}</a>
<span class="id">#{station.pk}</span> <span class="id">#{station.pk}</span>
</div> </div>
<div class="destra"> <div class="right">
<span class="data">Attivata il {dataCreazione}</span> <span class="date">Attivata il {createdDate}</span>
<span class="sep">·</span> <span class="sep">·</span>
<span <span
class="stato" class="status"
class:attiva class:active={isActive}
class:terminata={!attiva} class:ended={!isActive}
on:mouseenter={() => tooltipVisibile = true} on:mouseenter={() => showTooltip = true}
on:mouseleave={() => tooltipVisibile = false} on:mouseleave={() => showTooltip = false}
> >
{attiva ? 'Attiva' : 'Terminata'} {isActive ? 'Attiva' : 'Terminata'}
{#if !attiva && tooltipVisibile} {#if !isActive && showTooltip}
<span class="tooltip">Terminata il {dataFine}</span> <span class="tooltip">Terminata il {endDate}</span>
{/if} {/if}
</span> </span>
<button title="Scarica CSV" disabled>
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <button
type="button"
title={downloading ? 'Annulla' : 'Scarica CSV'}
on:click={downloading ? cancel : startDownload}
class:downloading
class:completed
>
{#if completed}
<svg width="18" height="18" viewBox="0 0 16 16" fill="none">
<path d="M2 8l4.5 4.5L14 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else if downloading}
<span class="icon-download">
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="40 20"/>
</svg>
</span>
<span class="icon-cancel">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 1l12 12M13 1L1 13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</span>
{:else}
<svg width="20" height="20" viewBox="0 0 16 16" fill="none">
<path d="M8 1v9M4.5 6.5 8 10l3.5-3.5M2 12h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8 1v9M4.5 6.5 8 10l3.5-3.5M2 12h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
{/if}
</button> </button>
</div> </div>
{#if downloading}
<div class="bar-wrap">
<div class="bar" class:cancelled={cancelling} class:completed style="width: {Math.max(pct, 2)}%"></div>
</div>
{/if}
</div> </div>
<style> <style>
.card { .card {
position: relative;
overflow: hidden;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 14px; border-radius: 14px;
@@ -72,18 +173,45 @@
transition: background 0.15s, color 0.15s, border-color 0.15s; transition: background 0.15s, color 0.15s, border-color 0.15s;
} }
button:hover:not(:disabled) { button:hover {
background: #eff6ff; background: #eff6ff;
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
} }
button:disabled { button.downloading {
opacity: 0.3; color: var(--accent);
cursor: default; border-color: var(--accent);
background: #eff6ff;
} }
.sinistra { button.downloading .icon-cancel { display: none; }
button.downloading .icon-download { display: flex; }
button.downloading:hover {
background: #fef2f2;
border-color: #dc2626;
color: #dc2626;
}
button.downloading:hover .icon-cancel { display: flex; }
button.downloading:hover .icon-download { display: none; }
.icon-download, .icon-cancel {
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.left {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 1.25rem; gap: 1.25rem;
@@ -91,7 +219,7 @@
min-width: 0; min-width: 0;
} }
.nome { .name {
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 500; font-weight: 500;
color: var(--text-hi); color: var(--text-hi);
@@ -101,7 +229,7 @@
white-space: nowrap; white-space: nowrap;
} }
.nome:hover { .name:hover {
text-decoration: underline; text-decoration: underline;
} }
@@ -112,7 +240,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
.destra { .right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -125,30 +253,27 @@
opacity: 0.5; opacity: 0.5;
} }
.data { .date {
font-size: 0.78rem; font-size: 0.78rem;
color: var(--text-dim); color: var(--text-dim);
white-space: nowrap; white-space: nowrap;
} }
.stato { .status {
position: relative;
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
} }
.attiva { .active {
color: var(--accent); color: var(--accent);
} }
.terminata { .ended {
color: var(--text-dim); color: var(--text-dim);
} }
.stato {
position: relative;
}
.tooltip { .tooltip {
position: absolute; position: absolute;
bottom: calc(100% + 16px); bottom: calc(100% + 16px);
@@ -173,4 +298,39 @@
border: 6px solid transparent; border: 6px solid transparent;
border-top-color: var(--border); border-top-color: var(--border);
} }
.bar-wrap {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--border);
}
.bar {
height: 100%;
background: var(--accent);
transition: width 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94), background 0.2s;
}
.bar.cancelled {
background: #dc2626;
}
.bar.completed {
background: #16a34a;
}
button.completed {
color: #16a34a;
border-color: #16a34a;
background: #f0fdf4;
}
button.completed:hover {
color: #16a34a;
border-color: #16a34a;
background: #f0fdf4;
}
</style> </style>
+10 -5
View File
@@ -7,13 +7,18 @@ export async function fetchStations() {
.sort((a, b) => b.pk - a.pk); .sort((a, b) => b.pk - a.pk);
} }
export async function fetchDay(id, date) { export async function fetchDay(id, date, signal) {
// L'API risponde con 5xx transienti: tre tentativi limitano i buchi nel dataset scaricato.
for (let attempt = 0; attempt < 3; attempt++) {
try { try {
const res = await fetch(`/api/get_measurements/${id}?day=${date}`); const res = await fetch(`/api/get_measurements/${id}?day=${date}`, { signal });
if (!res.ok) return []; if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
return data.misure ?? []; return data.misure ?? [];
} catch { } catch (e) {
return []; // La cancellazione non è un errore: uscire subito senza consumare altri retry.
if (e.name === 'AbortError') return [];
if (attempt === 2) return [];
}
} }
} }
+48
View File
@@ -0,0 +1,48 @@
import CsvWorker from './csv.worker.js?worker';
// Costruire il CSV di un'intera centralina (potenzialmente decine di migliaia di righe)
// blocca il main thread per centinaia di ms: si delega a Worker separati.
// Due worker perché la costruzione CSV è CPU-bound: aggiungerne di più non migliora
// il singolo job ma aumenta il consumo di memoria.
const POOL_SIZE = 2;
const queue = [];
const idle = [];
for (let i = 0; i < POOL_SIZE; i++) {
const w = new CsvWorker();
w.onmessage = ({ data }) => {
// _task aggancia la Promise al Worker: il message handler non riceve l'identità
// del worker mittente, quindi è l'unico modo per risalire alla Promise da risolvere.
const { resolve } = w._task;
w._task = null;
idle.push(w);
resolve(data);
dispatch();
};
w.onerror = e => {
const { reject } = w._task;
w._task = null;
idle.push(w);
reject(e);
dispatch();
};
idle.push(w);
}
function dispatch() {
if (!queue.length || !idle.length) return;
const w = idle.pop();
w._task = queue.shift();
w.postMessage(w._task.measurements);
}
export function buildCsvAsync(measurements) {
return new Promise((resolve, reject) => {
queue.push({ measurements, resolve, reject });
dispatch();
});
}
+31
View File
@@ -0,0 +1,31 @@
import { formatTimestampRome, ROME } from './timezone.js';
export function buildCsv(measurements) {
const header = '"Data","Ora","Temperatura","Umidità","PM10"';
const rows = measurements.map(m => {
const [date, time] = formatTimestampRome(m.time).split(' ');
return `"${date}","${time.split('.')[0]}",${m.temp},${m.umid},${m.pm10}`;
});
// BOM obbligatorio: Excel su Windows interpreta UTF-8 senza BOM come Windows-1252,
// corrompendo i caratteri accentati nelle intestazioni.
return '' + [header, ...rows].join('\n');
}
export function downloadCsv(content, filename) {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
// revokeObjectURL libera subito la memoria del blob: il click sincrono è sufficiente
// perché il browser avvia il download prima di passare allo statement successivo.
URL.revokeObjectURL(url);
}
export function buildFilename(title, id, date) {
// en-CA produce YYYY-MM-DD, il formato ISO che vogliamo nel nome file.
const dateStr = new Intl.DateTimeFormat('en-CA', { timeZone: ROME }).format(date);
const sanitized = title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '');
return `${sanitized}_(${id})_${dateStr}.csv`;
}
+5
View File
@@ -0,0 +1,5 @@
import { buildCsv } from './csv.js';
self.onmessage = ({ data: measurements }) => {
self.postMessage(buildCsv(measurements));
};
+26
View File
@@ -0,0 +1,26 @@
// Limita le richieste HTTP simultanee per non sovraccaricare l'API del server.
export const DOWNLOAD_WORKERS = 3;
let active = 0;
const queue = [];
function next() {
while (active < DOWNLOAD_WORKERS && queue.length) {
active++;
const { fn, resolve } = queue.shift();
// Promise.resolve() garantisce che fn() sia trattato come asincrono anche se
// restituisse un valore sincrono, rendendo il finally sempre raggiungibile.
Promise.resolve(fn()).then(resolve).finally(() => {
active--;
// Rientrata ricorsiva: ogni job completato sveglia il prossimo in coda.
next();
});
}
}
export function enqueue(fn) {
return new Promise(resolve => {
queue.push({ fn, resolve });
next();
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
[ [
{ {
"nome": "Mantova", "name": "Mantova",
"ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180] "ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180]
} }
] ]
+78
View File
@@ -0,0 +1,78 @@
export const ROME = 'Europe/Rome';
// Pre-calcolato una volta: viene chiamato per ogni riga del CSV, potenzialmente decine di migliaia di volte.
const PAD2 = Array.from({ length: 100 }, (_, i) => String(i).padStart(2, '0'));
// L'offset CET/CEST cambia solo due volte l'anno: cachare evita una chiamata Intl per ogni timestamp.
const offsetCache = new Map();
function getRomeOffsetMs(date) {
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}-${date.getUTCHours()}`;
if (offsetCache.has(key)) return offsetCache.get(key);
const romeHour = parseInt(
new Intl.DateTimeFormat('en', { timeZone: ROME, hour: 'numeric', hour12: false }).format(date),
10
);
let diff = romeHour - date.getUTCHours();
// Se diff supera ±12 le ore attraversano mezzanotte UTC: il salto va corretto.
if (diff > 12) diff -= 24;
if (diff < -12) diff += 24;
const offsetMs = diff * 3_600_000;
offsetCache.set(key, offsetMs);
return offsetMs;
}
export function formatTimestampRome(isoString) {
const d = new Date(isoString);
const frac = Math.floor(d.getMilliseconds() / 100);
// Shift manuale invece di toLocaleString: controllo totale sul formato,
// indipendente dall'implementazione Intl specifica del browser.
const r = new Date(d.getTime() + getRomeOffsetMs(d));
const dd = PAD2[r.getUTCDate()];
const mo = PAD2[r.getUTCMonth() + 1];
const yyyy = r.getUTCFullYear();
const hh = PAD2[r.getUTCHours()];
const mm = PAD2[r.getUTCMinutes()];
const ss = PAD2[r.getUTCSeconds()];
return `${dd}/${mo}/${yyyy} ${hh}:${mm}:${ss}.${frac}`;
}
export function formatDayParam(date) {
// formatToParts garantisce valori senza zero-padding, a differenza di format()
// il cui output dipende dalla localizzazione del browser.
const parts = new Intl.DateTimeFormat('it-IT', {
timeZone: ROME,
year: 'numeric',
month: 'numeric',
day: 'numeric',
}).formatToParts(date);
const get = type => parts.find(p => p.type === type).value;
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function getDayRange(startIsoUtc, endIsoUtc) {
// en-CA produce YYYY-MM-DD, l'unico formato ISO interpretato uniformemente da tutti i browser.
const toRomeDateString = date => new Intl.DateTimeFormat('en-CA', {
timeZone: ROME,
}).format(date);
const startStr = toRomeDateString(new Date(startIsoUtc));
const endStr = toRomeDateString(endIsoUtc ? new Date(endIsoUtc) : new Date());
const days = [];
// Mezzogiorno UTC (13:00/14:00 a Roma) non attraversa mai un cambio data locale,
// neanche durante il passaggio ora legale/solare.
let cursor = new Date(`${startStr}T12:00:00Z`);
const end = new Date(`${endStr}T12:00:00Z`);
while (cursor <= end) {
days.push(new Date(cursor));
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
}
return days;
}