A spatial data archive for Guyana.
Built by Kyle Semple — HPS Geospatial
Guynode is the first open spatial data archive purpose-built for Guyana — a hub for high-quality geographic datasets built for students, researchers, public agencies, businesses, and curious citizens who need reliable maps and data they can reuse with attribution.
Guyana's spatial data has historically been fragmented across government portals, academic repositories, and legacy GIS systems with inconsistent formats, incomplete metadata, and no unified access point. Guynode addresses this directly: every dataset is standardized, attributed to its source organization, validated against a schema at build time, and made available for download in its original format. Where possible, datasets are also previewable inline — as vector map layers, raster overlays, image maps, or PDFs — without requiring GIS software.
The application is a fully static SPA. There is no backend, no authentication layer, no database, and no runtime server. Everything the user sees is derived from static JSON files, served from Cloudflare Pages, and rendered entirely in the browser. This is a deliberate architectural choice — it minimizes operational cost and attack surface while keeping the data itself version-controlled and auditable.
| Concern | Approach |
|---|---|
| Routing | Query-param SPA (?view=CATALOG, ?view=MAP, etc.) — no React Router |
| Data | Static JSON in public/data/ — Zod-validated at build time |
| Hosting | Cloudflare Pages — SPA fallback via public/_redirects |
| Maps | Leaflet via react-leaflet — open source, no API key required |
| Raster | georaster + georaster-layer-for-leaflet — dynamically imported |
| Shapefiles | shpjs — parses zipped shapefiles in-browser from GCS URLs |
| Styling | Tailwind v4 via @tailwindcss/vite — no CDN, class-based dark mode |
| Types | TypeScript strict mode — ViewState union drives all navigation |
| Validation | Zod schemas in scripts/validate-content.ts — runs as prebuild |
| Data context | CatalogContext — single data-loading layer, mounts on app init |
| Error handling | AppErrorBoundary with componentDidCatch — all views covered |
| Security | Custom whitelist HTML sanitizer — no dangerouslySetInnerHTML escape |
Query-param routing was chosen over React Router because Cloudflare
Pages static hosting requires all paths to resolve to index.html. A
single _redirects rule handles this cleanly without hash routing or
server configuration. All routing logic lives in utils/routing.ts,
making it auditable and independently testable.
Static JSON over a CMS keeps the entire application deployable without a backend. Dataset metadata, blog posts, and learn content are version-controlled files. The Zod prebuild step enforces schema correctness before any build reaches production — content errors are caught at commit time, not in the browser.
CatalogContext as a single data-loading layer means every
data-dependent view (Catalog, Map, Locator, Learn) reads from one
shared context rather than making independent fetch calls. This
eliminates redundant network requests and provides a single error
propagation path.
Dynamic imports for GeoRaster reduced the initial MapViewer chunk from ~1,800KB to ~32KB. The raster processing library defers to a separate chunk that only loads when a GeoTIFF dataset is actually opened — the common case (vector GeoJSON layers) pays no cost for it.
Leaflet over MapboxGL avoids API key dependencies and usage-based billing entirely. The open-source Leaflet ecosystem (react-leaflet, georaster plugins, shpjs) covers all rendering requirements — vector layers, raster overlays, and in-browser shapefile parsing — with no third-party account required.
See DECISIONS.md for the full set of architectural decision records.
Prerequisites: Node 22+, npm 10+
git clone https://github.com/slyberia/Guynode_v2.git
cd Guynode_v2
npm install
npm run dev # dev server on :3000npm run build # prebuild (validate + sitemap) → vite build
npm run preview # preview built output locallyCloudflare Pages config:
- Build command:
npm run build - Output directory:
dist public/_redirectscontains the SPA fallback rule:/* /index.html 200
Guynode's data pipeline has three layers: schema validation at build time, static asset delivery via Google Cloud Storage, and an offline conversion pipeline for transforming source shapefiles into browser-renderable GeoJSON.
Every build automatically runs two prebuild scripts before Vite compiles the application:
scripts/validate-content.ts— validatespublic/data/datasets.jsonandpublic/blog/index.jsonagainst Zod schemas. If any entry fails validation (missing required field, invalid enum value, malformed URL), the build fails before any output is produced. This ensures the live site never serves malformed data.scripts/generate-sitemap.js— regeneratespublic/sitemap.xmlandpublic/robots.txtfrom the current dataset and content inventory. The sitemap is always current with the deployed content.
All 53 datasets are defined in public/data/datasets.json. Each entry
includes:
id— unique slug used for routing and asset path resolutiontitle,description,source— display metadata and attributionformat— one ofGeoJSON | Shapefile | GeoTIFF | PDF | Image | Spreadsheet | CSV | API | MixedviewerType— controls which inline viewer renders:leaflet | image | pdf | nonedownloadUrl— direct GCS URL to the downloadable assetgeojsonUrl— GCS URL to the converted GeoJSON file (vector datasets only, populated by the conversion pipeline)ingestionStatus,validationReport— provenance tracking fields populated bybuild-provenance-registry.py
To add a new dataset:
- Add an entry to
public/data/datasets.jsonfollowing the existing schema - Upload the source file to the
guynode-public-assetsGCS bucket underspatial-data/<category>/<format>/ - Set
downloadUrlto the public GCS URL - Run
npm run build— the prebuild step will reject any schema violation before the build proceeds - If the dataset is a shapefile intended for map preview, run the
conversion pipeline (see below) to generate and upload the GeoJSON,
then set
viewerType: "leaflet"
Source datasets are distributed as ESRI Shapefiles. Leaflet cannot render shapefiles natively, so a conversion step is required before any shapefile can be displayed in the map viewer or locator.
scripts/convert-and-upload.py automates the full pipeline:
Prerequisites:
- Python 3.8+
- GDAL (
ogr2ogrmust be on PATH — install viaconda install gdalorbrew install gdal) - Google Cloud SDK (
gsutilmust be on PATH and authenticated viagcloud auth login)
Usage:
# Step 1 — dry run: verify all source files are found, no uploads made
python scripts/convert-and-upload.py \
--assets-dir "/path/to/shapefiles" \
--dry-run
# Step 2 — full run: convert, upload, update datasets.json
python scripts/convert-and-upload.py \
--assets-dir "/path/to/shapefiles" \
--update-json
# Optional flags
--skip-existing # skip datasets where geojsonUrl is already populated
--dataset-id <id> # run for a single dataset onlyWhat the pipeline does per dataset:
- Locates the source
.zipshapefile using the dataset ID mapping inFILENAME_MAP - Unzips and converts to GeoJSON using
ogr2ogr, reprojecting to EPSG:4326 (WGS84) automatically - Uploads the GeoJSON to GCS at
spatial-data/<category>/geojson/<id>.geojson - Sets
Content-Type: application/geo+jsonon the uploaded object - If
--update-jsonis passed, updates the corresponding entry indatasets.json: setsviewerType: "leaflet"and populatesgeojsonUrl - Prints a summary of successful / skipped / failed / missing counts
After running:
git add public/data/datasets.json
git commit -m "data: populate geojsonUrl for converted shapefiles"
git pushscripts/sync-assets.sh syncs the public/data/ directory to the
guynode-public-assets GCS bucket using gsutil rsync. Useful for
keeping the bucket in sync after bulk content changes.
./scripts/sync-assets.sh # dry run — shows what would sync
./scripts/sync-assets.sh --force # executes the syncRequires gsutil installed and authenticated. The -d flag means
files deleted locally will also be deleted from the bucket — review
the dry run output before using --force.
scripts/build-provenance-registry.py fingerprints each dataset asset
with SHA-256, binds source organization and confidence ratings, and
outputs datasets_registry.json. This provides an auditable lineage
record for every file in the archive — which organization produced it,
when it was ingested, and whether the file has changed since ingestion.
- Shapefiles require desktop GIS software — downloadable
.shpfiles cannot be previewed in the browser; use QGIS or ArcGIS to open them - GeoJSON map viewer is pending — the dataset map viewer (rendering GeoJSON layers from GCS) is under development; the locator map (togglable settlement layers) is fully functional
- Blog content is stub data — blog posts are placeholder entries pending editorial content
- GeoTIFF preview is raster-only — GeoTIFF datasets render as raster overlays with no vector attribute inspection
This repository is a clean rebuild of an earlier prototype. The original scaffold (Google AI Studio → Jules → Gemini) accumulated significant technical debt across iterative multi-tool patching sessions — dual Tailwind versions, Leaflet loaded twice, neutralized error boundaries, dead GCS URLs, and type drift from parallel edits across tools with no shared context. A 16-phase structured remediation was executed to bring the codebase to production grade, targeting a GPT audit score improvement from 63/100 to 88+.
Phase 1 — Blockers
Resolved the four go/no-go blockers preventing any deployment:
removed the dual Tailwind CDN/Vite conflict; removed the duplicate
Leaflet CDN load that caused global L conflicts; created the
Cloudflare Pages SPA fallback (_redirects), restored the
AppErrorBoundary with proper componentDidCatch, and added
placeholder OG images; cleaned all stale dev artifacts left over
from AI Studio, Jules, and Gemini sessions.
Phase 2 — Asset pipeline
Audited all URLs in datasets.json and categorized each as live,
dead, or placeholder. Fixed GCS CORS configuration. Wired the map
viewer to confirmed-live GeoJSON URLs. Populated analyses.json and
blog/index.json with stub entries to prevent silent blank views.
Phase 3 — Stability
Full TypeScript audit (tsc --noEmit) — resolved all type errors and
tightened escape hatches introduced by multi-model patching. Added
loading, empty, and error states to all data-dependent views. Verified
build output was clean with no Rollup warnings.
Phase 4 — Data correctness and image preview
Corrected all 33 misconfigured shapefile entries in datasets.json
(wrong viewerType, non-existent geojsonUrl references). Fixed
format classifications across 19 entries. Added source attribution
for all 53 datasets. Expanded the viewerType union to support
image and pdf. Built inline image preview and PDF preview
components for the catalog detail panel.
Phase 5 — Locator page and conversion pipeline
Built the Locator page — a full-screen Leaflet map with a scrollable
layer panel that fetches and parses shapefiles directly from GCS using
shpjs, rendering them as styled GeoJSON overlays with feature
popups. Built convert-and-upload.py — a Python pipeline using
ogr2ogr and gsutil to automate shapefile → GeoJSON conversion
and bucket upload. Added PDF preview support for two historical
dataset entries.
Phase 6 — Learn section
Added a dedicated Learn section (LEARN_INDEX, LEARN_POST view
states) separate from the blog infrastructure. Built the
LearnIndexPage and LearnPostPage components with a Digital Field
Journal design system (Libre Baskerville headings, JetBrains Mono
code blocks). Authored eight structured educational posts covering
GIS fundamentals, software guides, and Guynode-specific tutorials.
Phase 7 — Learn section content and design refinement
Resolved dark mode conflicts in the Digital Field Journal design
system — light-only tokens replaced with proper dark: Tailwind
variants. Fixed markdown rendering issues in LearnPostPage where
per-paragraph card wrapping and raw pipe syntax were appearing instead
of formatted tables.
Phase 8 — Replacement readiness
Closed the gap between deployable MVP and safe domain cutover: added
17 legacy URL redirects (guynode.gy paths → 301s in _redirects),
fixed the LocatorPage Leaflet import (window.L → direct import),
expanded the sitemap to cover blog, learn, and analyses routes, and
cleaned up deployment documentation.
Phase 9 — GCS asset pipeline execution
Ran convert-and-upload.py to convert and upload 32 GeoJSON files
to the GCS bucket. Updated datasets.json with geojsonUrl values.
Verified the map viewer rendered at least three layers successfully.
Phase 10 — Provenance registry
Built scripts/build-provenance-registry.py — a pipeline that
fingerprints each dataset asset with SHA-256, binds source
organization and confidence ratings, and outputs
datasets_registry.json for audit and lineage tracking.
Phase 11 — Visual polish
Produced the branded OG image (1200×630). Audited and retired
orphaned bloom-* and night-* design token drift across component
files. Applied spacing and card consistency pass. Removed the beta
banner.
Phase 12 — MapViewer code-splitting
Reduced the MapViewer chunk from ~1,800KB to ~32KB by dynamically
importing georaster and georaster-layer-for-leaflet only when a
GeoTIFF dataset is opened. The deferred chunk (~1,186KB) loads on
first GeoTIFF access only.
Phase 13 — Dependency hygiene
Ran npm audit fix to resolve 18 known vulnerabilities in the
webpack/chokidar legacy chain. Verified build passed after fixes.
Documented remaining unfixable items.
Phase 14 — Portfolio documentation
Elevated README.md and DECISIONS.md to portfolio-grade artifacts.
Added full architectural decision records, remediation history, and
data pipeline documentation.
Phases 15–16 — Planned
Phase 15: developer hooks (fcakyon TypeScript/lint hooks, PR comment
→ Claude Code trigger automation). Phase 16: multi-agent pipeline
connecting the Antigravity superscalar model and Notion-driven async
architecture to Guynode's data ingestion workflow.
© 2026 Kyle Semple / HPS Geospatial. All rights reserved.