Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Ensure shell scripts always use LF line endings
*.sh text eol=lf

# Dockerfile should also use LF
Dockerfile text eol=lf
3 changes: 1 addition & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ RUN apk update && apk add --no-cache \
build-base \
nodejs-lts \
npm \
hdf5-dev \
netcdf-dev
hdf5-dev

# Set working directory
WORKDIR /app
Expand Down
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@



<!-- ABOUT THE PROJECT -->
## About The Project

NetCDF Explorer (netex) is a web application that allows users to upload and analyze NetCDF3 and NetCDF4 files. It
Expand All @@ -58,23 +57,34 @@ provides a simple interface for viewing file metadata and structure using xarray
Key features:
* Upload and view NetCDF file summaries including dimensions, coordinates, and variables

<p align="right">(<a href="#readme-top">back to top</a>)</p>


<br/>

### Built With

* [![Python][Python-badge]][Python-url]
* [![Flask][Flask-badge]][Flask-url]
* [![Xarray][Xarray-badge]][Xarray-url]
* [![MinIO][MinIO-badge]][MinIO-url]
* [![Tailwind CSS][Tailwind-badge]][Tailwind-url]
* [![Docker][Docker-badge]][Docker-url]

<br/>

### NetCDF Engines

NetCDF Explorer uses [xarray](https://xarray.dev/) to read uploaded files. Since files are streamed from MinIO into memory,
xarray selects a backend engine that supports reading from in-memory buffers. The engine is chosen automatically at runtime
based on the file format:

| File Format | Engine | Notes |
|-------------|--------|-------|
| NetCDF3 (classic) | `scipy` | Handles classic and 64-bit offset NetCDF formats |
| NetCDF4 / HDF5 | `h5netcdf` | Reads HDF5-based NetCDF4 files via `h5py` |

<p align="right">(<a href="#readme-top">back to top</a>)</p>



<!-- GETTING STARTED -->
## Getting Started

To get a local copy up and running, follow these steps.
Expand Down Expand Up @@ -254,13 +264,15 @@ Project Link: [https://github.com/mversaggi/netcdf-explorer](https://github.com/


<!-- MARKDOWN LINKS & IMAGES -->
[Docker-badge]: https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white
[Docker-url]: https://www.docker.com/
[Flask-badge]: https://img.shields.io/badge/Flask-000000?style=for-the-badge&logo=flask&logoColor=white
[Flask-url]: https://flask.palletsprojects.com/
[Python-badge]: https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white
[Python-url]: https://www.python.org/
[MinIO-badge]: https://img.shields.io/badge/MinIO-C72E49?style=for-the-badge&logo=minio&logoColor=white
[MinIO-url]: https://min.io/
[Python-badge]: https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white
[Python-url]: https://www.python.org/
[Tailwind-badge]: https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white
[Tailwind-url]: https://tailwindcss.com/
[Docker-badge]: https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white
[Docker-url]: https://www.docker.com/
[Xarray-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pydata/xarray/refs/heads/main/doc/badge.json&style=for-the-badge
[Xarray-url]: https://xarray.dev/
5 changes: 5 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ services:
depends_on:
minio:
condition: service_healthy
# TODO: Remove prior to release
# Bind mounts for local development (change source without rebuilding)
# volumes:
# - ./src:/app/src
# - ./templates:/app/templates
networks:
- netcdf-network
restart: unless-stopped
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ dependencies = [
"black==25.12.0",
"coverage==7.9.1",
"flask==3.1.0",
"h5netcdf==1.8.1",
"h5py==3.13.0",
"humanize==4.12.3",
"minio==7.2.20",
"netcdf4==1.7.2",
"path==17.1.1",
"pytest==8.4.2",
"pytest-env==1.2.0",
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Production Dependencies
flask==3.1.0
humanize==4.12.3
h5netcdf==1.8.1
h5py==3.13.0
minio==7.2.20
netCDF4==1.7.2
path==17.1.1
scipy==1.15.3
toml==0.10.2
Expand Down
10 changes: 9 additions & 1 deletion src/netex/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
request,
url_for,
)
from minio import Minio
from minio.error import S3Error
from xarray import Dataset

from netex.app import NETCDF_BUCKET_NAME

Expand Down Expand Up @@ -59,7 +61,8 @@ def summary(netcdf_filename):
# Retrieve file from object store
response = None
try:
response = current_app.object_store_client.get_object(
object_store_client: Minio = current_app.object_store_client
response = object_store_client.get_object(
bucket_name=NETCDF_BUCKET_NAME,
object_name=netcdf_filename,
)
Expand Down Expand Up @@ -87,3 +90,8 @@ def summary(netcdf_filename):
file_size=humanize.naturalsize(file_size),
summary=summary_html,
)


@app_blueprint.get("/map")
def map_view():
return render_template("map.html.jinja", heatmap_data=None)
196 changes: 196 additions & 0 deletions templates/map.html.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html>
<head>
<title>NetCDF Explorer</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon"
href="{{ url_for('static', filename='blue-block_100x100.png') }}"
type="image/png">
<!-- MapLibre GL JS for basemap (3.x compatible with deck.gl 9.x) -->
<script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet" />
<!-- deck.gl standalone bundle -->
<script src="https://unpkg.com/deck.gl@9.0.16/dist.min.js"></script>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
}
#map-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#back-link {
position: absolute;
top: 16px;
left: 16px;
z-index: 1000;
background: rgba(255, 255, 255, 0.9);
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
color: #1e40af;
font-family: serif;
font-size: 14px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
#back-link:hover {
background: rgba(255, 255, 255, 1);
}
</style>
</head>
<body>
<a id="back-link" href="{{ url_for('netex.index') }}">Back to Explorer</a>
<div id="map-container"></div>

<script>
function generateSampleData() {
// Generate sample heatmap points for testing purposes; each point is assigned a random weight.
const points = [];
for (let i = 0; i < 1000; i++) {
points.push({
position: [
-180 + Math.random() * 360, // longitude
-85 + Math.random() * 170 // latitude
],
weight: Math.random()
});
}
return points;
}

// Calculate zoom level to fit bounds in viewport
function getZoomForBounds(minLon, maxLon, minLat, maxLat, padding = 0.1) {
const lonDelta = (maxLon - minLon) * (1 + padding);
const latDelta = (maxLat - minLat) * (1 + padding);

// Approximate zoom calculation based on viewport size
// At zoom 0, the world is ~256px; each zoom doubles the size
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;

// Degrees per pixel at zoom 0
const WORLD_WIDTH = 360;
const WORLD_HEIGHT = 170; // Approximate for web mercator

const zoomX = Math.log2(viewportWidth / 256 * WORLD_WIDTH / Math.max(lonDelta, 0.001));
const zoomY = Math.log2(viewportHeight / 256 * WORLD_HEIGHT / Math.max(latDelta, 0.001));

// Use the smaller zoom to fit both dimensions, clamp to reasonable range
return Math.max(0, Math.min(20, Math.min(zoomX, zoomY) - 1));
}

// Calculate data bounds for initial view
function calculateViewState(data) {
if (data.length === 0) {
return {
longitude: 0,
latitude: 0,
zoom: 1,
pitch: 0,
bearing: 0
};
}

let minLon = Infinity, maxLon = -Infinity;
let minLat = Infinity, maxLat = -Infinity;

data.forEach(point => {
const [lon, lat] = point.position;
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
});

return {
longitude: (minLon + maxLon) / 2,
latitude: (minLat + maxLat) / 2,
zoom: getZoomForBounds(minLon, maxLon, minLat, maxLat),
pitch: 0,
bearing: 0
};
}

// Data passed from backend (or sample data for testing)
const heatmapData = {{ heatmap_data | tojson | safe if heatmap_data else '[]' }};

// Use sample data if no data provided (for template testing)
const data = heatmapData.length > 0 ? heatmapData : generateSampleData();

const INITIAL_VIEW_STATE = calculateViewState(data);

// Create the HeatmapLayer
const heatmapLayer = new deck.HeatmapLayer({
id: 'nc-heatmap-layer',
data: data,
getPosition: d => d.position,
getWeight: d => d.weight,
radiusPixels: 30,
intensity: 1,
threshold: 0.03,
colorRange: [
[255, 255, 178],
[254, 217, 118],
[254, 178, 76],
[253, 141, 60],
[240, 59, 32],
[189, 0, 38]
]
});


// Initialize deck.gl with MapLibre basemap
const deckgl = new deck.DeckGL({
container: 'map-container',
mapStyle: {
version: 8,
sources: {
'arcgis-ocean': {
type: 'raster',
tiles: [
'https://services.arcgisonline.com/arcgis/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}'
],
tileSize: 256
},
'arcgis-ocean-ref': {
type: 'raster',
tiles: [
'https://services.arcgisonline.com/arcgis/rest/services/Ocean/World_Ocean_Reference/MapServer/tile/{z}/{y}/{x}'
],
tileSize: 256
}
},
layers: [
{
id: 'ocean-base',
type: 'raster',
source: 'arcgis-ocean'
},
{
id: 'ocean-labels',
type: 'raster',
source: 'arcgis-ocean-ref'
}
]
},
initialViewState: INITIAL_VIEW_STATE,
controller: {
dragPan: true,
dragRotate: false,
scrollZoom: true,
doubleClickZoom: true,
touchZoom: true,
touchRotate: false,
keyboard: true
},
layers: [heatmapLayer]
});
</script>
</body>
</html>
Loading
Loading