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
61 changes: 61 additions & 0 deletions .github/workflows/demo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Demo (GitHub Pages)

on:
push:
branches: [master]
paths: ['demo/**', 'include/**', '.github/workflows/demo.yml']
pull_request:
paths: ['demo/**', 'include/**', '.github/workflows/demo.yml']
workflow_dispatch:

permissions:
contents: read

concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: mymindstorm/setup-emsdk@v16
with:
version: 6.0.11
- uses: actions/setup-node@v7
with:
node-version: 22

- name: Build native reference
run: |
c++ -std=c++17 -Wall -Wextra -pedantic -Iinclude demo/test/native_ref.cpp -o native_ref
./native_ref > expected.json

- name: Build WebAssembly
run: demo/build.sh

- name: Cross-check WASM against native
run: node demo/test/verify.mjs expected.json

- name: Report size
run: ls -l demo/web/geo.wasm demo/web/geo.js

- uses: actions/upload-pages-artifact@v5
if: github.event_name != 'pull_request'
with:
path: demo/web

deploy:
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@ Thumbs.db

# Tool sessions
.claude/

# WebAssembly demo build output (see demo/README.md)
demo/web/geo.js
demo/web/geo.wasm
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

_Stop hand-rolling haversine, polyline, and polygon math yourself._

