Complete Myriahedral raster-overlay example

Documentation index · Web workflow · Myriahedral implementation notes · Generation pipeline

This is a complete, copyable example for compiling the cartofreako C++20 Myriahedral projection to WebAssembly, generating a projection overlay in the browser, and loading the checked-in projection image beneath it.

The files shown here are examples to create locally; they are not additional src.wasm/ products. Start new applications with the production all-projection quick start. A separate Myriahedral compatibility adapter still computes only ocean and land; this example instead demonstrates graticules and city anchors over the checked-in raster.

The target frame is exactly 1920 x 1080. The source raster is 4480 x 2520, so both canvases are 16:9 and the browser scales the raster uniformly by 3/7. No aspect-ratio approximation, crop, or geometric correction is needed.

The example deliberately consists of three small source files:

src.wasm/
├── index.html
├── myriahedral-web.cc
└── smoke.mjs

The build places the two Emscripten outputs beside the browser and smoke sources here:

src.wasm/
├── index.html
├── myriahedral-web.cc
├── myriahedral.mjs
├── myriahedral.wasm
└── smoke.mjs

C++ source

Save this as src.wasm/myriahedral-web.cc:

#include <array>
#include <cmath>
#include <cstddef>
#include <iomanip>
#include <sstream>
#include <string>
#include <tuple>
#include <utility>
#include <vector>

#include <emscripten/bind.h>

#include <a60-io.h>
#include <izzi-svg.h>

#include "a60-carto-frame.h"
#include "a60-carto-projection.h"
#include "cart0freak0-myriahedral.h"

