From 6ccc856edc6a603406611185b7fbef57a993a029 Mon Sep 17 00:00:00 2001 From: Nicola Belluti Date: Fri, 8 May 2026 00:48:04 +0200 Subject: [PATCH] Added the ability to download the data in CSV and refactored the codebase --- src/App.svelte | 12 +- src/components/StationList.svelte | 34 +++-- src/components/StationRow.svelte | 234 +++++++++++++++++++++++++----- src/lib/api.js | 21 ++- src/lib/csv-pool.js | 48 ++++++ src/lib/csv.js | 31 ++++ src/lib/csv.worker.js | 5 + src/lib/download-pool.js | 26 ++++ src/lib/groups.json | 2 +- src/lib/timezone.js | 78 ++++++++++ 10 files changed, 423 insertions(+), 68 deletions(-) create mode 100644 src/lib/csv-pool.js create mode 100644 src/lib/csv.js create mode 100644 src/lib/csv.worker.js create mode 100644 src/lib/download-pool.js create mode 100644 src/lib/timezone.js diff --git a/src/App.svelte b/src/App.svelte index c0d1927..23d5b84 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -18,7 +18,7 @@ }); -
+

Centraline

Scarica i dati storici delle stazioni di monitoraggio ambientale di 37100Lab.

@@ -26,9 +26,9 @@
{#if loading} -

Caricamento…

+

Caricamento…

{:else if error} -

{error}

+

{error}

{:else} {/if} @@ -45,7 +45,7 @@
diff --git a/src/lib/api.js b/src/lib/api.js index b5c4b22..721e619 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -7,13 +7,18 @@ export async function fetchStations() { .sort((a, b) => b.pk - a.pk); } -export async function fetchDay(id, date) { - try { - const res = await fetch(`/api/get_measurements/${id}?day=${date}`); - if (!res.ok) return []; - const data = await res.json(); - return data.misure ?? []; - } catch { - return []; +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 { + const res = await fetch(`/api/get_measurements/${id}?day=${date}`, { signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + return data.misure ?? []; + } catch (e) { + // La cancellazione non è un errore: uscire subito senza consumare altri retry. + if (e.name === 'AbortError') return []; + if (attempt === 2) return []; + } } } diff --git a/src/lib/csv-pool.js b/src/lib/csv-pool.js new file mode 100644 index 0000000..921aed0 --- /dev/null +++ b/src/lib/csv-pool.js @@ -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(); + }); +} diff --git a/src/lib/csv.js b/src/lib/csv.js new file mode 100644 index 0000000..447e06a --- /dev/null +++ b/src/lib/csv.js @@ -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`; +} diff --git a/src/lib/csv.worker.js b/src/lib/csv.worker.js new file mode 100644 index 0000000..74f5b1c --- /dev/null +++ b/src/lib/csv.worker.js @@ -0,0 +1,5 @@ +import { buildCsv } from './csv.js'; + +self.onmessage = ({ data: measurements }) => { + self.postMessage(buildCsv(measurements)); +}; diff --git a/src/lib/download-pool.js b/src/lib/download-pool.js new file mode 100644 index 0000000..42890ec --- /dev/null +++ b/src/lib/download-pool.js @@ -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(); + }); +} diff --git a/src/lib/groups.json b/src/lib/groups.json index b25013b..4465142 100644 --- a/src/lib/groups.json +++ b/src/lib/groups.json @@ -1,6 +1,6 @@ [ { - "nome": "Mantova", + "name": "Mantova", "ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180] } ] diff --git a/src/lib/timezone.js b/src/lib/timezone.js new file mode 100644 index 0000000..8a10496 --- /dev/null +++ b/src/lib/timezone.js @@ -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; +}