**[▶ Live demo](https://gistrec.github.io/geo-utils-cpp/)** — the library compiled to WebAssembly, running in your browser.

<!-- Row 1 — identity + trust -->
<p align="center">
<img src="https://img.shields.io/badge/C%2B%2B-17-00599C?logo=cplusplus&logoColor=white" alt="C++17">
Expand Down
29 changes: 29 additions & 0 deletions demo/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
cmake_minimum_required(VERSION 3.16)
project(geo_utils_cpp_demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

set(GEO_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../include")

if(EMSCRIPTEN)
# WebAssembly module: web/geo.js + web/geo.wasm
add_executable(geo bindings.cpp)
target_include_directories(geo PRIVATE "${GEO_INCLUDE_DIR}")
target_compile_options(geo PRIVATE -O3 -Wall -Wextra)
target_link_options(geo PRIVATE
--bind -O3
-sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createGeo
-sALLOW_MEMORY_GROWTH=1
-sENVIRONMENT=web,node
-sASSERTIONS=0)
set_target_properties(geo PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/web"
SUFFIX ".js")
else()
# Native reference used to cross-check the WebAssembly build (test/verify.mjs).
add_executable(native_ref test/native_ref.cpp)
target_include_directories(native_ref PRIVATE "${GEO_INCLUDE_DIR}")
target_compile_options(native_ref PRIVATE -Wall -Wextra -pedantic)
endif()
50 changes: 50 additions & 0 deletions demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# geo-utils-cpp — WebAssembly demo

An interactive map page where every number is computed by the C++ library compiled to
WebAssembly: distance & bearing, offset, geofence (point-in-polygon), polyline
encode/decode (precision 5 and 6), Douglas–Peucker simplify, snap-to-route, and spherical area.
Client-side only: no backend, no API keys, no trackers. Map tiles come from OpenStreetMap;
Leaflet 1.9.4 (BSD-2) is vendored in `web/vendor/leaflet/`, so the only external request is for tiles.

## Layout

| File | Purpose |
|---|---|
| `demo_api.hpp` | Thin C++17 layer over `<geo/geo.hpp>`; paths are flat `[lat, lng, ...]` arrays. No Emscripten dependency. |
| `bindings.cpp` | embind wrapper — one forwarding function per `demo_api.hpp` function. |
| `test/native_ref.cpp` | Native build of the same layer; prints reference values as JSON. |
| `test/verify.mjs` | Loads the WASM module in Node and compares it to the native reference. |
| `web/index.html` | The page (vanilla JS + Leaflet). |

## Build

Needs the [Emscripten SDK](https://emscripten.org/docs/getting_started/downloads.html)
(`emcmake` on `PATH`, e.g. after `source ./emsdk_env.sh`) and CMake ≥ 3.16.

```sh
demo/build.sh # -> demo/web/geo.js + demo/web/geo.wasm
ls -l demo/web/geo.wasm
```

Build flags: `-O3 --bind -sMODULARIZE -sEXPORT_ES6 -sALLOW_MEMORY_GROWTH`.

## Run locally

```sh
cd demo/web && python3 -m http.server 8000 # open http://localhost:8000
```

Browsers refuse to load a `.wasm` module from `file://`, so opening `index.html` directly shows
an explanatory message instead of the demo. Any static server works.

## Verify the WASM build against native

```sh
c++ -std=c++17 -Iinclude demo/test/native_ref.cpp -o native_ref && ./native_ref > expected.json
node demo/test/verify.mjs expected.json
```

## Publish on GitHub Pages

`.github/workflows/demo.yml` builds, verifies and deploys `demo/web` on pushes to `master`.
One-time setup: **Settings → Pages → Build and deployment → Source: GitHub Actions**.
60 changes: 60 additions & 0 deletions demo/bindings.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Emscripten/embind wrapper. Arrays cross the boundary as plain JS arrays of numbers
// (flat [lat, lng, lat, lng, ...]); every function is a thin forward to demo_api.hpp.
#include "demo_api.hpp"

#include <emscripten/bind.h>
#include <emscripten/val.h>

#include <string>
#include <vector>

namespace {

using emscripten::val;

demo::Flat in(const val& array) {
return emscripten::convertJSArrayToNumberVector<double>(array);
}

val out(const demo::Flat& flat) {
val array = val::array();
for (std::size_t i = 0; i < flat.size(); ++i) {
array.set(i, flat[i]);
}
return array;
}

} // namespace

EMSCRIPTEN_BINDINGS(geo_demo) {
using namespace emscripten;

function("version", &demo::version);
function("distanceBetween", &demo::distance_between);
function("heading", &demo::heading);
function("offset", +[](double lat, double lng, double d, double h) { return out(demo::offset(lat, lng, d, h)); });
function("interpolate", +[](double a, double b, double c, double d, double f) {
return out(demo::interpolate(a, b, c, d, f));
});
function("pathLength", +[](const val& path) { return demo::path_length(in(path)); });
function("pointAtDistance", +[](const val& path, double d) { return out(demo::point_at_distance(in(path), d)); });
function("area", +[](const val& path) { return demo::area(in(path)); });
function("contains", +[](double lat, double lng, const val& poly, bool geodesic) {
return demo::contains(lat, lng, in(poly), geodesic);
});
function("onEdge", +[](double lat, double lng, const val& poly, bool geodesic, double tol) {
return demo::on_edge(lat, lng, in(poly), geodesic, tol);
});
function("distanceToEdge", +[](double lat, double lng, const val& poly) {
return demo::distance_to_edge(lat, lng, in(poly));
});
function("encode", +[](const val& path, unsigned precision) { return demo::encode(in(path), precision); });
function("decode", +[](const std::string& s, unsigned precision) { return out(demo::decode(s, precision)); });
function("simplify", +[](const val& path, double tol, bool geodesic) {
return out(demo::simplify(in(path), tol, geodesic));
});
function("closestPointOnPath", +[](double lat, double lng, const val& path) {
return out(demo::closest_point_on_path(lat, lng, in(path)));
});
function("bounds", +[](const val& path) { return out(demo::bounds(in(path))); });
}
14 changes: 14 additions & 0 deletions demo/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Builds web/geo.js + web/geo.wasm. Requires the Emscripten SDK (emcmake on PATH).
set -euo pipefail
cd "$(dirname "$0")"

if ! command -v emcmake >/dev/null 2>&1; then
echo "emcmake not found. Install and activate the Emscripten SDK first:" >&2
echo " https://emscripten.org/docs/getting_started/downloads.html (source ./emsdk_env.sh)" >&2
exit 1
fi

emcmake cmake -S . -B build-wasm -DCMAKE_BUILD_TYPE=Release
cmake --build build-wasm --parallel
ls -l web/geo.js web/geo.wasm
124 changes: 124 additions & 0 deletions demo/demo_api.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Thin, JS-friendly layer over geo-utils-cpp. Paths are flat arrays
// [lat0, lng0, lat1, lng1, ...]. Pure C++17 with no Emscripten dependency, so the
// exact same code is compiled natively (test/native_ref.cpp) to cross-check the
// WebAssembly build.
#pragma once

#include <geo/geo.hpp>

#include <cstddef>
#include <optional>
#include <string>
#include <vector>

namespace demo {

using Flat = std::vector<double>;

inline std::vector<geo::LatLng> to_path(const Flat& flat) {
std::vector<geo::LatLng> path;
path.reserve(flat.size() / 2);
for (std::size_t i = 0; i + 1 < flat.size(); i += 2) {
path.emplace_back(flat[i], flat[i + 1]);
}
return path;
}

inline Flat to_flat(const std::vector<geo::LatLng>& path) {
Flat flat;
flat.reserve(path.size() * 2);
for (const auto& p : path) {
flat.push_back(p.lat);
flat.push_back(p.lng);
}
return flat;
}

inline std::string version() { return GEO_UTILS_CPP_VERSION_STRING; }

inline double distance_between(double lat1, double lng1, double lat2, double lng2) {
return geo::distance_between({lat1, lng1}, {lat2, lng2});
}

inline double heading(double lat1, double lng1, double lat2, double lng2) {
return geo::heading({lat1, lng1}, {lat2, lng2});
}

inline Flat offset(double lat, double lng, double distance, double heading_deg) {
const geo::LatLng p = geo::offset({lat, lng}, distance, heading_deg);
return {p.lat, p.lng};
}

inline Flat interpolate(double lat1, double lng1, double lat2, double lng2, double fraction) {
const geo::LatLng p = geo::interpolate({lat1, lng1}, {lat2, lng2}, fraction);
return {p.lat, p.lng};
}

inline double path_length(const Flat& flat) { return geo::path_length(to_path(flat)); }

// Empty result for an empty path.
inline Flat point_at_distance(const Flat& flat, double distance) {
const auto p = geo::point_at_distance(to_path(flat), distance);
return p ? Flat{p->lat, p->lng} : Flat{};
}

inline double area(const Flat& flat) { return geo::area(to_path(flat)); }

inline bool contains(double lat, double lng, const Flat& polygon, bool geodesic) {
return geo::contains({lat, lng}, to_path(polygon), geodesic);
}

inline bool on_edge(double lat, double lng, const Flat& polygon, bool geodesic, double tolerance) {
return geo::on_edge({lat, lng}, to_path(polygon), geodesic, tolerance);
}

// Distance in meters from the point to the closest polygon edge (the polygon is
// closed implicitly). -1 for an empty polygon.
inline double distance_to_edge(double lat, double lng, const Flat& polygon) {
auto path = to_path(polygon);
if (path.empty()) {
return -1.0;
}
if (path.size() > 1) {
path.push_back(path.front());
}
const auto projection = geo::closest_point_on_path({lat, lng}, path);
return projection ? projection->distance : -1.0;
}

inline std::string encode(const Flat& flat, unsigned precision) {
return geo::encode(to_path(flat), precision);
}

inline Flat decode(const std::string& encoded, unsigned precision) {
return to_flat(geo::decode(encoded, precision));
}

inline Flat simplify(const Flat& flat, double tolerance, bool geodesic) {
return to_flat(geo::simplify(to_path(flat), tolerance, geodesic));
}

// [lat, lng, segment, distance_to_path_m, distance_along_path_m]; empty for an empty path.
inline Flat closest_point_on_path(double lat, double lng, const Flat& flat) {
const auto path = to_path(flat);
const auto projection = geo::closest_point_on_path({lat, lng}, path);
if (!projection) {
return {};
}
const std::vector<geo::LatLng> head(path.begin(), path.begin() + static_cast<std::ptrdiff_t>(projection->segment) + 1);
const double along = geo::path_length(head) + geo::distance_between(path[projection->segment], projection->point);
return {projection->point.lat, projection->point.lng, static_cast<double>(projection->segment),
projection->distance, along};
}

// [south, west, north, east]; empty for an empty path. Antimeridian-aware: west > east
// means the box crosses the 180th meridian.
inline Flat bounds(const Flat& flat) {
const auto b = geo::bounds(to_path(flat));
if (!b) {
return {};
}
return {b->southwest.lat, b->southwest.lng, b->northeast.lat, b->northeast.lng};
}

} // namespace demo
Loading
Loading