geoloupe 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. geoloupe-0.1.0/.dockerignore +11 -0
  2. geoloupe-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +30 -0
  3. geoloupe-0.1.0/.github/ISSUE_TEMPLATE/config.yml +5 -0
  4. geoloupe-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +15 -0
  5. geoloupe-0.1.0/.github/dependabot.yml +18 -0
  6. geoloupe-0.1.0/.github/pull_request_template.md +10 -0
  7. geoloupe-0.1.0/.github/workflows/ci.yml +82 -0
  8. geoloupe-0.1.0/.github/workflows/release.yml +78 -0
  9. geoloupe-0.1.0/.gitignore +28 -0
  10. geoloupe-0.1.0/.pre-commit-config.yaml +18 -0
  11. geoloupe-0.1.0/ARCHITECTURE.md +279 -0
  12. geoloupe-0.1.0/CHANGELOG.md +37 -0
  13. geoloupe-0.1.0/CITATION.cff +18 -0
  14. geoloupe-0.1.0/CODE_OF_CONDUCT.md +10 -0
  15. geoloupe-0.1.0/CONTRIBUTING.md +111 -0
  16. geoloupe-0.1.0/Dockerfile +18 -0
  17. geoloupe-0.1.0/LICENSE +202 -0
  18. geoloupe-0.1.0/PKG-INFO +282 -0
  19. geoloupe-0.1.0/README.md +214 -0
  20. geoloupe-0.1.0/RELEASING.md +71 -0
  21. geoloupe-0.1.0/ROADMAP.md +144 -0
  22. geoloupe-0.1.0/SECURITY.md +24 -0
  23. geoloupe-0.1.0/deploy/huggingface/Dockerfile +13 -0
  24. geoloupe-0.1.0/deploy/huggingface/README.md +18 -0
  25. geoloupe-0.1.0/docs/benchmarks.md +104 -0
  26. geoloupe-0.1.0/docs/configuration.md +243 -0
  27. geoloupe-0.1.0/docs/data-tiles.md +73 -0
  28. geoloupe-0.1.0/docs/decisions.md +204 -0
  29. geoloupe-0.1.0/docs/research.md +527 -0
  30. geoloupe-0.1.0/examples/README.md +17 -0
  31. geoloupe-0.1.0/examples/api_client.py +78 -0
  32. geoloupe-0.1.0/examples/arco-era5.yaml +14 -0
  33. geoloupe-0.1.0/examples/fetch_ncei_stations.py +65 -0
  34. geoloupe-0.1.0/examples/fetch_public_samples.py +57 -0
  35. geoloupe-0.1.0/examples/hpc/build_overviews.sbatch +45 -0
  36. geoloupe-0.1.0/examples/notebook_quickstart.py +80 -0
  37. geoloupe-0.1.0/examples/public-s3.yaml +20 -0
  38. geoloupe-0.1.0/examples/sentinel2.yaml +44 -0
  39. geoloupe-0.1.0/frontend/.prettierrc.json +1 -0
  40. geoloupe-0.1.0/frontend/e2e/gallery.mjs +118 -0
  41. geoloupe-0.1.0/frontend/e2e/smoke.mjs +162 -0
  42. geoloupe-0.1.0/frontend/e2e/tour.mjs +96 -0
  43. geoloupe-0.1.0/frontend/eslint.config.js +24 -0
  44. geoloupe-0.1.0/frontend/index.html +14 -0
  45. geoloupe-0.1.0/frontend/package-lock.json +3643 -0
  46. geoloupe-0.1.0/frontend/package.json +39 -0
  47. geoloupe-0.1.0/frontend/public/countries-110m.geojson +1 -0
  48. geoloupe-0.1.0/frontend/public/favicon.svg +1 -0
  49. geoloupe-0.1.0/frontend/src/App.tsx +99 -0
  50. geoloupe-0.1.0/frontend/src/api/client.ts +121 -0
  51. geoloupe-0.1.0/frontend/src/api/types.ts +212 -0
  52. geoloupe-0.1.0/frontend/src/components/AddData.tsx +107 -0
  53. geoloupe-0.1.0/frontend/src/components/Colorbar.tsx +154 -0
  54. geoloupe-0.1.0/frontend/src/components/Gallery.tsx +102 -0
  55. geoloupe-0.1.0/frontend/src/components/Icon.tsx +38 -0
  56. geoloupe-0.1.0/frontend/src/components/Inspector.tsx +266 -0
  57. geoloupe-0.1.0/frontend/src/components/LayerPanel.tsx +538 -0
  58. geoloupe-0.1.0/frontend/src/components/MapCanvas.tsx +374 -0
  59. geoloupe-0.1.0/frontend/src/components/MapView.tsx +403 -0
  60. geoloupe-0.1.0/frontend/src/components/StatusBar.tsx +104 -0
  61. geoloupe-0.1.0/frontend/src/components/TimeBar.tsx +148 -0
  62. geoloupe-0.1.0/frontend/src/components/TimeSeriesChart.tsx +141 -0
  63. geoloupe-0.1.0/frontend/src/components/Toolbar.tsx +250 -0
  64. geoloupe-0.1.0/frontend/src/lib/activity.ts +18 -0
  65. geoloupe-0.1.0/frontend/src/lib/basemaps.ts +60 -0
  66. geoloupe-0.1.0/frontend/src/lib/classes.ts +74 -0
  67. geoloupe-0.1.0/frontend/src/lib/colormap.ts +152 -0
  68. geoloupe-0.1.0/frontend/src/lib/datatile.ts +69 -0
  69. geoloupe-0.1.0/frontend/src/lib/exportImage.ts +79 -0
  70. geoloupe-0.1.0/frontend/src/lib/format.ts +30 -0
  71. geoloupe-0.1.0/frontend/src/lib/gallery.ts +57 -0
  72. geoloupe-0.1.0/frontend/src/lib/lib.test.ts +378 -0
  73. geoloupe-0.1.0/frontend/src/lib/mapRegistry.ts +10 -0
  74. geoloupe-0.1.0/frontend/src/lib/mercator.ts +62 -0
  75. geoloupe-0.1.0/frontend/src/lib/points.ts +106 -0
  76. geoloupe-0.1.0/frontend/src/lib/protocol.ts +111 -0
  77. geoloupe-0.1.0/frontend/src/lib/tiles.ts +76 -0
  78. geoloupe-0.1.0/frontend/src/lib/timeline.ts +85 -0
  79. geoloupe-0.1.0/frontend/src/lib/urlState.ts +79 -0
  80. geoloupe-0.1.0/frontend/src/lib/useFetch.ts +32 -0
  81. geoloupe-0.1.0/frontend/src/lib/useLatest.ts +10 -0
  82. geoloupe-0.1.0/frontend/src/lib/vectors.ts +81 -0
  83. geoloupe-0.1.0/frontend/src/lib/view.ts +141 -0
  84. geoloupe-0.1.0/frontend/src/main.tsx +38 -0
  85. geoloupe-0.1.0/frontend/src/state/store.ts +389 -0
  86. geoloupe-0.1.0/frontend/src/styles.css +1171 -0
  87. geoloupe-0.1.0/frontend/src/vite-env.d.ts +1 -0
  88. geoloupe-0.1.0/frontend/tsconfig.json +19 -0
  89. geoloupe-0.1.0/frontend/vite.config.ts +22 -0
  90. geoloupe-0.1.0/pyproject.toml +135 -0
  91. geoloupe-0.1.0/scripts/benchmark.py +92 -0
  92. geoloupe-0.1.0/scripts/build_colormaps.py +99 -0
  93. geoloupe-0.1.0/scripts/ci_local.sh +65 -0
  94. geoloupe-0.1.0/src/geoloupe/__init__.py +24 -0
  95. geoloupe-0.1.0/src/geoloupe/api/__init__.py +0 -0
  96. geoloupe-0.1.0/src/geoloupe/api/app.py +735 -0
  97. geoloupe-0.1.0/src/geoloupe/catalog.py +782 -0
  98. geoloupe-0.1.0/src/geoloupe/cli.py +306 -0
  99. geoloupe-0.1.0/src/geoloupe/colormaps.json +207 -0
  100. geoloupe-0.1.0/src/geoloupe/colormaps.py +79 -0
  101. geoloupe-0.1.0/src/geoloupe/config.py +400 -0
  102. geoloupe-0.1.0/src/geoloupe/demo.py +415 -0
  103. geoloupe-0.1.0/src/geoloupe/engine.py +614 -0
  104. geoloupe-0.1.0/src/geoloupe/gallery.yaml +314 -0
  105. geoloupe-0.1.0/src/geoloupe/grid.py +509 -0
  106. geoloupe-0.1.0/src/geoloupe/overviews.py +457 -0
  107. geoloupe-0.1.0/src/geoloupe/points.py +861 -0
  108. geoloupe-0.1.0/src/geoloupe/query.py +326 -0
  109. geoloupe-0.1.0/src/geoloupe/readers.py +468 -0
  110. geoloupe-0.1.0/src/geoloupe/server.py +131 -0
  111. geoloupe-0.1.0/src/geoloupe/stac.py +189 -0
  112. geoloupe-0.1.0/src/geoloupe/static/assets/index-CFeR8hOI.css +1 -0
  113. geoloupe-0.1.0/src/geoloupe/static/assets/index-Cbm8G5M-.js +825 -0
  114. geoloupe-0.1.0/src/geoloupe/static/assets/maplibre-gl-worker-CMkqA50c.mjs +10 -0
  115. geoloupe-0.1.0/src/geoloupe/static/countries-110m.geojson +1 -0
  116. geoloupe-0.1.0/src/geoloupe/static/favicon.svg +1 -0
  117. geoloupe-0.1.0/src/geoloupe/static/index.html +15 -0
  118. geoloupe-0.1.0/src/geoloupe/styles.py +69 -0
  119. geoloupe-0.1.0/src/geoloupe/tiling.py +104 -0
  120. geoloupe-0.1.0/src/geoloupe/timeaxis.py +256 -0
  121. geoloupe-0.1.0/src/geoloupe/vectors.py +270 -0
  122. geoloupe-0.1.0/tests/__init__.py +0 -0
  123. geoloupe-0.1.0/tests/conftest.py +76 -0
  124. geoloupe-0.1.0/tests/test_api.py +199 -0
  125. geoloupe-0.1.0/tests/test_categorical.py +131 -0
  126. geoloupe-0.1.0/tests/test_curvilinear.py +177 -0
  127. geoloupe-0.1.0/tests/test_engine.py +266 -0
  128. geoloupe-0.1.0/tests/test_gallery.py +241 -0
  129. geoloupe-0.1.0/tests/test_open.py +78 -0
  130. geoloupe-0.1.0/tests/test_overviews_config_readers.py +225 -0
  131. geoloupe-0.1.0/tests/test_points.py +251 -0
  132. geoloupe-0.1.0/tests/test_regressions.py +222 -0
  133. geoloupe-0.1.0/tests/test_s3.py +72 -0
  134. geoloupe-0.1.0/tests/test_satellite.py +177 -0
  135. geoloupe-0.1.0/tests/test_stac.py +140 -0
  136. geoloupe-0.1.0/tests/test_tiling_grid_time.py +196 -0
  137. geoloupe-0.1.0/tests/test_vectors.py +167 -0
  138. geoloupe-0.1.0/uv.lock +3764 -0