namespace {

constexpr double map_width_value = 1920;
constexpr double map_height_value = 1080;
constexpr double sample_step = 0.5;

struct geographic_point
{
  double latitude;
  double longitude;
};

struct projected_point
{
  double x;
  double y;
};

using projected_path = std::vector<projected_point>;
using projected_paths = std::vector<projected_path>;

struct projected_transition
{
  projected_point left;
  projected_point right;
  bool is_cut;
};

const a60::carto::myriaproj&
projection()
{
  static const a60::carto::frame map_frame {
    map_width_value, map_height_value
  };
  static const auto value = a60::carto::make_myriahedral_projection(
    map_frame, "assets.static/myriahedral/black-white-downsampled.png");
  return value;
}

projected_point
project(const geographic_point point)
{
  const auto [x, y] = projection().meridians_to_point_2d(
    point.latitude, point.longitude);
  return {x, y};
}

std::size_t
face(const geographic_point point)
{
  const double longitude = point.longitude == 180 ? -180 : point.longitude;
  return a60::carto::myriahedral_detail::containing_face(
    a60::carto::myriahedral_detail::geographic_vector(
      point.latitude, longitude));
}

geographic_point
interpolate(const geographic_point left, const geographic_point right,
            const double fraction)
{
  return {
    left.latitude + fraction * (right.latitude - left.latitude),
    left.longitude + fraction * (right.longitude - left.longitude),
  };
}

double
distance(const projected_point left, const projected_point right)
{ return std::hypot(right.x - left.x, right.y - left.y); }

void
append_unique(projected_path& path, const projected_point point)
{
  if (path.empty() || path.back().x != point.x || path.back().y != point.y)
    path.push_back(point);
}

projected_transition
find_face_transition(geographic_point left, geographic_point right,
                     const std::size_t left_face)
{
  // The deliberately fine input step makes multiple transitions unlikely.
  // This example assumes at most one face edge between adjacent samples.
  for (int iteration = 0; iteration != 48; ++iteration)
    {
      const geographic_point middle = interpolate(left, right, 0.5);
      if (face(middle) == left_face)
        left = middle;
      else
        right = middle;
    }

  const projected_point projected_left = project(left);
  const projected_point projected_right = project(right);
  return {
    projected_left,
    projected_right,
    distance(projected_left, projected_right)
      > map_width_value * 1e-5,
  };
}

projected_transition
find_coordinate_wrap(geographic_point left, geographic_point right)
{
  projected_point projected_left = project(left);
  projected_point projected_right = project(right);
  for (int iteration = 0; iteration != 48; ++iteration)
    {
      const geographic_point middle = interpolate(left, right, 0.5);
      const projected_point projected_middle = project(middle);
      if (distance(projected_left, projected_middle)
          > distance(projected_middle, projected_right))
        {
          right = middle;
          projected_right = projected_middle;
        }
      else
        {
          left = middle;
          projected_left = projected_middle;
        }
    }
  return {projected_left, projected_right, true};
}

projected_paths
project_line(const std::vector<geographic_point>& source)
{
  projected_paths result;
  if (source.empty())
    return result;

  projected_path current;
  append_unique(current, project(source.front()));
  for (std::size_t index = 1; index != source.size(); ++index)
    {
      const geographic_point left = source[index - 1];
      const geographic_point right = source[index];
      const projected_point projected_right = project(right);
      const std::size_t left_face = face(left);
      const std::size_t right_face = face(right);

      if (left_face != right_face)
        {
          const projected_transition transition
            = find_face_transition(left, right, left_face);
          append_unique(current, transition.left);
          if (transition.is_cut)
            {
              if (current.size() >= 2)
                result.push_back(std::move(current));
              current.clear();
              append_unique(current, transition.right);
            }
          else
            append_unique(current, transition.right);
        }
      else if (distance(current.back(), projected_right)
               > map_width_value / 3)
        {
          const projected_transition transition
            = find_coordinate_wrap(left, right);
          append_unique(current, transition.left);
          if (current.size() >= 2)
            result.push_back(std::move(current));
          current.clear();
          append_unique(current, transition.right);
        }

      append_unique(current, projected_right);
    }

  if (current.size() >= 2)
    result.push_back(std::move(current));
  return result;
}

std::vector<geographic_point>
parallel(const double latitude)
{
  std::vector<geographic_point> result;
  const int samples = static_cast<int>(360 / sample_step);
  result.reserve(static_cast<std::size_t>(samples + 1));
  for (int index = 0; index <= samples; ++index)
    result.push_back({latitude, -180 + index * sample_step});
  return result;
}

std::vector<geographic_point>
meridian(const double longitude)
{
  std::vector<geographic_point> result;
  const int samples = static_cast<int>(180 / sample_step);
  result.reserve(static_cast<std::size_t>(samples + 1));
  for (int index = 0; index <= samples; ++index)
    result.push_back({-90 + index * sample_step, longitude});
  return result;
}

std::string
coordinate_id(const std::string& prefix, const int coordinate)
{
  return prefix + (coordinate < 0 ? "-minus-" : "-")
         + std::to_string(std::abs(coordinate));
}

void
append_svg_path(std::ostringstream& output, const std::string& id,
                const std::string& classes,
                const projected_paths& paths)
{
  output << "<path id=\"" << id << "\" class=\"" << classes
         << "\" d=\"";
  for (const projected_path& path : paths)
    {
      if (path.size() < 2)
        continue;
      output << "M " << path.front().x << ' ' << path.front().y;
      for (std::size_t index = 1; index != path.size(); ++index)
        output << " L " << path[index].x << ' ' << path[index].y;
      output << ' ';
    }
  output << "\"/>\n";
}

std::string
generate_map_svg()
{
  // Force construction here so initialization time belongs to this call.
  static_cast<void>(projection());

  std::ostringstream output;
  output << std::fixed << std::setprecision(2);
  output
    << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
    << "<svg xmlns=\"http://www.w3.org/2000/svg\" "
       "viewBox=\"0 0 1920 1080\" role=\"img\" "
       "aria-labelledby=\"map-title map-description\">\n"
    << "<title id=\"map-title\">Myriahedral 1920 by 1080 graticule</title>\n"
    << "<desc id=\"map-description\">A seam-aware graticule generated by "
       "the cartofreako C++20 Myriahedral projection compiled to "
       "WebAssembly.</desc>\n"
    << "<style>"
       ".graticule{fill:none;vector-effect:non-scaling-stroke;"
       "stroke-linecap:round;stroke-linejoin:round}"
       ".latitude{stroke:#0077bb;stroke-width:1.1;opacity:.48}"
       ".longitude{stroke:#ee7733;stroke-width:1.1;opacity:.48}"
       ".major{stroke-width:1.7;opacity:.82}"
       ".anchor{fill:#cc3311;stroke:white;stroke-width:1.5;"
       "vector-effect:non-scaling-stroke}"
       ".frame{fill:none;stroke:#222;stroke-width:1;"
       "vector-effect:non-scaling-stroke}"
       "</style>\n"
    << "<g id=\"latitudes\" class=\"graticule\">\n";

  for (int latitude = -80; latitude <= 80; latitude += 10)
    append_svg_path(
      output, coordinate_id("latitude", latitude),
      latitude % 30 == 0 ? "latitude major" : "latitude",
      project_line(parallel(latitude)));
  output << "</g>\n<g id=\"longitudes\" class=\"graticule\">\n";
  for (int longitude = -180; longitude < 180; longitude += 10)
    append_svg_path(
      output, coordinate_id("longitude", longitude),
      longitude % 30 == 0 ? "longitude major" : "longitude",
      project_line(meridian(longitude)));
  output << "</g>\n<g id=\"anchors\">\n";

  struct anchor
  {
    const char* name;
    double latitude;
    double longitude;
  };
  constexpr std::array anchors {
    anchor {"New York", 40.7128, -74.0060},
    anchor {"Paris", 48.8575, 2.3514},
    anchor {"Delhi", 28.7041, 77.1025},
    anchor {"Tokyo", 35.6895, 139.6917},
    anchor {"Sydney", -33.8688, 151.2093},
    anchor {"Sao Paulo", -23.5558, -46.6396},
  };
  for (const anchor& value : anchors)
    {
      const projected_point point = project(
        {value.latitude, value.longitude});
      output << "<circle class=\"anchor\" cx=\"" << point.x
             << "\" cy=\"" << point.y << "\" r=\"4\"><title>"
             << value.name << "</title></circle>\n";
    }

  output
    << "</g>\n"
    << "<rect class=\"frame\" x=\"0.5\" y=\"0.5\" "
       "width=\"1919\" height=\"1079\"/>\n"
    << "</svg>\n";
  return output.str();
}

double
map_width()
{ return map_width_value; }

double
map_height()
{ return map_height_value; }

std::string
source_raster()
{ return "assets.static/myriahedral/black-white-downsampled.png"; }

} // namespace

