Added the ability to download the data in CSV and refactored the codebase
This commit is contained in:
+6
-6
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
<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"/>
|
type="button"
|
||||||
</svg>
|
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"/>
|
||||||
|
</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>
|
||||||
|
|||||||
+13
-8
@@ -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) {
|
||||||
try {
|
// L'API risponde con 5xx transienti: tre tentativi limitano i buchi nel dataset scaricato.
|
||||||
const res = await fetch(`/api/get_measurements/${id}?day=${date}`);
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
if (!res.ok) return [];
|
try {
|
||||||
const data = await res.json();
|
const res = await fetch(`/api/get_measurements/${id}?day=${date}`, { signal });
|
||||||
return data.misure ?? [];
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch {
|
const data = await res.json();
|
||||||
return [];
|
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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { buildCsv } from './csv.js';
|
||||||
|
|
||||||
|
self.onmessage = ({ data: measurements }) => {
|
||||||
|
self.postMessage(buildCsv(measurements));
|
||||||
|
};
|
||||||
@@ -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
@@ -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]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user