@@ -0,0 +1,11 @@
1
+ .git
2
+ .venv
3
+ **/__pycache__
4
+ .mypy_cache
5
+ .ruff_cache
6
+ .pytest_cache
7
+ dist
8
+ demo
9
+ docs/images
10
+ frontend/node_modules
11
+ frontend/dist
@@ -0,0 +1,30 @@
1
+ name: Bug report
2
+ description: Something does not work as described
3
+ labels: [bug]
4
+ body:
5
+ - type: textarea
6
+ id: what
7
+ attributes:
8
+ label: What happened, and what did you expect?
9
+ validations:
10
+ required: true
11
+ - type: textarea
12
+ id: steps
13
+ attributes:
14
+ label: How to reproduce
15
+ description: The command or configuration (YAML), the dataset (a public URL if possible), and the steps in the browser.
16
+ render: shell
17
+ validations:
18
+ required: true
19
+ - type: textarea
20
+ id: logs
21
+ attributes:
22
+ label: Server log or browser console messages
23
+ render: text
24
+ - type: input
25
+ id: version
26
+ attributes:
27
+ label: Versions
28
+ description: "Output of `geoloupe --version`, your Python version and browser"
29
+ validations:
30
+ required: true
@@ -0,0 +1,5 @@
1
+ blank_issues_enabled: true
2
+ contact_links:
3
+ - name: Security problem
4
+ url: https://github.com/salmanmohebi/geoloupe/security/advisories/new
5
+ about: Please report vulnerabilities privately (see SECURITY.md).
@@ -0,0 +1,15 @@
1
+ name: Feature or data support request
2
+ description: A new feature, file format, grid type or dataset to support
3
+ labels: [enhancement]
4
+ body:
5
+ - type: textarea
6
+ id: need
7
+ attributes:
8
+ label: What do you want to do, and why?
9
+ description: Describe the research task. A public example dataset helps a lot.
10
+ validations:
11
+ required: true
12
+ - type: textarea
13
+ id: idea
14
+ attributes:
15
+ label: Ideas for how it could work (optional)
@@ -0,0 +1,18 @@
1
+ # Keep dependencies current with grouped pull requests (one per ecosystem and interval).
2
+ version: 2
3
+ updates:
4
+ - package-ecosystem: github-actions
5
+ directory: /
6
+ schedule: {interval: weekly}
7
+ groups:
8
+ actions: {patterns: ["*"]}
9
+ - package-ecosystem: uv
10
+ directory: /
11
+ schedule: {interval: monthly}
12
+ groups:
13
+ python: {patterns: ["*"]}
14
+ - package-ecosystem: npm
15
+ directory: /frontend
16
+ schedule: {interval: monthly}
17
+ groups:
18
+ web: {patterns: ["*"]}
@@ -0,0 +1,10 @@
1
+ ## What and why
2
+
3
+ <!-- The problem this solves, and how. Link the issue: "Fixes #123". -->
4
+
5
+ ## Checklist
6
+
7
+ - [ ] Tests added or updated (`uv run pytest`, `npm test`)
8
+ - [ ] `scripts/ci_local.sh` passes, or the same checks by hand (ruff, mypy, eslint, build)
9
+ - [ ] Docs updated (README, docs/, CHANGELOG.md under "Unreleased")
10
+ - [ ] For UI changes: a screenshot, and the browser smoke test (`node e2e/smoke.mjs`)
@@ -0,0 +1,82 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ workflow_dispatch: # "Run workflow" button in the Actions tab
7
+
8
+ jobs:
9
+ python:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python: ["3.11", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v6
17
+ - run: uv sync --all-extras --python ${{ matrix.python }}
18
+ - run: uv run ruff check src tests scripts examples
19
+ - run: uv run ruff format --check src tests scripts examples
20
+ - run: uv run mypy
21
+ - run: uv run pytest
22
+
23
+ web:
24
+ runs-on: ubuntu-latest
25
+ defaults:
26
+ run:
27
+ working-directory: frontend
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: actions/setup-node@v4
31
+ with:
32
+ node-version: 24
33
+ cache: npm
34
+ cache-dependency-path: frontend/package-lock.json
35
+ - run: npm ci
36
+ - run: npm run lint
37
+ - run: npm test
38
+ - run: npm run build
39
+
40
+ e2e:
41
+ needs: [python, web]
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ - uses: astral-sh/setup-uv@v6
46
+ - uses: actions/setup-node@v4
47
+ with:
48
+ node-version: 24
49
+ - run: uv sync
50
+ - run: uv run geoloupe demo-data demo --days 200
51
+ - run: uv run geoloupe serve demo/demo.yaml --port 8765 --open-root demo &
52
+ - run: cd frontend && npm ci && npx playwright install --with-deps chromium && npm run build
53
+ - run: timeout 300 bash -c 'until curl -sf http://127.0.0.1:8765/api/health; do sleep 2; done'
54
+ - run: cd frontend && node e2e/smoke.mjs http://127.0.0.1:8765 shots
55
+ - uses: actions/upload-artifact@v4
56
+ if: always()
57
+ with:
58
+ name: screenshots
59
+ path: frontend/shots
60
+
61
+ # The gallery reads public data from AWS, Google Cloud and NOAA: an outage there must not make
62
+ # CI red, so this job only reports.
63
+ gallery:
64
+ needs: [python, web]
65
+ runs-on: ubuntu-latest
66
+ continue-on-error: true
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+ - uses: astral-sh/setup-uv@v6
70
+ - uses: actions/setup-node@v4
71
+ with:
72
+ node-version: 24
73
+ - run: uv sync --all-extras
74
+ - run: uv run geoloupe gallery --port 8766 &
75
+ - run: cd frontend && npm ci && npx playwright install --with-deps chromium
76
+ - run: timeout 180 bash -c 'until curl -sf http://127.0.0.1:8766/api/health; do sleep 2; done'
77
+ - run: cd frontend && node e2e/gallery.mjs http://127.0.0.1:8766 gallery-shots
78
+ - uses: actions/upload-artifact@v4
79
+ if: always()
80
+ with:
81
+ name: gallery-screenshots
82
+ path: frontend/gallery-shots
@@ -0,0 +1,78 @@
1
+ name: Release
2
+ # Push a tag "vX.Y.Z" (equal to __version__ in src/geoloupe/__init__.py) to publish that version on
3
+ # PyPI and create a GitHub release. PyPI accepts the upload through "trusted publishing" (OpenID
4
+ # Connect): no password or token is stored anywhere. See RELEASING.md for the one-time setup.
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+ workflow_dispatch: # build and check only (no upload): a dry run
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v6
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: 24
19
+ cache: npm
20
+ cache-dependency-path: frontend/package-lock.json
21
+ - name: The tag equals the package version
22
+ if: startsWith(github.ref, 'refs/tags/v')
23
+ run: |
24
+ v=$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/geoloupe/__init__.py)
25
+ if [ "v$v" != "$GITHUB_REF_NAME" ]; then
26
+ echo "tag $GITHUB_REF_NAME does not match __version__ $v"; exit 1
27
+ fi
28
+ - name: Build the web client from this commit (shipped inside the wheel)
29
+ run: cd frontend && npm ci && npm run build
30
+ - run: uv build
31
+ - run: uvx twine check dist/*
32
+ - name: Smoke test the wheel in a clean environment
33
+ run: |
34
+ uv venv /tmp/wheel-test
35
+ uv pip install --python /tmp/wheel-test/bin/python dist/*.whl
36
+ /tmp/wheel-test/bin/geoloupe --version
37
+ /tmp/wheel-test/bin/geoloupe gallery --show-config > /dev/null
38
+ cd /tmp && /tmp/wheel-test/bin/geoloupe demo-data demo --days 20
39
+ (/tmp/wheel-test/bin/geoloupe serve demo/demo.yaml --port 8799 &)
40
+ timeout 120 bash -c 'until curl -sf http://127.0.0.1:8799/api/health; do sleep 2; done'
41
+ curl -sf http://127.0.0.1:8799/ > /dev/null
42
+ - uses: actions/upload-artifact@v4
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+
47
+ publish:
48
+ needs: build
49
+ if: startsWith(github.ref, 'refs/tags/v')
50
+ runs-on: ubuntu-latest
51
+ environment:
52
+ name: pypi
53
+ url: https://pypi.org/project/geoloupe/
54
+ permissions:
55
+ id-token: write # trusted publishing
56
+ steps:
57
+ - uses: actions/download-artifact@v4
58
+ with:
59
+ name: dist
60
+ path: dist/
61
+ - uses: pypa/gh-action-pypi-publish@release/v1
62
+
63
+ github-release:
64
+ needs: publish
65
+ runs-on: ubuntu-latest
66
+ permissions:
67
+ contents: write
68
+ steps:
69
+ - uses: actions/download-artifact@v4
70
+ with:
71
+ name: dist
72
+ path: dist/
73
+ - name: Create the GitHub release with the built files
74
+ env:
75
+ GH_TOKEN: ${{ github.token }}
76
+ run: >
77
+ gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY"
78
+ --title "GeoLoupe ${GITHUB_REF_NAME#v}" --generate-notes
@@ -0,0 +1,28 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ build/
10
+ dist/
11
+
12
+ # Web client (the built client in src/geoloupe/static IS committed: the package ships it)
13
+ frontend/node_modules
14
+ frontend/dist
15
+ frontend/test-results/
16
+ frontend/playwright-report/
17
+
18
+ # Data and outputs (regenerable)
19
+ demo/
20
+ samples/
21
+ *.overviews.zarr/
22
+ slurm_logs/
23
+ *.out
24
+
25
+ # Editors / OS
26
+ .DS_Store
27
+ .idea/
28
+ .vscode/
@@ -0,0 +1,18 @@
1
+ # pre-commit install → runs on every commit
2
+ repos:
3
+ - repo: https://github.com/astral-sh/ruff-pre-commit
4
+ rev: v0.14.0
5
+ hooks:
6
+ - id: ruff-check
7
+ args: [--fix]
8
+ - id: ruff-format
9
+ - repo: https://github.com/pre-commit/pre-commit-hooks
10
+ rev: v6.0.0
11
+ hooks:
12
+ - id: end-of-file-fixer
13
+ exclude: ^src/geoloupe/(static|colormaps\.json)
14
+ - id: trailing-whitespace
15
+ exclude: ^src/geoloupe/static/
16
+ - id: check-yaml
17
+ - id: check-added-large-files
18
+ args: [--maxkb=2000]
@@ -0,0 +1,279 @@
1
+ # GeoLoupe architecture
2
+
3
+ This document describes how GeoLoupe works, why it is built this way, and which alternatives were
4
+ considered. The survey of existing tools is in [docs/research.md](docs/research.md); the individual
5
+ decisions are logged in [docs/decisions.md](docs/decisions.md).
6
+
7
+ ## 1. Goals and constraints
8
+
9
+ GeoLoupe should let a researcher open large gridded datasets and explore them like a web map:
10
+ pan, zoom, step through time, read values, compare datasets. The hard requirements are:
11
+
12
+ 1. **Seamless multi-resolution.** A coarse global dataset and a fine regional one (or several) must
13
+ form one map: coarse when zoomed out, fine where it exists when zoomed in, without visible seams
14
+ and without the user switching layers.
15
+ 2. **Never load what is not visible.** A 20 880 × 43 200 × 13 864 grid (a 30″ ≈ 1 km daily climate dataset) cannot be
16
+ read for a zoomed-out view. Every request must have a bounded cost.
17
+ 3. **Scientific honesty.** The user must be able to tell what they see: which dataset, at which
18
+ resolution, aggregated or native. Clicked values must be the stored values.
19
+ 4. **Formats as they are.** Data stays where and how it is (Zarr, NetCDF, GRIB, GeoTIFF, local or
20
+ object storage). Preprocessing is optional and only adds speed.
21
+ 5. **Simple to run** on a laptop, a workstation, or an HPC compute node behind an SSH tunnel or
22
+ JupyterHub proxy.
23
+
24
+ ## 2. Overview
25
+
26
+ ```
27
+ Browser Python server (FastAPI)
28
+ ┌─────────────────────────────────┐ ┌─────────────────────────────────────────────────────┐
29
+ │ React UI (panels, time, charts, │ │ API /api/layers/{id}/tiles/{z}/{x}/{y}.bin .png │
30
+ │ gallery) · state (zustand, URL)│ HTTP │ /preview.png /point /timeseries /stats /catalog │
31
+ │ MapLibre GL JS v6 │◄──────►│ │ │
32
+ │ raster source "geoloupe://" │ float │ TileEngine: per tile and per source │
33
+ │ │ │ tiles │ level choice → read budget → resample → composite │
34
+ │ protocol handler: │ │ │ │
35
+ │ fetch float tile → cache │ │ Catalog: datasets → sources (levels) → layers │
36
+ │ → colormap (CPU) → ImageBitmap│ │ │ │
37
+ │ hover values from the cache │ │ Readers: xarray (zarr, netcdf, grib, geotiff, refs) │
38
+ └─────────────────────────────────┘ └─────────────────────┬───────────────────────────────┘
39
+ │ lazy window reads
40
+ ┌───────────────────────────┴──────────────────────────┐
41
+ │ native datasets (never modified) + external overviews │
42
+ │ local disk, Lustre, S3/GCS/HTTP (fsspec) │
43
+ └──────────────────────────────────────────────────────┘
44
+ ```
45
+
46
+ Four ideas carry the design:
47
+
48
+ | Idea | What it gives |
49
+ |---|---|
50
+ | **Float data tiles, coloured in the browser** | Colormap and range changes are instant (no new requests); hover shows the value of the displayed pixel; difference maps are computed in the browser from two cached tiles. |
51
+ | **Per-tile level choice with a read budget** | Every tile reads at most `max_read_cells` grid cells. The engine picks the coarsest level that is still as fine as a screen pixel; if even that is too expensive, the source is skipped at this zoom and the UI says so. |
52
+ | **Per-pixel compositing of several datasets** | A layer can stack sources of different resolution. Per pixel, the finest source with data wins; coarser sources fill the rest. This is the "Google Maps" behaviour, and no existing server does it across datasets. |
53
+ | **External overviews in the native grid** | Optional pyramids (block means at 2×, 4×, … coarser) stored next to, not inside, the source. Values stay traceable to source cells; one pyramid serves any display projection. |
54
+
55
+ ## 3. Data model
56
+
57
+ ```
58
+ Config (YAML) Catalog (runtime)
59
+ ───────────── ─────────────────────────────────────────────────────────────
60
+ datasets: ──► DatasetEntry (status ok/error, attrs)
61
+ path / levels └─ Source = one variable of one dataset
62
+ overviews ├─ levels: [Level, …] finest first
63
+ styles │ Level = lazy DataArray + Grid + TimeAxis + origin
64
+ ├─ extra_dims (e.g. plev, member)
65
+ layers: ──► Layer = [Source, …] in priority order + style + union of times
66
+ sources (every gridded variable is also its own layer "<dataset>:<variable>")
67
+ ```
68
+
69
+ * **Grid** ([grid.py](src/geoloupe/grid.py)) describes a rectilinear grid: 1-D cell centres for x and
70
+ y and a CRS. It finds the spatial dimensions (CF `axis`, `standard_name`, common names), the CRS (config
71
+ override, CF `grid_mapping` with `crs_wkt`/`spatial_ref` or CF parameters, or lat/lon coordinates),
72
+ and turns coordinates into *fractional cell positions* (regular axes by arithmetic, irregular ones such
73
+ as Gaussian latitudes by binary search). Longitudes wrap: global grids in 0…360 or −180…180 both
74
+ work, and so do regional grids that cross the antimeridian.
75
+ * **TimeAxis** ([timeaxis.py](src/geoloupe/timeaxis.py)) labels times in ISO 8601 and matches
76
+ requests to the nearest time within a tolerance (default: half a step for monthly or coarser data, at
77
+ most 12 h otherwise, so sparsely sampled daily data never show a neighbouring day). It works in each axis'
78
+ own calendar, so cftime calendars (`noleap`, `360_day`) from climate models are supported.
79
+ * **Levels** come from (a) the dataset itself, if it is a multiscale Zarr group, (b) an explicit list
80
+ of copies at other resolutions (`levels:`), or (c) an external overview store (`overviews:`). Each
81
+ level has its own time axis: overviews may exist for only part of the period, and the engine then
82
+ falls back to finer levels for the other times.
83
+
84
+ ### Point observations
85
+
86
+ Point sources ([points.py](src/geoloupe/points.py)) are tables (CSV, Parquet, GeoParquet; long or wide,
87
+ columns detected) or CF timeSeries NetCDF/Zarr. Every variable is a point layer `<source>:<variable>`, and
88
+ any point layer can be drawn over a gridded layer. For a layer and a day, the server returns the
89
+ stations in a compact JSON list, read from Parquet with filter push-down (only that day; hive `year=`
90
+ partitions pruned) and cached per day; time axes of very large tables come from Parquet statistics. The browser draws them as
91
+ circles with the layer's colour scale (a MapLibre `interpolate` expression built from the same lookup
92
+ table), so observation and grid are compared by eye; hover adds the numbers and their difference.
93
+
94
+ ## 4. A tile request, step by step
95
+
96
+ `GET /api/layers/tas/tiles/7/67/45.bin?time=1978-07-15`
97
+
98
+ 1. **Cache.** The engine's LRU cache (bytes-bounded) is keyed by (layer, tile, time, dims). Hits return
99
+ immediately.
100
+ 2. **For each source of the layer, in priority order** (finest first by default):
101
+ 1. *Zoom window and time.* Sources outside their configured zoom window, or without data at this
102
+ time (within the tolerance), are skipped and the reason is recorded.
103
+ 2. *Level choice.* The screen pixel size is computed in the source's CRS units (lat/lon grids:
104
+ degrees in x and y; projected grids: a pyproj transform at the tile centre). The preferred level
105
+ is the **coarsest level whose cells are no larger than a pixel in both directions**, so no detail
106
+ that the screen could show is lost. Levels without the requested time are not considered.
107
+ 3. *Read budget.* The read window (the grid cells the tile covers, clipped to the grid) is computed
108
+ from the cell positions of the tile's pixel edges. If it exceeds `max_read_cells` the next
109
+ coarser level is tried; if none fits, the source is skipped with reason `cost`. A regional
110
+ source is cheap even at zoom 0, because the window is clipped to its extent.
111
+ 4. *Read.* One `isel` per contiguous column run (two runs at the antimeridian), through xarray's
112
+ lazy backend arrays — no dask graph. CF decoding (`scale_factor`, `_FillValue`) gives floats with
113
+ NaN for missing data.
114
+ 5. *Resample to the tile.*
115
+ * **Lat/lon grids (separable fast path).** Web Mercator is separable, so the cells of each output
116
+ row depend only on latitude and the cells of each column only on longitude. Each pixel takes
117
+ the **mean of the cells whose centres lie inside it**, computed exactly for all pixels at once
118
+ with an integral image (summed-area table, float64) of values and valid counts. When a pixel is
119
+ smaller than a cell, it takes the cell under its centre. `min`/`max` and `nearest` are also
120
+ available; `nearest` is the default for categorical variables (`flag_values`, land cover).
121
+ * **Projected grids** (LAEA, rotated pole, UTM, …): pixel centres are transformed with pyproj and
122
+ the containing cell is taken (nearest). Build overviews for proper means at low zoom.
123
+ 6. *Unit transform* (`scale`, `offset` from the layer config).
124
+ 7. *Composite.* Pixels still empty take this source's finite values; the source index is recorded
125
+ per pixel. When no pixel is empty, the remaining sources are not read at all.
126
+ 3. **Encode.** Body = 256 × 256 float32 values, byte-shuffled, followed by 256 × 256 uint8 source
127
+ indices; gzip; headers `X-GeoLoupe-Sources` (source:level:pixels) and `X-GeoLoupe-Skipped`
128
+ (source:reason). An empty tile is HTTP 204 with the same headers. See
129
+ [docs/data-tiles.md](docs/data-tiles.md).
130
+
131
+ In the browser, MapLibre decides which tiles are visible and requests `geoloupe://<spec>/{z}/{x}/{y}`.
132
+ The protocol handler fetches the data tile (from its own cache if possible), colours it with the
133
+ current colour scale and returns an `ImageBitmap`. A "spec" is an immutable description of what to
134
+ draw (layer, time, dims, colour scale, optional subtrahend); every change makes a new spec and a new
135
+ MapLibre source, added on top of the old one, which is removed only after the new tiles have loaded.
136
+ That double buffering keeps time animation and colour changes free of flicker.
137
+
138
+ ### Curvilinear grids
139
+
140
+ A grid with 2-D longitude/latitude keeps its cells as they are: `Grid.locate()` finds the nearest cell
141
+ centre of every pixel with a KD-tree on 3-D unit vectors (no special cases at the date line or the
142
+ poles) and leaves pixels empty beyond ¾ of a cell. The resulting per-pixel (row, col) plan is the same
143
+ as for projected grids, so reading, compositing, queries and exports are shared. Coarser levels are
144
+ views of every 2nd, 4th, … cell, so low zooms stay within the read budget.
145
+
146
+ Small grids (≤ `field_cache_cells`, e.g. ERA5's 1440 × 721 or a 1° ocean model) are read whole
147
+ once per time step and every tile cuts its window from memory. Stores whose chunks hold a whole
148
+ field, or many time steps (CMIP6), would otherwise be decompressed again for every tile.
149
+
150
+ ### Mosaics and STAC
151
+
152
+ A `mosaic:` (a list of files) or a `stac:` search is expanded when the catalog opens into hidden
153
+ member datasets, one per file, opened in parallel. Each variable becomes one composite layer over the
154
+ members, in order, so the first file with data at a pixel wins (for STAC: the best item by
155
+ `sortby`). RGB layers record the channel of each source; the engine composites each channel over its
156
+ sources the same way, so true colour works across many scenes.
157
+
158
+ ### Vector overlays
159
+
160
+ `vectors:` files are read once per server (pyogrio/GDAL, or pyarrow for GeoParquet), reprojected to
161
+ lon/lat, optionally filtered and simplified, and sent as one GeoJSON document
162
+ (`/api/vectors/{id}.geojson`, gzip, cached by the browser). MapLibre tiles and simplifies it per zoom
163
+ on the client (geojson-vt) and draws it between the data and the station circles. Polygons always get
164
+ a fill layer, transparent when no fill colour is set, so that hovering inside a region finds it. This
165
+ suits overlays up to a few hundred thousand features (limit `max_vector_mb`); larger collections need
166
+ vector tiles (roadmap).
167
+
168
+ ### Featured views and previews
169
+
170
+ A configuration can list `views:` (layer, time, place, colour scale, optional comparison). The
171
+ client shows them as a start screen (the gallery) when the URL has no state. A view is exactly the
172
+ state of a shareable link: opening it sets the URL hash, and the normal start-up restores it, so there
173
+ is no second code path. Each card has a preview from `GET /api/layers/{id}/preview.png?center=lon,lat&zoom=z&width&height`:
174
+ a Web Mercator window of any size centred on a point, stitched from the same (cached) tiles as the
175
+ map and coloured once (colormap, RGB stretch or class colours).
176
+
177
+ ## 5. What the user sees, and what it means
178
+
179
+ GeoLoupe shows three kinds of numbers. They are deliberately different:
180
+
181
+ | Where | Value | Resolution |
182
+ |---|---|---|
183
+ | Map pixel and **hover** read-out | The value of the displayed pixel: a mean of the source cells inside the pixel (or an overview level, itself a block mean) | Display resolution; the read-out names the source and level |
184
+ | **Click** (inspector table, time series) | The stored value of the native grid cell under the point, for every source | Native resolution |
185
+ | **Export** (NetCDF) | The native cells inside the box, with the source attributes | Native resolution |
186
+
187
+ The **provenance badge** at the top of the map always lists the sources and levels on screen, with
188
+ their share of pixels and whether they are native or overview data, and says when a source is hidden
189
+ because it is too expensive at this zoom.
190
+
191
+ Known approximations (documented, not hidden):
192
+
193
+ * Block means on lat/lon grids are **not area-weighted**. Across a block of height Δ (radians) the
194
+ cos(latitude) cell areas vary by about tan(φ)·Δ: 0.9 % for a 0.5° block at 45°, 3 % for a 1° block at
195
+ 60°. The effect on the mean is smaller still (it is a weight error, not a value error) and grows toward
196
+ the poles. Overviews use the same unweighted mean; area weighting is on the roadmap.
197
+ * A screen pixel takes the cells whose **centres** fall inside it, not an area-weighted share of
198
+ partly covered cells (that would be conservative remapping). At the edges of fine data this can
199
+ shift a boundary by up to half a cell.
200
+ * **Difference maps** (A − B) subtract two layers sampled on the same screen pixels, each at its own
201
+ best level. They show where A and B differ at the displayed resolution; they are not a regridding.
202
+ * **Overview edges.** When a grid size is not a multiple of the factor, the last overview row/column
203
+ is a block mean of the remaining cells but is placed as if the block were full, up to f/2 native
204
+ cells beyond the grid edge (e.g. 0.67° past 90° S at factor 4 of a 0.25° grid with 522 rows).
205
+ * **Compositing** assumes the sources describe the same quantity in the same units (unit conversion
206
+ is configured per source). It does not blend at the boundary: the fine source simply wins where it
207
+ has data.
208
+
209
+ ## 6. Technology choices and alternatives
210
+
211
+ | Component | Choice | Alternatives considered | Why |
212
+ |---|---|---|---|
213
+ | Data access | **xarray** (lazy, no dask by default) with zarr-python 3, h5netcdf/scipy, cfgrib, rioxarray, kerchunk refs | Format-specific readers; GDAL for everything | xarray is the common model of the Pangeo ecosystem, decodes CF metadata and supports every needed format. Dask graphs add overhead for small window reads, so it is only used for multi-file NetCDF. |
214
+ | Tile engine | **Own engine** on numpy + pyproj (~500 lines) | TiTiler / rio-tiler `XarrayReader`; xpublish-tiles | Neither returns float data tiles with automatic level selection, and neither composites several datasets of different resolution per pixel. rio-tiler warps through GDAL for every tile and has no multiscale choice for xarray. xpublish-tiles (the closest match, with excellent grid coverage) renders PNG on the server. The engine copies their best ideas (cost budget, advertised minimum zoom, separable lat/lon path). |
215
+ | Multi-resolution storage | **External overviews**, block means in the native grid, Zarr v3 with GeoZarr `multiscales` + `proj` + `spatial` v0.1 metadata | Web Mercator pyramids (ndpyramid); on-the-fly coarsening only; overviews inside the source store | Native grid keeps values traceable and serves any projection. External stores work with read-only archives and do not duplicate level 0. The GeoZarr conventions let other tools (xpublish-tiles, zarr-layer, OpenLayers GeoZarr, titiler-eopf) read the same pyramid. On-the-fly coarsening alone cannot serve a 900 M-cell grid at zoom 0. |
216
+ | API | **FastAPI** | Starlette alone; xpublish plugin; Django | Typed endpoints, automatic OpenAPI docs at `/api/docs`, the standard in this ecosystem (TiTiler, xpublish). |
217
+ | Tile format | **float32, byte-shuffled, gzip** + uint8 source map | PNG (server colormap); `.npy` (TiTiler); LERC; RGB-packed floats for MapLibre `raster-dem` | Exact values, NaN for missing data, simple to decode, about 2× better compression than unshuffled floats. PNG would make every colour change a refetch and loses the values. |
218
+ | Map library | **MapLibre GL JS v6** + custom protocol | deck.gl `TileLayer` + custom shader; OpenLayers WebGLTile; CesiumJS; Leaflet | MapLibre handles tiling, LOD, caching, globe projection and vector basemaps with labels above the data. The protocol handler is about 80 lines. OpenLayers' WebGL tiles are the strongest float-raster alternative (GPU colormaps, any CRS) and are the documented fallback. deck.gl adds weight for no gain at this stage; Cesium is for full 3D; Leaflet has no WebGL. |
219
+ | Colouring | **CPU** in the protocol handler (≈0.3 ms per tile) | WebGL2 custom layer; `raster-dem` + `color-relief` | Simple, exact NaN transparency, same LUT as the server. A GPU path is on the roadmap if profiling shows a need. |
220
+ | UI | **React 19 + zustand + uPlot** | Vanilla TS; Svelte; Plotly/ECharts | React is what most contributors know; zustand is a 1 kB store; uPlot draws long daily series (13 000+ points) fast and small. |
221
+ | Colormaps | One **LUT file** (matplotlib, cmocean, Crameri) for server and client | matplotlib at runtime | Identical colours everywhere, no matplotlib dependency at run time. |
222
+
223
+ ## 7. Performance
224
+
225
+ Measured on one compute node (72-core Arm CPU, data on a busy Lustre file system) with a large private
226
+ store — a 30″ ≈ 1 km global daily dataset (20 880 × 43 200 cells × 13 864 days, uint16, sharded Zarr
227
+ v3) over a 0.25° reanalysis — and with public data read over the internet. Full numbers, conditions
228
+ and the script: [docs/benchmarks.md](docs/benchmarks.md).
229
+
230
+ | Request | Time |
231
+ |---|---|
232
+ | Composite tile, zoom 2–4, 1 km data from overviews | 14–33 ms |
233
+ | Composite tile, zoom 4–5, 1 km data native (≤ 6 M cells read, no overviews) | 76–95 ms |
234
+ | Composite tile, zoom ≥ 6, 1 km data native | 6–14 ms |
235
+ | Tile from the server cache | 3–20 ms (incl. gzip of the response) |
236
+ | Point query (native cells of all sources) | 9–10 ms |
237
+ | One year of daily values at a point, two sources | 4.7 s cold, 0.3–0.5 s warm |
238
+ | Remote Sentinel-2 COG tile over HTTPS (internal overview chosen per zoom) | 0.3–0.9 s cold |
239
+ | Remote ARCO-ERA5 tile over HTTPS (1.3 million hourly steps) | 1.0–1.5 s cold |
240
+ | 16 cold Sentinel-2 RGB tiles (3 remote COGs each), in parallel | 2.0 s (13.3 s with a global read lock) |
241
+ | Opening the whole gallery (ERA5 Zarr, 6 DEM + 4 Sentinel-2 + 1 WorldCover COGs, 21 station CSVs) | 5–15 s |
242
+ | Overview build, two variables, one year of 1 km daily data, 32 workers | 18.5 min, 12 GB |
243
+
244
+ Data tiles are 10–100 kB after gzip (a PNG of the same tile: ~30 kB). The main levers are the chunk
245
+ layout (720 × 720 inner chunks make windows cheap; whole-globe chunks make point series read a full
246
+ field per time step) and overviews for low zoom. A fresh process on a busy parallel file system pays
247
+ tens of seconds for imports and first reads, so the server warms up its main layers in a background
248
+ thread at start.
249
+
250
+ ## 8. Deployment
251
+
252
+ * **Local:** `geoloupe serve config.yaml` (uvicorn). The web client is a static bundle inside the
253
+ Python package, so no Node.js is needed to run it.
254
+ * **HPC:** run on a compute node; open through an SSH tunnel, or through jupyter-server-proxy. All
255
+ client URLs are relative, so a path prefix such as `/user/<name>/proxy/8000/` works.
256
+ * **Notebook:** `geoloupe.show({"era5": ds})` starts the server in a background thread and displays
257
+ the map inline (any in-memory or lazily opened xarray Dataset).
258
+ * **Security:** the API is read-only. It serves only datasets named in the configuration; static file
259
+ paths are confined to the bundled client. There is no authentication (bind to `127.0.0.1`, the
260
+ default, or put it behind an authenticating proxy). CORS is open by default for read-only use and is
261
+ configurable.
262
+
263
+ ### Opening data at run time
264
+
265
+ `POST /api/datasets` ("Add data") is off unless the server allows remote URLs (`--allow-open`) or
266
+ local folders (`--open-root`); local paths are resolved (symlinks, `..`) and must stay below a
267
+ root. The new dataset is opened by a small catalog of its own and merged into the running one, so
268
+ data already open is not touched; vector and point files go to their registries.
269
+
270
+ ## 9. Extension points
271
+
272
+ | To add … | Change |
273
+ |---|---|
274
+ | A file format | a branch in [readers.py](src/geoloupe/readers.py) returning an xarray Dataset |
275
+ | A grid type (curvilinear, UGRID, HEALPix) | a new planner in [engine.py](src/geoloupe/engine.py) next to `plan_separable` / `plan_projected` |
276
+ | An aggregation | `resample()` in the engine and `block_reduce()` in [overviews.py](src/geoloupe/overviews.py) |
277
+ | A colormap | an entry in [scripts/build_colormaps.py](scripts/build_colormaps.py), then regenerate |
278
+ | A tile matrix set (polar stereographic, EPSG:4326 tiles) | `tiling.Tile` (MapLibre v6.15 supports custom projections) |
279
+ | An API endpoint | [api/app.py](src/geoloupe/api/app.py) |
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to GeoLoupe. The format follows [Keep a Changelog](https://keepachangelog.com/),
4
+ and versions follow [Semantic Versioning](https://semver.org/) (before 1.0, minor versions may change
5
+ the configuration format; such changes are listed here).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-11-06
10
+
11
+ First public version.
12
+
13
+ ### Data
14
+ - Gridded data from Zarr v2/v3, NetCDF 3/4 (also many files as one), GRIB, GeoTIFF/COG and
15
+ kerchunk references; local, HTTPS, S3 (public buckets need no setup) and GCS.
16
+ - Grids: regular and irregular lat/lon in either longitude convention, projected CRSs, rotated pole,
17
+ and curvilinear grids with 2-D longitude/latitude (ocean models, WRF, CMIP6 native grids).
18
+ - Layers that combine datasets of different resolution per pixel; mosaics of many files; STAC
19
+ searches and items; RGB composites (true and false colour); categorical data with class names
20
+ and colours; fixed-time sources for static fields.
21
+ - Stations and point data (CSV, Parquet, GeoParquet, CF timeSeries NetCDF) as layers and overlays.
22
+ - Vector overlays (GeoJSON, Shapefile, GeoPackage, FlatGeobuf, KML, GML, GeoParquet).
23
+ - Overview pyramids in the native grid (`geoloupe overviews build`, GeoZarr conventions v0.1).
24
+
25
+ ### Web client
26
+ - Float data tiles coloured in the browser: instant colour changes, real values on hover and click,
27
+ a badge that names the data and resolution on screen.
28
+ - Time slider and playback, time series, side-by-side and difference comparison, PNG and NetCDF
29
+ export, shareable links, a gallery of featured views, "Add data" (off unless allowed).
30
+
31
+ ### Tools
32
+ - `geoloupe serve | gallery | info | demo-data | overviews build`; `geoloupe.show()` in notebooks.
33
+ - The gallery: ERA5, Copernicus DEM, Sentinel-2 (STAC), ESA WorldCover, a CMIP6 climate model ocean,
34
+ NOAA stations and Natural Earth overlays, all read live from public archives.
35
+
36
+ [Unreleased]: https://github.com/salmanmohebi/geoloupe/compare/v0.1.0...HEAD
37
+ [0.1.0]: https://github.com/salmanmohebi/geoloupe/releases/tag/v0.1.0
@@ -0,0 +1,18 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use GeoLoupe in your research, please cite it as below."
3
+ title: "GeoLoupe: interactive, multi-resolution exploration of large geodata in the browser"
4
+ type: software
5
+ authors:
6
+ - family-names: Mohebi
7
+ given-names: Salman
8
+ license: Apache-2.0
9
+ repository-code: "https://github.com/salmanmohebi/geoloupe"
10
+ keywords:
11
+ - xarray
12
+ - zarr
13
+ - climate data
14
+ - geospatial
15
+ - visualization
16
+ - web map
17
+ version: 0.1.0
18
+ date-released: "2026-11-06"
@@ -0,0 +1,10 @@
1
+ # Code of conduct
2
+
3
+ GeoLoupe follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
4
+
5
+ In short: be respectful and welcoming, assume good intent, and focus on what is best for the
6
+ community and the science. Harassment and personal attacks are not accepted in issues, pull
7
+ requests, discussions or any other project space.
8
+
9
+ To report unacceptable behaviour, contact the maintainer privately (see the maintainer's GitHub
10
+ profile). Reports are handled confidentially.