EMSCRIPTEN_BINDINGS(cartofreako_myriahedral_web)
{
  emscripten::function("mapWidth", &map_width);
  emscripten::function("mapHeight", &map_height);
  emscripten::function("sourceRaster", &source_raster);
  emscripten::function("generateMapSvg", &generate_map_svg);
}

What the wrapper exports

The Embind block makes four ordinary functions available on the initialized JavaScript module:

JavaScript call Result
module.mapWidth() 1920
module.mapHeight() 1080
module.sourceRaster() repository-relative PNG path
module.generateMapSvg() complete SVG overlay as a JavaScript string

The wrapper creates the projection once as a function-local static object. Every graticule and anchor coordinate then passes through the public myriaproj::meridians_to_point_2d(latitude, longitude) API.

The implementation-detail face query is used only to keep SVG paths from bridging a cut in the unfolded mesh. When adjacent samples enter different triangular faces, the generator bisects the geographic interval and compares the two projected limits. Coincident limits are a retained hinge; separated limits start a new SVG subpath.

Browser page

Save this as src.wasm/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>cartofreako Myriahedral WebAssembly example</title>
  <style>
    :root {
      color-scheme: light dark;
      font-family: system-ui, sans-serif;
    }
    body {
      margin: 0;
      padding: 1.5rem;
      background: Canvas;
      color: CanvasText;
    }
    main {
      max-width: 1920px;
      margin-inline: auto;
    }
    .map-stage {
      position: relative;
      width: 100%;
      aspect-ratio: 16 / 9;
      overflow: hidden;
      background: #eee;
      box-shadow: 0 0 0 1px color-mix(in srgb, CanvasText 30%, transparent);
    }
    .map-stage img {
      position: absolute;
      inset: 0;
      display: block;
      width: 100%;
      height: 100%;
    }
    #generated-map {
      pointer-events: none;
    }
    #status {
      min-height: 1.5em;
    }
  </style>
