Compare commits
13 Commits
5c57b56fa5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02f23b391d | |||
| 5ac470ba46 | |||
| 267b174931 | |||
| 1af46c0fa3 | |||
| 2246b57c2a | |||
| 41152138c1 | |||
| 2e47984bef | |||
| 11bddcac70 | |||
| e6ce118333 | |||
| 6ccc856edc | |||
| 02c1a4c58c | |||
| 02392b8c24 | |||
| a335736ccf |
@@ -0,0 +1,26 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
container: docker.io/oven/bun:1-alpine
|
||||
steps:
|
||||
- name: Installa git
|
||||
run: apk add --no-cache git
|
||||
|
||||
- name: Checkout del codice
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Installa dipendenze
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Deploy su Vercel
|
||||
run: bunx vercel --prod --yes
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -14,48 +14,67 @@ componenti, file, classi CSS, e qualsiasi termine tecnico consolidato (es.
|
||||
|
||||
## Panoramica
|
||||
|
||||
Portale web **statico** (solo frontend, nessun backend) per visualizzare le
|
||||
centraline di monitoraggio ambientale di 37100lab e scaricare i dati storici in
|
||||
formato CSV.
|
||||
Portale web per visualizzare le centraline di monitoraggio ambientale di
|
||||
37100lab e scaricare i dati storici come archivio ZIP contenente CSV.
|
||||
|
||||
**Stack**: Svelte + Vite. Output finale: cartella `dist/` da servire con
|
||||
qualsiasi web server statico.
|
||||
**Stack**: SvelteKit + Vite + TypeScript. La lista centraline viene caricata
|
||||
lato server al page load (`+page.server.ts`); le richieste di misurazioni
|
||||
vengono instradate attraverso una route API interna (`/api/get_station_data`)
|
||||
che funge da proxy verso il backend esterno, aggiungendo sanitizzazione e
|
||||
cache HTTP.
|
||||
|
||||
---
|
||||
|
||||
## Setup iniziale
|
||||
|
||||
Usare il template Svelte ufficiale di Vite. I comandi di sviluppo standard sono
|
||||
`dev`, `build` e `preview`.
|
||||
**Ambiente di sviluppo**: Nix flake + Bun. Entrare nell'ambiente con
|
||||
`nix develop`, poi usare i comandi `bun run dev`, `bun run build`,
|
||||
`bun run preview`.
|
||||
|
||||
---
|
||||
|
||||
## Struttura del progetto
|
||||
|
||||
```
|
||||
portale-centraline/
|
||||
download-dati-centraline/
|
||||
├── CLAUDE.md
|
||||
├── package.json
|
||||
├── vite.config.js
|
||||
├── index.html
|
||||
├── svelte.config.js
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── flake.nix
|
||||
├── static/
|
||||
│ └── favicon.svg
|
||||
└── src/
|
||||
├── App.svelte
|
||||
├── app.css # variabili CSS globali, font, stili base
|
||||
├── app.html # template HTML (carica Poppins da Google Fonts)
|
||||
├── app.d.ts
|
||||
├── lib/
|
||||
│ ├── api.js # fetch verso l'API
|
||||
│ ├── timezone.js # conversione UTC → Europe/Rome, iterazione giorni
|
||||
│ └── csv.js # costruzione CSV e download
|
||||
└── components/
|
||||
├── StationList.svelte
|
||||
├── StationRow.svelte
|
||||
└── DownloadProgress.svelte
|
||||
│ ├── types.ts # interfacce Station e Measurement
|
||||
│ ├── api.ts # fetchStations, fetchDay
|
||||
│ ├── timezone.ts # formatTimestampRome, formatDayParam, getDayRange
|
||||
│ ├── csv.ts # buildCsv, downloadBlob, buildDayFilename, buildZipFilename
|
||||
│ ├── download-pool.ts # coda di download con concorrenza limitata
|
||||
│ ├── groups.json # raggruppamento centraline per area geografica
|
||||
│ └── components/
|
||||
│ ├── StationList.svelte
|
||||
│ └── StationRow.svelte
|
||||
└── routes/
|
||||
├── +layout.svelte # importa app.css
|
||||
├── +page.svelte # pagina principale
|
||||
├── +page.server.ts # carica la lista centraline lato server
|
||||
└── api/
|
||||
└── get_station_data/
|
||||
└── +server.ts # proxy verso il backend esterno
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
## API esterna
|
||||
|
||||
**Base URL**: `http://37100lab.it:8101`
|
||||
|
||||
Il frontend non chiama mai direttamente il backend esterno per le misurazioni:
|
||||
usa la route interna `/api/get_station_data` che fa da proxy (vedi sotto).
|
||||
Solo `fetchStations` chiama direttamente il backend (dalla funzione `load`
|
||||
di SvelteKit, lato server).
|
||||
|
||||
### GET `/api/campagne_map_data` — lista centraline
|
||||
|
||||
Risposta: GeoJSON. Le centraline si trovano in `.features[].properties`.
|
||||
@@ -69,8 +88,7 @@ Campi rilevanti di ogni `.properties`:
|
||||
| `created_at` | ISO 8601 UTC | Data/ora di creazione |
|
||||
| `ended_at` | string o null | Data/ora di fine; null se ancora attiva |
|
||||
|
||||
Il campo `pk` va convertito a numero intero. Le centraline vanno ordinate per ID
|
||||
decrescente.
|
||||
Il campo `pk` va convertito a numero intero.
|
||||
|
||||
### GET `/api/get_measurements/{id}?day=YYYY-M-D` — misurazioni giornaliere
|
||||
|
||||
@@ -79,8 +97,9 @@ decrescente.
|
||||
- ✅ `2026-5-6`
|
||||
- ❌ `2026-05-06`
|
||||
|
||||
La risposta contiene una chiave `misure` con un array di oggetti. Ogni oggetto
|
||||
ha:
|
||||
La risposta contiene una chiave `misure` con un array di oggetti, oppure un
|
||||
array vuoto. Il backend può emettere `NaN`/`Infinity` come valori JSON non
|
||||
standard — il proxy li sostituisce con `null` prima di parsare.
|
||||
|
||||
| Campo | Tipo | Descrizione |
|
||||
|--------|---------|-----------------------|
|
||||
@@ -89,135 +108,209 @@ ha:
|
||||
| `pm10` | number | PM10 (µg/m³) |
|
||||
| `time` | ISO UTC | Timestamp misurazione |
|
||||
|
||||
`misure` può essere un array vuoto se il giorno non ha dati.
|
||||
---
|
||||
|
||||
## Route API interna: `/api/get_station_data`
|
||||
|
||||
`src/routes/api/get_station_data/+server.ts`
|
||||
|
||||
Parametri query: `id` (numero) e `date` (formato `YYYY-MM-DD` con zero-padding).
|
||||
|
||||
Comportamento:
|
||||
1. Converte `date` in `YYYY-M-D` senza zero-padding prima di chiamare il backend.
|
||||
2. Sostituisce `NaN`/`Infinity`/`-inf` con `null` nella risposta grezza.
|
||||
3. Filtra le misurazioni con `time` non valido o oggetti `null`.
|
||||
4. Aggiunge header `Cache-Control`: **1 settimana** per giorni precedenti a ieri,
|
||||
**60 secondi** per ieri e oggi. In `dev`, usa `no-store`.
|
||||
|
||||
Il client chiama questa route passando `date` in formato `YYYY-MM-DD`; la route
|
||||
si occupa della conversione al formato del backend.
|
||||
|
||||
---
|
||||
|
||||
## Formato CSV
|
||||
## Formato archivio ZIP
|
||||
|
||||
```
|
||||
"Data","Temperatura","Umidità","PM10"
|
||||
"05/05/2026 22:00:53.7",13.45,89.22,16.3
|
||||
"05/05/2026 22:01:53.9",13.45,89.02,16.1
|
||||
"05/05/2026 22:02:54.1",13.47,88.88,15.5
|
||||
```
|
||||
Il download produce un file `.zip` contenente:
|
||||
|
||||
- **Un CSV per ogni giorno** con misurazioni, senza colonna Data:
|
||||
```
|
||||
"Ora","Temperatura","Umidità","PM10"
|
||||
"22:00:53",13.45,89.22,16.3
|
||||
```
|
||||
Nome file: `YYYY-MM-DD.csv`
|
||||
|
||||
- **Un CSV combinato** con tutte le misurazioni, con colonna Data:
|
||||
```
|
||||
"Data","Ora","Temperatura","Umidità","PM10"
|
||||
"05/05/2026","22:00:53",13.45,89.22,16.3
|
||||
```
|
||||
Nome file: `{NomeCentralina}_(id).csv`
|
||||
|
||||
Nome del file ZIP: `{NomeCentralina}_(id).zip`
|
||||
Esempio: `FabLab_Mantova_(178).zip`
|
||||
Sanitizzazione: spazi → `_`, caratteri non alfanumerici (eccetto `_`) rimossi.
|
||||
|
||||
### Caratteristiche CSV
|
||||
|
||||
| Caratteristica | Valore |
|
||||
|-------------------|--------------------------------------------------------------|
|
||||
| Encoding | **UTF-8 con BOM** (`\uFEFF`) — necessario per Excel italiano |
|
||||
| Encoding | **UTF-8 con BOM** (``) — necessario per Excel italiano |
|
||||
| Separatore | virgola |
|
||||
| Intestazioni | tra doppi apici, prima riga |
|
||||
| Colonna Data | stringa tra doppi apici, formato `DD/MM/YYYY HH:MM:SS.s` |
|
||||
| Colonna Data | stringa tra doppi apici, formato `DD/MM/YYYY` |
|
||||
| Colonna Ora | stringa tra doppi apici, formato `HH:MM:SS` (senza decimali) |
|
||||
| Colonne numeriche | senza apici, valori originali (non arrotondare) |
|
||||
| Timestamp | convertito da UTC a **Europe/Rome** (gestisce ora legale) |
|
||||
|
||||
**Nome file**: `{NomeCentralina}_({id})_{YYYY-MM-DD}.csv` Esempio:
|
||||
`FabLab_Mantova_(178)_2026-05-07.csv` Sostituire spazi con `_`, rimuovere
|
||||
caratteri non alfanumerici eccetto `_`.
|
||||
Libreria di compressione: `jszip` (unica dipendenza runtime).
|
||||
|
||||
---
|
||||
|
||||
## Moduli
|
||||
|
||||
### `lib/api.js`
|
||||
### `lib/types.ts`
|
||||
|
||||
Contiene due funzioni: una per caricare la lista delle centraline e una per
|
||||
caricare le misurazioni di un singolo giorno. La seconda non deve mai lanciare
|
||||
eccezioni — in caso di errore HTTP o di rete restituisce un array vuoto, così da
|
||||
non interrompere il loop di download.
|
||||
Interfacce TypeScript condivise: `Station` e `Measurement`.
|
||||
|
||||
### `lib/timezone.js`
|
||||
### `lib/api.ts`
|
||||
|
||||
Gestisce tutto ciò che riguarda date e timezone. Non usare librerie esterne:
|
||||
`Intl.DateTimeFormat` con `timeZone: 'Europe/Rome'` è sufficiente e gestisce
|
||||
automaticamente il cambio ora legale (CET/CEST).
|
||||
- **`fetchStations(fetch?)`**: carica la lista centraline dal backend esterno.
|
||||
Accetta il `fetch` di SvelteKit per il SSR. Lancia eccezione in caso di
|
||||
errore (gestita da `+page.server.ts`). Ordina per `pk` decrescente.
|
||||
|
||||
Contiene tre funzioni:
|
||||
- **`fetchDay(id, day, signal)`**: scarica le misurazioni di un giorno
|
||||
chiamando la route interna `/api/get_station_data?id=…&date=YYYY-MM-DD`.
|
||||
Accetta un `AbortSignal`; ritenta fino a 3 volte in caso di errore; restituisce
|
||||
sempre `[]` in caso di errore o abort (non lancia mai eccezioni).
|
||||
|
||||
- **`formatTimestampRome(isoString)`**: converte un timestamp ISO UTC nella
|
||||
stringa formattata per il CSV (`DD/MM/YYYY HH:MM:SS.s`). I millisecondi
|
||||
vengono troncati al primo decimale con `Math.floor`, non arrotondati: `706ms →
|
||||
.7`. I millisecondi sono indipendenti dal timezone e si leggono direttamente
|
||||
dall'oggetto `Date`.
|
||||
### `lib/timezone.ts`
|
||||
|
||||
Gestisce date e timezone senza librerie esterne.
|
||||
|
||||
- **`formatTimestampRome(isoString)`**: converte un timestamp ISO UTC nel formato
|
||||
`DD/MM/YYYY HH:MM:SS.s`. Usa un offset cache (per ora UTC) per evitare chiamate
|
||||
ripetute a `Intl.DateTimeFormat`. Il decimale dei secondi è troncato (non
|
||||
arrotondato): `706ms → .7`.
|
||||
|
||||
- **`formatDayParam(date)`**: restituisce la data locale Europe/Rome nel formato
|
||||
`YYYY-M-D` senza zero-padding, da usare come parametro `day` nelle chiamate
|
||||
API. Usare `Intl.DateTimeFormat` con `month: 'numeric'` e `day: 'numeric'` in
|
||||
`formatToParts` — in questo modo i valori non hanno zero-padding.
|
||||
`YYYY-M-D` senza zero-padding, tramite `Intl.DateTimeFormat` con
|
||||
`formatToParts`.
|
||||
|
||||
- **`getDayRange(startIsoUtc, endIsoUtc)`**: restituisce un array di oggetti
|
||||
`Date`, uno per ogni giorno dal giorno di `startIsoUtc` al giorno di
|
||||
`endIsoUtc` (estremi inclusi), calcolati in `Europe/Rome`. Se `endIsoUtc` è
|
||||
`null`, il limite superiore è oggi. Usare **mezzogiorno UTC** (`T12:00:00Z`)
|
||||
come ancora per ogni cursore giornaliero: mezzogiorno UTC corrisponde alle
|
||||
13:00 o 14:00 a Roma, quindi non attraversa mai un cambio di data locale
|
||||
neanche al cambio ora legale.
|
||||
- **`getDayRange(startIsoUtc, endIsoUtc)`**: restituisce un array di `Date`,
|
||||
uno per ogni giorno dal giorno di `startIsoUtc` al giorno di `endIsoUtc`
|
||||
(estremi inclusi) in `Europe/Rome`. Se `endIsoUtc` è `null`, il limite
|
||||
superiore è oggi. Usa **mezzogiorno UTC** (`T12:00:00Z`) come ancora giornaliera
|
||||
per non attraversare mai un cambio di data locale.
|
||||
|
||||
### `lib/csv.js`
|
||||
### `lib/csv.ts`
|
||||
|
||||
Contiene tre funzioni: una per costruire la stringa CSV da un array di
|
||||
misurazioni (con BOM), una per triggerare il download nel browser, e una per
|
||||
costruire il nome del file.
|
||||
- **`buildCsv(measurements, includeDate?)`**: costruisce la stringa CSV con BOM.
|
||||
Se `includeDate` è `true` (default), include la colonna "Data"; altrimenti solo
|
||||
"Ora". L'ora è troncata ai secondi (niente decimali).
|
||||
|
||||
- **`downloadBlob(blob, filename)`**: crea un URL oggetto, simula il click su un
|
||||
link `<a download>`, poi revoca l'URL.
|
||||
|
||||
- **`buildDayFilename(date)`**: restituisce `YYYY-MM-DD.csv` in `Europe/Rome`.
|
||||
|
||||
- **`buildZipFilename(title, id)`**: sanitizza il nome e restituisce
|
||||
`{NomeSanitizzato}_(id).zip`.
|
||||
|
||||
### `lib/download-pool.ts`
|
||||
|
||||
Pool di concorrenza per le richieste HTTP di download. Costante
|
||||
`DOWNLOAD_WORKERS = 3` (richieste parallele massime). La funzione `enqueue(fn)`
|
||||
accoda un'operazione e restituisce una `Promise` che si risolve al termine.
|
||||
Gestisce internamente la coda FIFO e il contatore di worker attivi.
|
||||
|
||||
### `lib/groups.json`
|
||||
|
||||
Array di oggetti `{ name, ids[] }` che mappa nomi di aree geografiche a set di
|
||||
ID centralina. Usato da `StationList` per raggruppare le card. Le centraline non
|
||||
presenti in nessun gruppo finiscono nella sezione "Altre".
|
||||
|
||||
---
|
||||
|
||||
## Componenti
|
||||
|
||||
### `App.svelte`
|
||||
### `routes/+page.server.ts`
|
||||
|
||||
Componente radice. Al montaggio carica la lista delle centraline e gestisce i
|
||||
tre stati: caricamento, errore, lista pronta.
|
||||
Funzione `load` di SvelteKit: chiama `fetchStations` lato server al page load.
|
||||
In caso di errore restituisce `{ stations: [], loadError: '…' }`.
|
||||
|
||||
### `routes/+page.svelte`
|
||||
|
||||
Pagina principale. Riceve `data` da `+page.server.ts`. Mostra il messaggio di
|
||||
errore se presente, altrimenti renderizza `StationList`. Contiene header e footer.
|
||||
|
||||
### `StationList.svelte`
|
||||
|
||||
Riceve l'array di centraline e le mostra in una tabella con colonne: ID, nome,
|
||||
data di creazione (formattata in `Europe/Rome`), stato (attiva/terminata),
|
||||
pulsante download.
|
||||
Raggruppa le centraline in sezioni usando `groups.json`. All'interno di ogni
|
||||
sezione, ordina: attive prima delle terminate, poi per `created_at` decrescente.
|
||||
Le centraline non in nessun gruppo vanno nella sezione "Altre". Sezioni vuote
|
||||
vengono omesse.
|
||||
|
||||
### `StationRow.svelte`
|
||||
|
||||
Gestisce la riga di una singola centralina e contiene tutta la logica di
|
||||
download:
|
||||
Card di una singola centralina. Contiene tutta la logica di download:
|
||||
|
||||
1. Calcola il range di giorni con `getDayRange`. Le centraline **attive**
|
||||
(`ended_at: null`) usano oggi come limite superiore; le centraline
|
||||
**terminate** (`ended_at` valorizzato) usano `ended_at` come limite
|
||||
superiore.
|
||||
2. Itera i giorni **sequenzialmente** (non in parallelo) per non sovraccaricare
|
||||
l'API.
|
||||
3. Per ogni giorno chiama `fetchDay` e accumula le misurazioni.
|
||||
4. Aggiorna il contatore di progresso ad ogni giorno completato.
|
||||
5. Supporta la cancellazione: controlla un flag `cancelled` ad ogni iterazione;
|
||||
se attivo, interrompe il loop senza scaricare nulla.
|
||||
6. Al termine, se ci sono misurazioni e il download non è stato annullato,
|
||||
genera e scarica il CSV.
|
||||
1. Calcola il range di giorni con `getDayRange` (limite superiore: `ended_at` o
|
||||
oggi per le attive).
|
||||
2. Scarica tutti i giorni **in parallelo** tramite `enqueue` dal pool
|
||||
(max 3 richieste simultanee) con `Promise.all`.
|
||||
3. La cancellazione avviene tramite `AbortController`: `controller.abort()`
|
||||
interrompe le fetch in corso; `fetchDay` intercetta `AbortError` e ritorna `[]`.
|
||||
4. Tiene traccia del progresso con i contatori `currentDay` / `totalDays`
|
||||
(incrementati al completamento di ogni giorno).
|
||||
5. Al completamento di tutte le fetch (non cancellate), genera lo ZIP con `jszip`
|
||||
e lo scarica.
|
||||
6. Il pulsante di download mostra uno spinner durante il fetch; al hover diventa
|
||||
un'icona ✕ per annullare. Al completamento mostra un segno di spunta verde per
|
||||
2 secondi.
|
||||
7. La barra di progresso (3px, fondo card) si colora di verde al completamento e
|
||||
di rosso se annullata.
|
||||
8. In `onDestroy`, chiama `controller?.abort()` per pulire fetch pendenti se il
|
||||
componente viene smontato.
|
||||
|
||||
### `DownloadProgress.svelte`
|
||||
|
||||
Mostra il progresso durante il download: giorno corrente, totale giorni e
|
||||
percentuale. Ha un pulsante "Annulla" che emette un evento verso `StationRow`.
|
||||
Il tooltip sulla data di fine centralina è implementato come Svelte action
|
||||
(`floatingTooltip`) che crea/rimuove un `div` nel `document.body` — necessario
|
||||
perché la card ha `overflow: hidden` e non può contenere il tooltip.
|
||||
|
||||
---
|
||||
|
||||
## Edge case e comportamenti attesi
|
||||
|
||||
| Situazione | Comportamento |
|
||||
|-----------------------------------------------|-----------------------------------------------------------------------|
|
||||
| Giorno senza misurazioni (`misure: []`) | Ignorare, continuare con il giorno successivo |
|
||||
| Errore HTTP su un singolo giorno | `fetchDay` restituisce `[]`, ignorare e continuare |
|
||||
| Download annullato dall'utente | Il loop si interrompe al controllo del flag, nessun file scaricato |
|
||||
| Nessuna misurazione dopo tutti i giorni | Non generare il CSV, nessun download |
|
||||
| Centralina attiva (`ended_at: null`) | Range: da `created_at` a **oggi** in Europe/Rome |
|
||||
| Centralina terminata (`ended_at` valorizzato) | Range: da `created_at` a **`ended_at`** in Europe/Rome |
|
||||
| Nome centralina con caratteri speciali | Sanitizzare per il nome file: spazi → `_`, resto rimosso |
|
||||
| Errore nel caricamento della lista | `App.svelte` mostra un messaggio di errore all'utente |
|
||||
| Situazione | Comportamento |
|
||||
|-----------------------------------------------|----------------------------------------------------------------------------|
|
||||
| Giorno senza misurazioni (`misure: []`) | Ignorare, il giorno non contribuisce allo ZIP |
|
||||
| Errore HTTP su un singolo giorno | `fetchDay` restituisce `[]` dopo 3 tentativi, ignorare e continuare |
|
||||
| Download annullato dall'utente | `AbortController` interrompe le fetch, nessun file scaricato |
|
||||
| Nessuna misurazione dopo tutti i giorni | Non generare lo ZIP, nessun download |
|
||||
| Centralina attiva (`ended_at: null`) | Range: da `created_at` a **oggi** in Europe/Rome |
|
||||
| Centralina terminata (`ended_at` valorizzato) | Range: da `created_at` a **`ended_at`** in Europe/Rome |
|
||||
| Nome centralina con caratteri speciali | Sanitizzare per il nome file: spazi → `_`, resto rimosso |
|
||||
| Errore nel caricamento della lista | `+page.svelte` mostra un messaggio di errore |
|
||||
| Backend emette `NaN`/`Infinity` | Il proxy li sostituisce con `null` con regex prima del parse |
|
||||
| Timestamp non valido nella risposta | `+server.ts` filtra le misurazioni con `time` non parsabile |
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
Il portale riguarda dati ambientali e scientifici raccolti sul campo da stazioni
|
||||
fisiche. L'estetica deve riflettere questo contesto: strumentale, precisa,
|
||||
leggibile. Scegliere una direzione stilistica netta — non generica. Evitare
|
||||
palette pastello, font di sistema e layout cookie-cutter. Curare tipografia,
|
||||
spaziatura e la gerarchia visiva della tabella.
|
||||
Font: **Poppins** (Google Fonts, pesi 400/500/600/700).
|
||||
|
||||
Palette (variabili CSS in `app.css`):
|
||||
|
||||
| Variabile | Valore | Uso |
|
||||
|---------------|-----------|----------------------------------|
|
||||
| `--bg` | `#f0f2f5` | sfondo pagina |
|
||||
| `--surface` | `#ffffff` | sfondo card |
|
||||
| `--border` | `#dde1e7` | bordi |
|
||||
| `--text` | `#374151` | testo normale |
|
||||
| `--text-dim` | `#9ca3af` | testo secondario |
|
||||
| `--text-hi` | `#111827` | testo in evidenza |
|
||||
| `--accent` | `#2563eb` | blu (link, attivo, progresso) |
|
||||
|
||||
Il layout usa card arrotondate (`border-radius: 14px`) al posto di una tabella.
|
||||
Le centraline attive sono in evidenza rispetto alle terminate. Il nome è un link
|
||||
alla pagina della centralina sul backend (`/campagna/{pk}`).
|
||||
|
||||
@@ -13,3 +13,39 @@
|
||||
> [37100Lab](http://37100lab.it:8101/) in CSV
|
||||
|
||||
✨ Vibe codato al 100% con Claude — perché la vita è troppo breve.
|
||||
|
||||
## Avvio
|
||||
|
||||
```sh
|
||||
git clone https://git.nicolabelluti.me/nicolabelluti/download-dati-centraline.git
|
||||
cd download-dati-centraline
|
||||
nix develop --command $SHELL
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
bun run preview
|
||||
```
|
||||
|
||||
## Gruppi di centraline
|
||||
|
||||
Le centraline vengono raggruppate visivamente tramite il file
|
||||
`src/lib/groups.json`. La struttura è un array di gruppi:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"nome": "Mantova",
|
||||
"ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Ogni gruppo ha un nome e una lista di ID numerici delle centraline. Le
|
||||
centraline non presenti in nessun gruppo vengono mostrate automaticamente nella
|
||||
sezione "Altre". L'ordine dei gruppi nel file determina l'ordine di
|
||||
visualizzazione sulla pagina.
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "download-dati-centraline",
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@types/node": "^25.6.2",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.128.0", "", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.18", "", { "os": "android", "cpu": "arm64" }, "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm" }, "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "s390x" }, "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "x64" }, "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.18", "", { "os": "linux", "cpu": "x64" }, "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.18", "", { "os": "none", "cpu": "arm64" }, "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.18", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.18", "", { "os": "win32", "cpu": "x64" }, "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
|
||||
|
||||
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.59.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.1.2", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devalue": ["devalue@5.8.0", "", {}, "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esrap": ["esrap@2.2.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.18", "", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.4.8", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@8.0.11", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
}
|
||||
}
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777954456,
|
||||
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
description = "Portale centraline 37100Lab — frontend Svelte/Vite";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in {
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.bun
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "download-dati-centraline",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@types/node": "^25.6.2",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1"
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f0f2f5;
|
||||
--surface: #ffffff;
|
||||
--border: #dde1e7;
|
||||
|
||||
--text: #374151;
|
||||
--text-dim:#9ca3af;
|
||||
--text-hi: #111827;
|
||||
|
||||
--accent: #2563eb;
|
||||
|
||||
--sans: 'Poppins', system-ui, sans-serif;
|
||||
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
.tooltip-floating {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
padding: 0.45em 0.9em;
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tooltip-floating::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Centraline 37100Lab</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Station, Measurement } from './types.js';
|
||||
|
||||
export async function fetchStations(fetch = globalThis.fetch): Promise<Station[]> {
|
||||
const res = await fetch('http://37100lab.it:8101/api/campagne_map_data');
|
||||
if (!res.ok) throw new Error(`Errore HTTP ${res.status}`);
|
||||
const geojson = await res.json();
|
||||
return (geojson.features as Array<{ properties: Record<string, unknown> }>)
|
||||
.map(f => ({
|
||||
pk: parseInt(f.properties.pk as string, 10),
|
||||
title: f.properties.title as string,
|
||||
created_at: f.properties.created_at as string,
|
||||
ended_at: f.properties.ended_at as string | null,
|
||||
}))
|
||||
.sort((a, b) => b.pk - a.pk);
|
||||
}
|
||||
|
||||
export async function fetchDay(id: number, day: string, signal: AbortSignal): Promise<Measurement[]> {
|
||||
const [y, m, d] = day.split('-');
|
||||
const date = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`/api/get_station_data?id=${id}&date=${date}`, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json() as Measurement[];
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') return [];
|
||||
if (attempt === 2) return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import StationRow from './StationRow.svelte';
|
||||
import groups from '$lib/groups.json';
|
||||
import type { Station } from '$lib/types.js';
|
||||
|
||||
let { stations = [] }: { stations: Station[] } = $props();
|
||||
|
||||
// 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));
|
||||
|
||||
// Le attive appaiono prima delle terminate per dare prominenza alle stazioni in funzione.
|
||||
function sortStations(items: Station[]): Station[] {
|
||||
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;
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
const sections = $derived([
|
||||
...groups.map(g => ({
|
||||
name: g.name,
|
||||
stations: sortStations(stations.filter(s => g.ids.includes(s.pk))),
|
||||
})).filter(s => s.stations.length > 0),
|
||||
{
|
||||
name: 'Altre',
|
||||
stations: sortStations(stations.filter(s => !groupedIds.has(s.pk))),
|
||||
},
|
||||
].filter(s => s.stations.length > 0));
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
{#each sections as section}
|
||||
<section>
|
||||
<h2>{section.name}</h2>
|
||||
<div class="list">
|
||||
{#each section.stations as station (station.pk)}
|
||||
<StationRow {station} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rem;
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getDayRange, formatDayParam } from '$lib/timezone.js';
|
||||
import { fetchDay } from '$lib/api.js';
|
||||
import { buildCsv, downloadBlob, buildDayFilename, buildZipFilename } from '$lib/csv.js';
|
||||
import { enqueue } from '$lib/download-pool.js';
|
||||
import type { Station, Measurement } from '$lib/types.js';
|
||||
|
||||
function floatingTooltip(node: HTMLElement, text: string) {
|
||||
let el: HTMLDivElement | null = null;
|
||||
|
||||
function show() {
|
||||
if (!text) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
el = document.createElement('div');
|
||||
el.className = 'tooltip-floating';
|
||||
el.textContent = text;
|
||||
document.body.appendChild(el);
|
||||
el.style.top = `${rect.top - el.offsetHeight - 10}px`;
|
||||
el.style.left = `${rect.left + rect.width / 2 - el.offsetWidth / 2}px`;
|
||||
void el.offsetHeight; // force reflow prima della transizione
|
||||
el.style.opacity = '1';
|
||||
el.style.transform = 'translateY(0)';
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!el) return;
|
||||
const target = el;
|
||||
el = null;
|
||||
target.style.opacity = '0';
|
||||
target.style.transform = 'translateY(4px)';
|
||||
target.addEventListener('transitionend', () => target.remove(), { once: true });
|
||||
}
|
||||
|
||||
node.addEventListener('mouseenter', show);
|
||||
node.addEventListener('mouseleave', hide);
|
||||
return {
|
||||
update(t: string) { text = t; },
|
||||
destroy() {
|
||||
node.removeEventListener('mouseenter', show);
|
||||
node.removeEventListener('mouseleave', hide);
|
||||
hide();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let { station }: { station: Station } = $props();
|
||||
|
||||
const formatDate = (d: string) => new Intl.DateTimeFormat('it-IT', {
|
||||
timeZone: 'Europe/Rome',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(new Date(d));
|
||||
|
||||
const isActive = $derived(station.ended_at === null);
|
||||
const createdDate = $derived(formatDate(station.created_at));
|
||||
const endDate = $derived(isActive ? null : formatDate(station.ended_at!));
|
||||
|
||||
let downloading = $state(false);
|
||||
let cancelling = $state(false);
|
||||
let completed = $state(false);
|
||||
let controller: AbortController | null = null;
|
||||
let currentDay = $state(0);
|
||||
let totalDays = $state(0);
|
||||
|
||||
let pct = $derived(totalDays > 0 ? (currentDay / totalDays) * 100 : 0);
|
||||
|
||||
async function startDownload() {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
downloading = true;
|
||||
|
||||
const days = getDayRange(station.created_at, station.ended_at);
|
||||
totalDays = days.length;
|
||||
currentDay = 0;
|
||||
|
||||
const resultsByDay: Measurement[][] = 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) {
|
||||
currentDay = totalDays;
|
||||
await new Promise(r => setTimeout(r, 900));
|
||||
|
||||
if (!signal.aborted) {
|
||||
const zip = new JSZip();
|
||||
let allMeasurements: Measurement[] = [];
|
||||
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const measurements = resultsByDay[i];
|
||||
if (!measurements?.length) continue;
|
||||
zip.file(buildDayFilename(days[i]), buildCsv(measurements, false));
|
||||
allMeasurements = allMeasurements.concat(measurements);
|
||||
}
|
||||
|
||||
if (allMeasurements.length > 0) {
|
||||
zip.file(buildZipFilename(station.title, station.pk).replace('.zip', '.csv'), buildCsv(allMeasurements));
|
||||
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
|
||||
downloadBlob(blob, buildZipFilename(station.title, station.pk));
|
||||
completed = true;
|
||||
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>
|
||||
|
||||
<div class="card">
|
||||
<div class="left">
|
||||
<a class="name" href="http://37100lab.it:8101/campagna/{station.pk}" target="_blank" rel="noreferrer">{station.title}</a>
|
||||
<span class="id">#{station.pk}</span>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<span class="date">Attivata il {createdDate}</span>
|
||||
<span class="sep">·</span>
|
||||
<span
|
||||
class="status"
|
||||
class:active={isActive}
|
||||
class:ended={!isActive}
|
||||
use:floatingTooltip={!isActive ? `Terminata il ${endDate}` : ''}
|
||||
>{isActive ? 'Attiva' : 'Terminata'}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={downloading ? 'Annulla' : 'Scarica CSV'}
|
||||
onclick={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>
|
||||
</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>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.4rem 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.75rem;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #eff6ff;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
button.downloading {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
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;
|
||||
align-items: baseline;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-hi);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sep {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: relative;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ended {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.bar-wrap {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Measurement } from './types.js';
|
||||
import { formatTimestampRome, ROME } from './timezone.js';
|
||||
|
||||
export function buildCsv(measurements: Measurement[], includeDate = true): string {
|
||||
const header = includeDate
|
||||
? '"Data","Ora","Temperatura","Umidità","PM10"'
|
||||
: '"Ora","Temperatura","Umidità","PM10"';
|
||||
const rows = measurements.map(m => {
|
||||
const [date, time] = formatTimestampRome(m.time).split(' ');
|
||||
const t = time.split('.')[0];
|
||||
return includeDate ? `"${date}","${t}",${m.temp},${m.umid},${m.pm10}` : `"${t}",${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 downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function buildDayFilename(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone: ROME }).format(date) + '.csv';
|
||||
}
|
||||
|
||||
export function buildZipFilename(title: string, id: number): string {
|
||||
const sanitized = title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '');
|
||||
return `${sanitized}_(${id}).zip`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const DOWNLOAD_WORKERS = 3;
|
||||
|
||||
type Job = { fn: () => Promise<void>; resolve: () => void };
|
||||
|
||||
let active = 0;
|
||||
const queue: Job[] = [];
|
||||
|
||||
function next(): void {
|
||||
while (active < DOWNLOAD_WORKERS && queue.length) {
|
||||
active++;
|
||||
const { fn, resolve } = queue.shift()!;
|
||||
Promise.resolve(fn()).then(resolve).finally(() => {
|
||||
active--;
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function enqueue(fn: () => Promise<void>): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
queue.push({ fn, resolve });
|
||||
next();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"name": "Mantova",
|
||||
"ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,68 @@
|
||||
export const ROME = 'Europe/Rome';
|
||||
|
||||
const PAD2 = Array.from({ length: 100 }, (_, i) => String(i).padStart(2, '0'));
|
||||
|
||||
const offsetCache = new Map<string, number>();
|
||||
|
||||
function getRomeOffsetMs(date: Date): number {
|
||||
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();
|
||||
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: string): string {
|
||||
const d = new Date(isoString);
|
||||
const frac = Math.floor(d.getMilliseconds() / 100);
|
||||
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: Date): string {
|
||||
const parts = new Intl.DateTimeFormat('it-IT', {
|
||||
timeZone: ROME,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).formatToParts(date);
|
||||
|
||||
const get = (type: string) => parts.find(p => p.type === type)!.value;
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
}
|
||||
|
||||
export function getDayRange(startIsoUtc: string, endIsoUtc: string | null): Date[] {
|
||||
const toRomeDateString = (date: 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: Date[] = [];
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface Station {
|
||||
pk: number;
|
||||
title: string;
|
||||
created_at: string;
|
||||
ended_at: string | null;
|
||||
}
|
||||
|
||||
export interface Measurement {
|
||||
temp: number;
|
||||
umid: number;
|
||||
pm10: number;
|
||||
time: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { fetchStations } from '$lib/api.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
try {
|
||||
return { stations: await fetchStations(fetch) };
|
||||
} catch {
|
||||
return { stations: [], loadError: 'Impossibile caricare le centraline. Controlla la connessione e riprova.' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import StationList from '$lib/components/StationList.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{#if data.loadError}
|
||||
<p class="status error">{data.loadError}</p>
|
||||
{:else}
|
||||
<StationList stations={data.stations} />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Fatto da <strong><a href="https://nicolabelluti.me" target="_blank" rel="noreferrer">Nicola Belluti</a></strong> (e <a href="https://claude.com/product/claude-code" target="_blank" rel="noreferrer">Claude Code</a>) col ❤️</p>
|
||||
<p class="links">
|
||||
<a href="https://git.nicolabelluti.me/nicolabelluti/download-dati-centraline" target="_blank" rel="noreferrer">Codice sorgente</a>
|
||||
·
|
||||
<a href="https://choosealicense.com/licenses/agpl-3.0/" target="_blank" rel="noreferrer">Licenza GNU AGPLv3.0</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 7rem 0.5rem 0;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 7rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 0 0 5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 3.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-hi);
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: auto;
|
||||
padding: 2rem 0 4rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
footer p {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
footer strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { dev } from '$app/environment';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
|
||||
function cacheControl(date: string): string {
|
||||
if (dev) return 'no-store';
|
||||
const toIso = (d: Date) => new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Rome' }).format(d);
|
||||
const yesterday = toIso(new Date(Date.now() - 86_400_000));
|
||||
return date < yesterday ? 'public, max-age=604800' : 'public, max-age=60';
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const id = url.searchParams.get('id');
|
||||
const date = url.searchParams.get('date'); // YYYY-MM-DD
|
||||
|
||||
if (!id || !date) {
|
||||
return new Response('[]', { status: 400, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
// Il backend vuole YYYY-M-D senza zero-padding
|
||||
const [y, m, d] = date.split('-');
|
||||
const day = `${parseInt(y)}-${parseInt(m)}-${parseInt(d)}`;
|
||||
|
||||
const res = await fetch(`http://37100lab.it:8101/api/get_measurements/${id}?day=${day}`);
|
||||
|
||||
if (!res.ok) {
|
||||
return new Response('[]', { status: res.status, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
// Il backend Python emette NaN/Infinity per valori mancanti o fuori range,
|
||||
// che non sono JSON valido e farebbero esplodere JSON.parse.
|
||||
const sanitized = text.replace(/(?<=[:\s\[,])(?:nan|inf|-inf|infinity|-infinity)(?=[,\]\s}])/gi, 'null');
|
||||
type RawMeasurement = { temp: number; umid: number; pm10: number; time: string };
|
||||
const misure = ((JSON.parse(sanitized).misure ?? []) as RawMeasurement[])
|
||||
.filter(m => m != null && Number.isFinite(new Date(m.time).getTime()));
|
||||
|
||||
return new Response(JSON.stringify(misure), {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'cache-control': cacheControl(date),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve">
|
||||
<g>
|
||||
<rect x="4" y="4" style="fill:#FFFFFF;" width="120" height="120"/>
|
||||
<g>
|
||||
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="24.35" x2="5.57" y2="24.35"/>
|
||||
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="43.67" x2="5.47" y2="43.67"/>
|
||||
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="62.99" x2="5.36" y2="62.99"/>
|
||||
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="82.31" x2="5.26" y2="82.31"/>
|
||||
<line style="fill:none;stroke:#B0BEC5;stroke-width:2;stroke-miterlimit:10;" x1="124" y1="101.64" x2="5.15" y2="101.64"/>
|
||||
</g>
|
||||
<path style="fill:#9CCC65;" d="M38.72,121.91H21.89V55.38c0-1.36,1.1-2.46,2.46-2.46h11.91c1.36,0,2.46,1.1,2.46,2.46V121.91z"/>
|
||||
<path style="fill:#F44336;" d="M72.42,121.91H55.58V74.84c0-1.36,1.1-2.46,2.46-2.46h11.91c1.36,0,2.46,1.1,2.46,2.46V121.91z"/>
|
||||
<path style="fill:#0091EA;" d="M103.64,121.91H91.73c-1.36,0-2.46-1.1-2.46-2.46V25.86c0-1.36,1.1-2.46,2.46-2.46h11.91 c1.36,0,2.46,1.1,2.46,2.46v93.59C106.1,120.81,105,121.91,103.64,121.91z"/>
|
||||
<polygon style="fill:#B0BEC5;" points="124,120 8,120 8,4 4,4 4,124 124,124 "/>
|
||||
<g>
|
||||
<path style="fill:#B0BEC5;" d="M122,6v116H6V6H122 M124,4H4v120h120V4L124,4z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,13 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
runes: true,
|
||||
},
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
});
|
||||
Reference in New Issue
Block a user