</head>
<body>
  <main>
    <h1>Myriahedral map generated with C++20 and WebAssembly</h1>
    <p id="status" aria-live="polite">Loading WebAssembly…</p>
    <figure>
      <div class="map-stage"
           role="img"
           aria-label="Myriahedral source raster with a generated graticule and city anchors">
        <img id="projection-raster" alt="">
        <img id="generated-map" alt="">
      </div>
      <figcaption>
        The browser scales the registered 4480×2520 raster and the
        WASM-generated 1920×1080 SVG through the same 16:9 viewport.
      </figcaption>
    </figure>
    <p><a id="download" download="myriahedral-1920x1080.svg" hidden>
      Download the generated SVG overlay
    </a></p>
  </main>

  <script type="module">
    import createMyriahedralModule from "./myriahedral.mjs";

    const status = document.querySelector("#status");
    const raster = document.querySelector("#projection-raster");
    const generatedMap = document.querySelector("#generated-map");
    const download = document.querySelector("#download");

    function loadImage(element, url) {
      return new Promise((resolve, reject) => {
        element.addEventListener("load", resolve, {once: true});
        element.addEventListener(
          "error",
          () => reject(new Error(`Could not load ${url}`)),
          {once: true}
        );
        element.src = url;
      });
    }

    try {
      const module = await createMyriahedralModule();
      const width = module.mapWidth();
      const height = module.mapHeight();
      raster.width = generatedMap.width = width;
      raster.height = generatedMap.height = height;

      // This page is deployed as src.wasm/index.html, one level below the
      // repository root where assets.static/myriahedral lives.
      const repositoryRoot = new URL("../", window.location.href);
      const rasterUrl = new URL(module.sourceRaster(), repositoryRoot);
      const rasterReady = loadImage(raster, rasterUrl.href);

      status.textContent = "Generating the seam-aware SVG graticule…";
      const svg = module.generateMapSvg();
      const generatedUrl = URL.createObjectURL(
        new Blob([svg], {type: "image/svg+xml"})
      );
      const generatedReady = loadImage(generatedMap, generatedUrl);

      download.href = generatedUrl;
      download.hidden = false;
      await Promise.all([rasterReady, generatedReady]);
      status.textContent =
        `Ready: ${width}×${height}, ${svg.length.toLocaleString()} SVG characters.`;

      window.addEventListener(
        "pagehide",
        () => URL.revokeObjectURL(generatedUrl),
        {once: true}
      );
    } catch (error) {
      status.textContent = `Generation failed: ${error.message}`;
      console.error(error);
    }
  </script>
</body>
</html>

The two images are registered by construction:

The page must be served from the repository root with its files in src.wasm/. That layout is why ../ from the page resolves to the repository root and makes assets.static/myriahedral/black-white-downsampled.png available without copying it.

Do not revoke the generated Blob URL immediately after assigning it. The URL must remain alive while the overlay image and download link use it; this page revokes it on pagehide.

Node smoke test

Save this as src.wasm/smoke.mjs:

import assert from "node:assert/strict";
import {statSync, writeFileSync} from "node:fs";
import createMyriahedralModule from "./myriahedral.mjs";

const module = await createMyriahedralModule();
assert.equal(module.mapWidth(), 1920);
assert.equal(module.mapHeight(), 1080);
assert.equal(
  module.sourceRaster(),
  "assets.static/myriahedral/black-white-downsampled.png"
);

const svg = module.generateMapSvg();
assert.match(svg, /viewBox="0 0 1920 1080"/);
assert.match(svg, /id="latitudes"/);
assert.match(svg, /id="longitudes"/);
assert.match(svg, /New York/);
assert.doesNotMatch(svg, /(?:nan|inf)/i);
assert.ok((svg.match(/<path /g) ?? []).length === 53);
assert.ok(svg.length > 500000);
const svgUrl = new URL("./myriahedral-1920x1080.svg", import.meta.url);
const wasmUrl = new URL("./myriahedral.wasm", import.meta.url);
writeFileSync(svgUrl, svg);

console.log(JSON.stringify({
  wasmBytes: statSync(wasmUrl).size,
  svgBytes: Buffer.byteLength(svg),
  paths: (svg.match(/<path /g) ?? []).length
}));

The Node test exercises the same ES module and WASM binary as the browser. It checks dimensions, asset registration, stable layer IDs, finite coordinates, and the expected 17 latitude plus 36 longitude paths. Its output SVG is written beside the smoke script regardless of the shell’s current directory.

Build

From the repository root, activate the local SDK and prepare the output directory:

source /home/bkoz/src/emsdk/emsdk_env.sh
mkdir -p src.wasm

Copy the three source blocks above into src.wasm/, then compile:

em++ src.wasm/myriahedral-web.cc \
  -I src.projections \
  -isystem ../alpha60/src \
  -isystem ../izzi/src \
  -std=c++20 \
  -O3 \
  -Wall -Wextra -Wpedantic -Werror \
  --bind \
  --no-entry \
  -sMODULARIZE=1 \
  -sEXPORT_ES6=1 \
  -sEXPORT_NAME=createMyriahedralModule \
  -sENVIRONMENT=web,node \
  -sALLOW_MEMORY_GROWTH=1 \
  -sFILESYSTEM=0 \
  -o src.wasm/myriahedral.mjs

The neighboring Alpha60 and Izzi headers are system includes so their own warnings do not fail this wrapper’s strict -Werror build. The cartofreako headers remain normal includes and therefore remain covered by those warning flags.

If the SDK checkout is read-only to the build account, select a writable task-specific cache before compiling:

export EM_CACHE=/tmp/cartofreako-emscripten-cache

Verify without a browser

Run the smoke test from any directory:

node /absolute/path/to/cartofreako/src.wasm/smoke.mjs

For Emscripten 6.0.5, the verified build reported:

{"wasmBytes":270756,"svgBytes":574903,"paths":53}

Compiler revisions can change the byte counts. The assertions on dimensions, IDs, finite output, and path count are the behavioral checks.

Run in a browser

From the cartofreako repository root:

emrun \
  --no_browser \
  --serve_after_close \
  --serve_root "$PWD" \
  --port 8000 \
  src.wasm/index.html

Open:

http://localhost:8000/src.wasm/index.html

The status first reports WASM loading and SVG generation, then shows the intrinsic size and generated character count. The visible result is the black-and-white Myriahedral projection image with blue latitude paths, orange longitude paths, and six red city anchors. The download link saves the generated SVG overlay independently of the raster.

Opening the page directly with file:// will not work reliably because the ES module fetches its companion .wasm file. A different server is fine as long as it serves .mjs as JavaScript, .wasm as application/wasm, and the build and asset paths from one origin.

Changing the output size

For this fixed raster-compatible Myriahedral layout, keep the exact 16:9 relationship. Replace both C++ constants together, for example:

constexpr double map_width_value = 1280;
constexpr double map_height_value = 720;

The SVG viewBox, frame rectangle, and title in this compact example are literal 1920×1080 values and must be updated at the same time. For a reusable runtime-sized API, serialize those values from the constants and accept only frames satisfying width / height == 16 / 9 within the projection API’s normal floating-point tolerance.

Changing only the CSS display size does not require recompilation. The aspect-ratio: 16 / 9 stage scales both layers together while the generated coordinate system remains 1920×1080.

Production extensions

For an ocean-and-land vector base map, use cahill-myriahedral.cc and make check-wasm-cahill-myriahedral. This example favors an inspectable raster-overlay path. A production viewer can build on it by:

For the numeric and perceptual reasoning behind those choices, see the web workflow and the general generation notes.


Documentation index · Web workflow · Myriahedral implementation notes · Generation pipeline