Omnimap is one PHP contract for web maps, whoever draws them. A page describes a view once - its
centre, its zoom, its markers - and the provider configured for it turns that view into what is
needed: a description the browser draws with the provider's own library (Google's Maps JavaScript
API, Leaflet, MapLibre GL JS), an image of the view made on the server, the tiles of a slippy map,
places found from an address and addresses found from a point. Each provider is a package of its
own (omnimap/google, omnimap/openstreetmap, omnimap/maplibre) that answers the contracts it
can and says which in its Capabilities.
$registry->renderer('maps')->describe($view); // array: what the browser's driver draws
$registry->staticMap('maps')->image($view, new Size(640, 400)); // Image: PNG bytes made on the server
$registry->geocoder('maps')->geocode('Kyoto'); // Place[], the best first
$registry->tiles('maps')->url(12, 3592, 1622); // one tile's URL
$registry->get('maps')->capabilities(); // what it does, which hosts the browser reaches
Around the contracts sits the map cache: the picture of a view, named by a signature of what
it depends on, cut in square tiles and kept in the host's storage (a directory, a Flysystem
storage, an omnidrive drive). A page then shows the picture instead of the live map, and a visitor's
browser reaches no third party at all. The picture comes from the browser (an administrator's
capture of the live map) or from the server (a provider's static image, or tiles put together):
the cache is the same for every provider. This is the cache glitchr/ux-google kept for
lesringostars, made independent of Google; its file layout and its names are kept, so pictures
captured before omnimap are still served.
The core needs no framework: it requires psr/simple-cache, symfony/http-client-contracts
(interfaces only) and willdurand/geocoder (geocoder-php's interfaces and models). Geocoding is
geocoder-php's: omnimap does not parse a geocoding API itself, it puts a geocoder-php provider
behind its own contract with a cache and a pace. The Symfony and Laravel wiring lives in
omnibridge (Symfony, Laravel). The browser side is three ES modules
without a library (The browser).
When to use it
- A site that shows maps and wants to be able to change provider (Google to OpenStreetMap, raster to vector tiles) without touching its pages: the view, the markup and the cache stay the same.
- A site that must not send its visitors to a third party without their consent: every live map goes through a consent gate, and a cached picture reaches no one.
- An application that needs geocoding with the caching and the pace the services' terms ask for (Nominatim: one request a second at most, results cached).
- A server that makes map images (an e-mail, a PDF, an Open Graph image) from a tile server.
It is not a map library: drawing, panning and zooming are the provider's library's job in the browser. It does not store anything but the pictures of the map cache, and it does not decide whether a provider's terms allow a picture of its map to be kept: whoever runs it answers for that (see Responsibility).
Providers
Three packages. The capabilities are what each provider's capabilities()->contracts() answers,
probed in the harness on 2026-10-25 with the options and clients named; "with X"
means the capability is off until X is given. The browser hosts are the defaults.
| Package | Factory name | Renderer (driver) | Static map | Geocoder | Tiles | Browser hosts |
|---|---|---|---|---|---|---|
omnimap/google |
google |
yes (assets/google.js, Maps JavaScript API) |
with an HTTP client and a key (server_key or api_key) |
with a PSR-18 client and a key (geocoder-php's GoogleMaps) |
- | maps.googleapis.com, maps.gstatic.com, fonts.googleapis.com |
omnimap/openstreetmap |
openstreetmap |
yes (assets/leaflet.js, Leaflet 1.9.4) |
with an HTTP client and a user_agent, tiles put together |
with a PSR-18 client, a user_agent and a nominatim URL (geocoder-php's Nominatim) |
yes | tile.openstreetmap.org, unpkg.com |
omnimap/maplibre |
maplibre |
yes (assets/maplibre.js, MapLibre GL JS 5.24.0) |
- | - | - | tiles.openfreemap.org, unpkg.com |
The core ships a fourth provider for tests, Omnimap\Testing\TestTiles (name test: renderer,
static map, tiles, on https://tiles.example/), with no factory (Testing).
Each provider's own manual lists its options, its description's keys and its behaviour. A new provider starts from glitchr-omnisense/omni-provider-template (Writing a provider).
How this manual is organised
- Installation: requirements, a first script in plain PHP, a real provider.
- Symfony and Laravel: the omnibridge wiring, its configuration, its routes, its Twig functions.
- Concepts: providers, factories, the registry, the four contracts, every model, the projection, the signer.
- Usage: a live map in the browser, images made on the server, geocoding, tiles, the map cache, the browser's modules.
- Configuration reference: every option of every factory and every constructor argument of the core's classes.
- Errors: the exceptions and when each is thrown.
- Testing: the fakes, recorded answers, the conformance suite test by test.
- The Docker harness: the core and every provider in one installation.
- Writing a provider.
- Verified, and not.
Proposer une modification docs/index.md
Installation
composer require omnisense/omnimap omnimap/openstreetmap symfony/http-client
Requirements
- PHP 8.2 or later.
omnisense/omnimaprequirespsr/simple-cache(^2.0 or ^3.0),symfony/http-client-contracts(^3.0, interfaces only) andwilldurand/geocoder(^5.0, geocoder-php's interfaces and models), and nothing else: no framework, no HTTP client.ext-gdfor the map cache and for images made of tiles:SnapshotStore::save()cuts a picture with GD,Static\TileStitcherputs tiles together with it (PNG, JPEG and WebP support for the formats you keep). Without GD, live maps, descriptions, geocoding and tile URLs still work.- A provider that calls a service needs an HTTP client:
symfony/http-client(a library that stands alone) for tiles and Static Maps; for geocoding, a PSR-18 client - Symfony'sPsr18Clientover the same client works, with a PSR-17 factory such asnyholm/psr7installed. - Suggested by the core, each for one storage of the map cache:
league/flysystem(Snapshot\FlysystemStorage) andomnisense/omnidrive(Snapshot\DriveStorage).
The providers require the core, symfony/http-client-contracts and, when they geocode,
psr/http-client, psr/simple-cache and their geocoder-php provider
(geocoder-php/google-maps-provider ^4.7, geocoder-php/nominatim-provider ^5.7).
omnimap/maplibre requires the core and symfony/http-client-contracts only.
Plain PHP
GD (ext-gd) cuts and makes the pictures. Everything below runs without a network: the tiles
come from Omnimap\Testing\TestTiles, a provider for tests whose tiles a recorded HTTP client
draws. Put omnimap/openstreetmap's provider in its place, with your application's User-Agent,
and the same code fetches real tiles. Saved as map.php:
<?php
use Omnimap\Geocoding\GeocoderPhp;
use Omnimap\MapHtml;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Marker;
use Omnimap\Signer;
use Omnimap\Snapshot\DirectoryStorage;
use Omnimap\Snapshot\Snapshots;
use Omnimap\Snapshot\SnapshotStore;
use Omnimap\Testing\ArrayCache;
use Omnimap\Testing\FixedGeocoder;
use Omnimap\Testing\PlainTiles;
use Omnimap\Testing\RecordedHttpClient;
use Omnimap\Testing\TestTiles;
require 'vendor/autoload.php';
$provider = new TestTiles(new RecordedHttpClient(PlainTiles::answer()), new ArrayCache());
$view = new MapView(new LatLng(35.0116, 135.7681), 12, [new Marker(new LatLng(35.0036, 135.7780), 'Gion')], controls: false, id: 'map');
// The map cache: pictures named by a signature, kept in a directory, made by the server from tiles.
$dir = sys_get_temp_dir().'/omnimap-docs-'.getmypid();
$snapshots = new Snapshots($provider, new Signer('c2VjcmV0'), new SnapshotStore(new DirectoryStorage($dir), 'maps'), $provider);
$snapshot = $snapshots->findOrMake($view);
echo "{$snapshot->width}x{$snapshot->height} in {$snapshot->xtiles}x{$snapshot->ytiles} tiles of {$snapshot->tilesize}\n";
// 1512x938 in 3x2 tiles of 512
// The page's element: the picture's frame, which assets/snapshot.js fills with the tiles.
$html = (new MapHtml(['src' => '/maps/{signature}/{id}.webp']))->snapshot('map', $snapshot->signature, $snapshot, $provider->attribution());
echo substr_count($html, 'data-omnimap="snapshot"'), " frame\n";
// 1 frame
// Geocoding: a geocoder-php provider behind omnimap's contract, its answers kept.
$geocoder = new GeocoderPhp(new FixedGeocoder(['Kyoto' => ['lat' => 35.0116, 'lng' => 135.7681, 'locality' => 'Kyoto', 'country' => 'Japan']]), new ArrayCache());
echo $geocoder->geocode('Kyoto')[0]->label, "\n";
// Kyoto, Japan
$snapshots->delete($snapshot->signature);
@rmdir("$dir/maps");
@rmdir($dir);
$ php map.php
1512x938 in 3x2 tiles of 512
1 frame
Kyoto, Japan
(run on 2026-10-25 in the harness, PHP 8.4 with GD; Tests/DocsTest.php runs the
block above as it is written and checks these three lines, on every run of the suite)
No key, no network. The pieces:
- a provider: here
TestTiles, which describes a view, gives tile URLs and makes images of tiles; in an application, a factory's provider (OpenStreetMapFactory,GoogleFactory,MapLibreFactory), usually through aRegistry; - the map cache:
Snapshotsnames a view by a signature (Signer, with the host's secret), finds its picture in aSnapshotStoreor makes it with the provider'sStaticMapInterface, and the store cuts it into 512-pixel tiles in aStorage(DirectoryStoragehere); MapHtml: the page's markup, a picture's frame (snapshot()) or a live map (live()), which the browser's modules fill (The browser);GeocoderPhp: a geocoder-php provider (FixedGeocoderhere, a test double) behind omnimap's models, its answers kept in a PSR-16 cache (ArrayCachehere).
No class of a framework, of an HTTP client or of Flysystem is loaded on the way:
Tests/BareTest.php checks it in a process of its own, with docker/harness/bin/bare.
With a real provider
The same view drawn live by Leaflet over OpenStreetMap's tiles, and its picture made by the server
from those tiles. Fetching tiles needs a user_agent naming the application and a contact (the
OpenStreetMap Foundation's tile policy blocks a library's default one); HttpClient::create() is
any Symfony\Contracts\HttpClient\HttpClientInterface.
<?php
require __DIR__.'/vendor/autoload.php';
use Omnimap\MapHtml;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Marker;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Registry;
use Omnimap\Signer;
use Omnimap\Snapshot\DirectoryStorage;
use Omnimap\Snapshot\Snapshots;
use Omnimap\Snapshot\SnapshotStore;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;
$http = HttpClient::create();
$registry = new Registry([new OpenStreetMapFactory($http, new Psr18Client($http))], [
'maps' => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0 (+https://example.org; maps@example.org)']],
]);
$view = new MapView(new LatLng(35.0116, 135.7681), 12, [new Marker(new LatLng(35.0036, 135.7780), 'Gion')], id: 'map');
// Live: the description omnimap.js and leaflet.js draw, behind the page's consent gate.
echo (new MapHtml())->live('map', $registry->renderer('maps')->describe($view));
// Cached: the picture made once by the server (35 tiles for 1512 x 938), then read from /var/maps.
$snapshots = new Snapshots(
$registry->renderer('maps'),
new Signer(getenv('MAP_SECRET') ?: ''),
new SnapshotStore(new DirectoryStorage('/var/maps')),
$registry->staticMap('maps'),
);
echo (new MapHtml())->snapshot('map', $snapshots->signature($view), $snapshots->findOrMake($view), $registry->renderer('maps')->attribution());
// Geocoding: Nominatim, one call a second at most in this process.
echo $registry->geocoder('maps')->geocode('Kyoto, Japan', 'en', 1)[0]->label;
This script reaches tile.openstreetmap.org and nominatim.openstreetmap.org: it was not run for
this page. The harness's live command runs the same calls (an image of Kyoto from 35 tiles and
one Nominatim query), last on 2026-10-23 (Verified, and not). In production, the
Foundation's tile server is for modest use only: its policy asks heavy users for a tile provider
of their own or their own server (the tiles option, see omnimap/openstreetmap's manual). The
page then needs assets/omnimap.js and assets/leaflet.js (The browser) and,
for the cached picture, assets/snapshot.js and a route that serves the tiles (The map
cache).
In a framework
- Symfony: omnibridge/symfony's
OmnimapExtensionbuilds the registry, the cache and the printer fromconfig/packages/omnimap.yaml, routes the cache and gives Twigomnimap(): Symfony. - Laravel: omnibridge/laravel's
Map\MapServiceProviderreadsconfig/omnimap.php, the same keys: Laravel. - Any other: build the
Registry(andSnapshots,MapPrinter) once, where the framework builds its services, as the scripts above do.
Proposer une modification docs/installation.md
Symfony
The core holds nothing of a framework. In a Symfony application, omnibridge/symfony wires it:
Omnibridge\Symfony\Map\DependencyInjection\OmnimapExtension, registered by
Omnibridge\Symfony\OmnibridgeBundle when omnisense/omnimap is installed, builds the providers,
the registry, the map cache and the printer from config/packages/omnimap.yaml; omnibridge/twig's
Omnibridge\Twig\MapExtension gives Twig omnimap(); a route loader and a controller serve the
cache. Without an omnimap.yaml, the extension loads nothing.
composer require omnibridge/symfony omnisense/omnimap omnimap/openstreetmap
with Omnibridge\Symfony\OmnibridgeBundle in config/bundles.php. The Twig functions need
omnibridge/twig and TwigBundle; cache.drive needs omnisense/omnidrive and the bridge's
Omnidrive\Drives service; geocoding needs a PSR-17 factory (nyholm/psr7 or
php-http/discovery) for the PSR-18 client.
omnimap.yaml
# config/packages/omnimap.yaml
omnimap:
provider: openstreetmap # the page's map: a key of providers
secret: '%env(MAP_SECRET)%' # signs the pictures' names
providers:
openstreetmap: { user_agent: 'MySite/1.0 (+https://example.org)' }
google: { api_key: '%env(GOOGLE_MAPS_CLIENT_KEY)%', secret: '%env(GOOGLE_MAPS_SECRET)%' }
cache:
drive: local.uploads # an omnidrive drive (Omnidrive\Drives); or storage: an Omnimap\Snapshot\Storage service
public_directory: '%kernel.project_dir%/public' # tiles served are written there too
routes:
tile: { path: '/omnimap/{signature}/{id}.webp', name: omnimap_tile }
# config/routes/omnimap.yaml
omnimap: { resource: ., type: omnimap }
The whole tree, from Omnibridge\Symfony\Map\DependencyInjection\Configuration:
| Key | Type | Default | |
|---|---|---|---|
provider |
string | required, not empty | the provider of the page's map: a key of providers |
geocoder |
string or null | null |
the provider Omnimap\GeocoderInterface is (a key of providers); the page's provider when null |
secret |
string | '' |
signs the pictures' names (URL-safe base64; Google's URL signing secret fits) |
providers |
map of name => options | {} |
each provider's options, as its factory reads them (Configuration); a factory key names the factory when the name is not one (places: { factory: google, ... }) |
cache_only |
bool | true |
a visitor never gets the live map: the picture kept, or an empty frame |
control |
string | ROLE_ADMIN |
the attribute (Access::isGranted()) of whoever may capture and delete pictures, and sees the live map while there is none |
cache.drive |
string or null | null |
the omnidrive drive the cache is kept on (Omnidrive\Drives: a storage's name, local.uploads) |
cache.storage |
string or null | null |
a service implementing Omnimap\Snapshot\Storage, when the cache is on no omnidrive drive; drive or storage is required |
cache.directory |
string | omnimap |
the folder of the pictures in the storage |
cache.pattern |
string | {signature}/image-{id} |
a tile's path in it |
cache.format |
png, jpeg, jpg, webp |
png |
the tiles' format as kept |
cache.tilesize |
int | 512 |
the tiles' size; 0 or less keeps pictures whole |
cache.make |
auto, true, false |
auto |
whether the server makes a missing picture itself: when the page's provider makes images (auto), always (true: a provider that does not implement StaticMapInterface is a NotSupportedException when the cache is built), never (false: a host that reads another host's cache) |
cache.size |
string | 1512x938 |
the size of a picture the server makes (WIDTHxHEIGHT) |
cache.scale |
int, 1 or 2 | 1 |
its density |
cache.pool |
string | cache.app |
the PSR-6 pool, seen as PSR-16 (omnimap.cache), that keeps fetched tiles and geocoders' answers |
cache.max_age |
int | 86400 |
the HTTP lifetime of a tile served by the route |
cache.public_directory |
string or null | null |
where a tile served is also written, at the route's own path (the web server then serves it without PHP): the public folder |
cache.public_size |
int or null | null |
the largest side of a tile served as WebP, in pixels (none: as kept) |
html.class |
string | omnimap-snapshot |
the frame's class (The markup) |
html.suffix |
string | _snapshot |
the frame's id suffix |
html.missing |
string | '' |
the frame's data-missing: the address of the picture snapshot.js shows for a missing picture or tile |
routes.tile |
{path, name} |
/omnimap/{signature}/{id}.webp, omnimap_tile |
a tile (also the src the frame carries) |
routes.metadata |
{path, name} |
/omnimap/{signature}, omnimap_metadata |
a picture's metadata |
routes.export |
{path, name} |
/omnimap/{signature}/export, omnimap_export |
the capture's post |
routes.suppress |
{path, name} |
/omnimap/{signature}/suppress, omnimap_suppress |
the deletion's post |
lesringostars keeps glitchr/ux-google's names: cache.directory: google, html: { class: google-tilemap, suffix: _html2canvas }, routes.tile: { path: '/google/{signature}/{id}.webp' }.
Services
| Service id | Class | |
|---|---|---|
Omnimap\Google\GoogleFactory, Omnimap\OpenStreetMap\OpenStreetMapFactory, Omnimap\MapLibre\MapLibreFactory |
the factories | registered when their package is installed, Google's and OpenStreetMap's over http_client, omnimap.psr18 and omnimap.cache, MapLibre's over http_client, tagged omnimap.provider_factory |
Omnimap\Registry (public) |
Registry |
every service tagged omnimap.provider_factory (the factories above, an application's own by the tag), and providers |
Omnimap\MapRendererInterface |
the page's provider | Registry::renderer(provider) |
Omnimap\GeocoderInterface |
the geocoder | Registry::geocoder(geocoder ?? provider) |
Omnimap\Snapshot\SnapshotStore |
the store | on cache.storage, or a DriveStorage of Drives::get(cache.drive) |
Omnimap\Snapshot\Snapshots (public) |
the cache | the page's renderer, Signer(secret), the store, the static map by cache.make, cache.size, cache.scale, cache.tilesize |
Omnimap\MapHtml |
the markup | html and the tile route's path as src |
Omnimap\MapPrinter (public) |
the page's rule | cache_only, errors logged as warnings on logger (omnimap: the map's picture could not be made: ...) |
Omnibridge\Twig\MapExtension |
Twig | when omnibridge/twig and TwigBundle are there, tagged twig.extension |
Omnibridge\Symfony\Map\SnapshotController (public) |
the routes' controller | |
Omnibridge\Symfony\Map\MapRouteLoader |
route loader of type omnimap |
|
omnimap.cache |
Psr16Cache over cache.pool |
|
omnimap.psr18 |
Psr18Client over http_client |
when a PSR-17 factory is installed; otherwise no provider geocodes |
All are autowirable by their class: public function __construct(private readonly Registry $maps, private readonly GeocoderInterface $geocoder) {}.
Twig functions
| Function | |
|---|---|
omnimap(view) |
MapPrinter::map(view, mayChange): the picture kept (made now when the server can), else the empty frame for a visitor (cache_only), else the live map; '' for a null view |
omnimap_export(view, {text: 'O', class: ...}) |
for whoever has control: the button that sends the live map's picture to routes.export, with a token of intent omnimap-export; '' for anyone else |
omnimap_suppress(view, {text: 'X', ...}) |
for whoever has control, when a picture is kept: the button that deletes it (routes.suppress, intent omnimap-suppress) |
omnimap_signature(view) |
the view's signature |
The buttons' text (default O and X) is printed as it is; every other key is an attribute of
the button. The view is a MapView the controller passes to the template:
return $this->render('store/show.html.twig', [
'map' => new MapView(new LatLng($store->lat, $store->lng), 15, [new Marker(new LatLng($store->lat, $store->lng), $store->name)], id: 'store-map'),
]);
<div style="position: relative; height: 400px">{{ omnimap(map) }}</div>
{{ omnimap_export(map, {text: 'Keep this picture', class: 'btn'}) }}
{{ omnimap_suppress(map, {text: 'Delete the picture', class: 'btn'}) }}
Routes and controller
MapRouteLoader (type omnimap) gives four routes, the signature matching [A-Za-z0-9_-]{1,128},
the tile first (its path may lie under the metadata's):
| Route | Method | Controller | |
|---|---|---|---|
routes.tile |
GET, HEAD | SnapshotController::tile |
id digits, default 0 |
routes.export |
POST | SnapshotController::export |
|
routes.suppress |
POST | SnapshotController::suppress |
|
routes.metadata |
GET, HEAD | SnapshotController::metadata |
- tile: the tile kept, 404 when there is none (or the signature is not one). When the route's
path ends in
.webpand the store's format is not WebP, the tile is converted: with GD built with WebP (fitted withinpublic_size, quality 75), else Imagick with WebP, else served as kept. With apublic_directory, it is also written there at the route's own path. Public,max-age=max_age,must-revalidate, an ETag (MD5 of the bytes), 304 when it matches. - metadata: the picture's
metadata.txtas JSON,{"status": "BAD"}when there is none. - export: for whoever
Access::isGranted(control), with a valid token of intentomnimap-export(when aTokensservice exists):Snapshots::save(signature, image, tilesize)from the postedimage(adata:URL) andtilesize(elsecache.tilesize), answered with the picture's metadata; then the tiles written in the public directory for that signature are deleted. Noimage: 400; not an image: 400; no right or a wrong token: 403. - suppress: the same right, intent
omnimap-suppress: the picture deleted, its public tiles too,{"status": "OK"}.
The Access, Tokens and Urls services are omnibridge's host contracts
(Omnibridge\Contract\*), which omnibridge/symfony implements over the security component, the
CSRF token manager and the router. Without an Access service, nobody may change the map.
Testing a Symfony application
The MapTest of omnibridge/symfony is a model: replace http_client in the test environment with
a MockHttpClient answering the tiles (Omnimap\Testing\PlainTiles::png()), point cache.drive
or cache.storage at a temporary directory, and request the page, the tiles and the routes
(Testing).
Proposer une modification docs/symfony.md
Laravel
omnibridge/laravel wires omnimap into a Laravel application: Omnibridge\Laravel\Map\MapServiceProvider,
registered by Omnibridge\Laravel\OmnibridgeServiceProvider when omnisense/omnimap is installed,
reads config/omnimap.php - the keys of omnibridge/symfony's omnimap.yaml, fewer of them - and
binds the registry, the map cache, the printer and omnibridge/twig's MapExtension, and routes
the tiles. It routes nothing and gives Twig nothing unless config/omnimap.php names a
provider.
composer require omnibridge/laravel omnisense/omnimap omnimap/openstreetmap
config/omnimap.php
// config/omnimap.php
return [
'provider' => 'openstreetmap',
'secret' => env('MAP_SECRET'),
'providers' => [
'openstreetmap' => ['user_agent' => 'MySite/1.0 (+https://example.org; maps@example.org)'],
'places' => ['factory' => 'google', 'server_key' => env('GOOGLE_MAPS_SERVER_KEY')],
],
'cache' => ['storage' => storage_path('app/omnimap'), 'directory' => 'omnimap', 'make' => 'auto'],
'routes' => ['tile' => ['path' => '/omnimap/{signature}/{id}.webp', 'name' => 'omnimap_tile', 'register' => true]],
];
Every key, as MapServiceProvider::config() and the bindings read them:
| Key | Default | |
|---|---|---|
provider |
'' |
the page's provider, a key of providers; empty: no route, no Twig function |
secret |
'' |
signs the pictures' names |
providers |
[] |
name => the factory's options; a factory key names the factory when the name is not one |
geocoder |
the page's provider | the provider bound as Omnimap\GeocoderInterface |
cache.drive |
- | an omnidrive drive of the bound Omnidrive\Drives (a storage's name: local.uploads) |
cache.storage |
storage_path('app/omnimap') |
when there is no drive: the container's name of an Omnimap\Snapshot\Storage, else a directory (DirectoryStorage) |
cache.directory |
omnimap |
|
cache.pattern |
{signature}/image-{id} |
|
cache.format |
png |
png, jpeg, jpg, webp |
cache.tilesize |
512 |
0 or less keeps pictures whole |
cache.make |
auto |
auto: the server makes a missing picture when the page's provider declares staticMap; true or 'true': whenever it implements StaticMapInterface; false or 'false': never |
cache.size |
1512x938 |
|
cache.scale |
1 |
|
cache.pool |
- | a store of Laravel's cache (config/cache.php): the providers' PSR-16 cache (tiles, geocoders' answers); without it a Symfony FilesystemAdapter (namespace omnimap) in storage/framework/cache/omnimap |
cache.max_age |
86400 |
a tile's max-age |
cache.public_directory |
- | where each tile served is written at the route's path, for the web server to serve next |
cache.public_size |
- | a tile served as WebP is fitted within it |
cache_only |
true |
anything but false keeps visitors off the live map |
control |
ROLE_ADMIN |
the ability (Gate) of whoever may change the map |
html.class, html.suffix, html.missing |
omnimap-snapshot, _snapshot, '' |
the frame's markup (The markup); src is the tile route's path |
routes.tile |
/omnimap/{signature}/{id}.webp, omnimap_tile, register: true |
the tile route; register: false for a host whose web server serves the tiles, or that reads another host's cache |
routes.metadata, routes.export, routes.suppress |
/omnimap/{signature}, .../export, .../suppress; omnimap_metadata, omnimap_export, omnimap_suppress |
names only: these routes are not registered (below) |
These are omnibridge/laravel 1.x's since 77de4a4; before it geocoder and the four last cache
keys were read by nothing.
Bindings
| Binding | |
|---|---|
Omnimap\Registry (singleton) |
GoogleFactory, OpenStreetMapFactory and MapLibreFactory when installed (MapServiceProvider::FACTORIES), and every binding tagged omnimap.provider_factory (MapServiceProvider::FACTORY_TAG), each over the HTTP client bound as omnimap.http_client (else HttpClient::create()), a Psr18Client over it when nyholm/psr7 or php-http/discovery is installed (else none: no provider geocodes), and the cache above |
Omnimap\Snapshot\Snapshots (singleton) |
the page's renderer, Signer(secret), a SnapshotStore on the storage above, the static map by cache.make |
Omnimap\MapRendererInterface |
the page's provider (Snapshots::renderer()) |
Omnimap\MapPrinter (singleton) |
cache_only; a picture the server could not make logged as a warning when a LoggerInterface is bound |
Omnibridge\Twig\MapExtension (singleton) |
the printer, the host's Urls, Access and Tokens (omnibridge/laravel's: the router, the Gate, the session), control and the export and suppress route names |
Omnimap\GeocoderInterface is bound to the registry's geocoder named by geocoder (the page's
provider by default); another one is the registry's, app(Registry::class)->geocoder('places').
Routes
Only the tile route is registered: GET routes.tile.path, the signature matching
[A-Za-z0-9_-]{1,128} and the id digits, served by Omnibridge\Laravel\Map\TileController as omnibridge/symfony's controller serves it:
at a path ending in .webp the tile as WebP (fitted within cache.public_size; GD with WebP, else
Imagick, else as kept, Content-Type: image/<format>, jpg as image/jpeg), written in
cache.public_directory when set, public, max-age cache.max_age, must-revalidate, an ETag
(MD5 of the bytes), 304 when it matches, 404 when there is none.
The metadata, the capture and the deletion of a picture are routed by omnibridge/symfony only. A
Laravel host that shows the buttons routes them itself, under the names in routes.export.name
and routes.suppress.name (the buttons' URLs are built from those names, and building them fails
when no route has the name). Written by hand, with the cache's own methods:
// routes/web.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Omnibridge\Contract\Tokens;
use Omnibridge\Twig\MapExtension;
use Omnimap\Snapshot\Snapshots;
Route::post('/omnimap/{signature}/export', function (Request $request, Snapshots $snapshots, Tokens $tokens, string $signature) {
abort_unless(Gate::allows('ROLE_ADMIN') && $tokens->isValid(MapExtension::EXPORT, $request->input('_token')), 403);
return $snapshots->save($signature, (string) $request->input('image'), (int) $request->input('tilesize') ?: null)->metadata();
})->where('signature', '[A-Za-z0-9_-]{1,128}')->name('omnimap_export');
Route::post('/omnimap/{signature}/suppress', function (Request $request, Snapshots $snapshots, Tokens $tokens, string $signature) {
abort_unless(Gate::allows('ROLE_ADMIN') && $tokens->isValid(MapExtension::SUPPRESS, $request->input('_token')), 403);
$snapshots->delete($signature);
return ['status' => 'OK'];
})->where('signature', '[A-Za-z0-9_-]{1,128}')->name('omnimap_suppress');
(not run; a picture that is not an image throws Omnimap\Exception\InvalidArgumentException,
which such a route should turn into a 400). The buttons' token is omnibridge/laravel's
SessionTokens::csrf(): Laravel's session token, the one its CSRF middleware checks on the web
group, the same for every intent - the posts of capture.js pass that middleware as they are.
In a page
With omnibridge/laravel's Twig views, omnimap(), omnimap_export(), omnimap_suppress() and
omnimap_signature() are there as under Symfony (Twig functions). In
a Blade view, print the printer's markup:
<div style="position: relative; height: 400px">
{!! app(\Omnimap\MapPrinter::class)->map($map, Gate::allows('ROLE_ADMIN')) !!}
</div>
The browser side is the same as everywhere (The browser).
Tested
omnibridge/laravel's Tests/MapTest.php (Orchestra Testbench): a page's Twig has omnimap(); the
picture is made by the server from OpenStreetMap's tiles (answered by a MockHttpClient bound as
omnimap.http_client) and kept on the omnidrive drive local.uploads (a local disk); its tiles
are served by the route (a 404 past the grid); a user allowed ROLE_ADMIN by the Gate gets the
export button. This page was written from that code and that test, not run in an application.
Proposer une modification docs/laravel.md
Concepts
Providers and capabilities
Omnimap\ProviderInterface is one map provider, configured. Every provider has:
| Method | Returns |
|---|---|
getName(): string |
the factory's name (google, openstreetmap, maplibre): also the provider of its descriptions and the driver the browser picks |
getTitle(): string |
its display name (Google Maps, OpenStreetMap, MapLibre), used in error messages |
capabilities(): Capabilities |
what it can be asked, and what it costs the visitor's privacy |
What a provider can be asked is a set of four contracts, each an interface extending
ProviderInterface. A provider implements those it can answer:
| Contract | Capability | Methods |
|---|---|---|
MapRendererInterface |
renderer: draws a map in the browser |
describe(MapView): array, fingerprint(MapView): string, attribution(): string |
StaticMapInterface |
staticMap: makes an image of a map on the server |
image(MapView, Size, int $scale = 1): Image |
GeocoderInterface |
geocoder: places from an address, addresses from a point |
geocode(string, ?string $locale = null, int $limit = 5): list<Place>, reverse(LatLng, ?string $locale = null, int $limit = 5): list<Place> |
TileSourceInterface |
tiles: square Web Mercator tiles, one URL each |
template(), url(int, int, int), tileSize(), maxZoom(), attribution(), headers() |
Implementing an interface is not enough to be asked: a provider built without the client a
capability needs (no HTTP client, no PSR-18 client, no key) still implements the interface but
declares the capability off, and its method throws NotSupportedException before any request.
The Registry's typed getters check the interface only; capabilities() is what to read
beforehand.
Capabilities
Omnimap\Capabilities is a final readonly value, built with named arguments:
| Property | Type | Default | |
|---|---|---|---|
renderer |
bool |
false |
it draws a map in the browser |
staticMap |
bool |
false |
it makes an image of a map on the server |
geocoder |
bool |
false |
it geocodes |
tiles |
bool |
false |
it gives tile URLs |
browserHosts |
list<string> |
[] |
the host names (no scheme, no path) a visitor's browser reaches when the map is drawn live: what a consent banner asks about and what a Content-Security-Policy must allow |
cookies |
bool |
false |
whether its browser script sets or reads cookies |
terms |
list<string> |
[] |
the https:// addresses of the terms whoever runs it must follow |
contracts(): list<string> lists the capabilities that are on, by name (renderer,
staticMap, geocoder, tiles); toArray() gives every property and contracts, ready for
JSON.
Factories and Config
Omnimap\ProviderFactoryInterface builds a provider from options:
| Method | |
|---|---|
getName(): string |
the name providers are configured with (factory: in a configuration) |
create(array $options = []): ProviderInterface |
a provider, its options checked |
Every provider's factory extends Omnimap\ProviderFactory, which implements both methods around
two of its own:
createConfig($options)makes aConfig(anArrayObject) holding the options given, then calls the factory'spopulate(Config $c), which sets its name, its title, its required options and the defaults of the others with$c->defaults([...])- only the keys the options did not set;create()checks the keys listed inomnimap.required_options: one missing,''or[]is anInvalidConfigExceptionnaming every missing key (The "acme" provider needs: api_key.);build(Config $c)returns the provider.
getName() is omnimap.factory_name of an empty configuration (the class name when there is
none). Building calls nothing: HTTP clients, PSR-18 clients and caches come through the factory's
own constructor, and a factory given none builds a provider with the capabilities that need them
off.
The reserved keys:
| Key | |
|---|---|
omnimap.factory_name |
the provider's name, snake_case or kebab-case (^[a-z0-9][a-z0-9_-]*$) |
omnimap.factory_title |
its display name |
omnimap.required_options |
list<string>: options that must be filled |
Config reads options as an environment variable may give them:
| Method | |
|---|---|
defaults(array $defaults): self |
sets each key not set yet |
validateNotEmpty(list<string> $keys): void |
step 2 above |
get(string $key, mixed $default = null): mixed |
the value, or $default when the key is not set |
string(string $key): ?string |
a scalar as a string; null when it is not set, not scalar, or '' |
int(string $key, int $default = 0): int |
a numeric value as an integer, else $default |
float(string $key, float $default = 0.0): float |
a numeric value as a float, else $default |
bool(string $key): bool |
true for true and for the strings 1, true, yes, on (any case, trimmed); false otherwise |
Registry
Omnimap\Registry holds the application's providers by name, each built once:
$registry = new Registry(
[new GoogleFactory($http, $psr18, $cache), new OpenStreetMapFactory($http, $psr18, $cache)], // iterable, keyed by getName()
[
'maps' => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0 (+https://example.org)']],
'places' => ['factory' => 'google', 'options' => ['server_key' => '...']], // options default to []
],
);
| Method | |
|---|---|
get(string $name): ProviderInterface |
the provider, built on the first call and kept |
has(string $name): bool |
whether a provider of that name is configured |
renderer(string $name): MapRendererInterface |
the provider, as a renderer |
staticMap(string $name): StaticMapInterface |
the provider, as a static map |
geocoder(string $name): GeocoderInterface |
the provider, as a geocoder |
tiles(string $name): TileSourceInterface |
the provider, as a tile source |
options(string $name): array |
the options it is configured with ([] for an unknown name) |
names(): list<string> |
the configured names, in the configuration's order |
factories(): list<string> |
the factories installed: what factory may name |
An unknown name (No "x" map provider; configured: maps, places.) and a factory that is not
installed (No "google" factory for the "places" map provider; installed: openstreetmap.) are
InvalidConfigExceptions. A typed getter on a provider that does not implement the interface is a
NotSupportedException (The "vector" provider (Omnimap\MapLibre\MapLibreProvider) does not make a map image.); it does not look at capabilities().
The contracts
MapRendererInterface
The map in the browser. describe(MapView $view): array returns plain, JSON-ready data that the
page's script draws with the provider's driver: no code, the same for a Twig page, a Laravel view
or a React component. Every description holds the envelope schema/map-description.schema.json
defines - provider, consent, view, attribution, and optionally library and options -
with the provider's own keys beside it (A live map).
fingerprint(MapView $view): string is the text everything a picture of the view depends on is
written in: two views with the same fingerprint must look the same. The map cache names a
picture by the fingerprint, signed (The map cache). Each provider
puts in it its name, the view's centre, zoom and markers, and what else changes its drawing
(the controls, the tile URL, the style, the view's options of its own).
attribution(): string is the attribution the map must show, as HTML: printed beside a kept
picture. It may be empty when the live map shows its own (Google).
StaticMapInterface
image(MapView $view, Size $size, int $scale = 1): Image - a map image made on the server: a
provider's static map service (Google's Static Maps) or its tiles put together
(Static\TileStitcher). A $scale of 2 gives an image of twice the density, $size x 2 pixels.
The image is no larger than asked: a provider with a limit (Google: 640 pixels a side at scale 1)
answers smaller, Image::$size says how large.
GeocoderInterface
geocode(string $address, ?string $locale = null, int $limit = 5): list<Place> - places for an
address, the best first, at most $limit; nothing found is an empty list, not an exception.
reverse(LatLng $position, ?string $locale = null, int $limit = 5): list<Place> - addresses at a
point. $locale is the language of the answer (en, ja), the provider's default when null.
Both go through geocoder-php (Geocoding).
TileSourceInterface
Square tiles of the Web Mercator grid, one URL each: what Leaflet draws and what TileStitcher
puts together.
| Method | |
|---|---|
template(): string |
the URL template, {z}, {x} and {y} in it (and {s} for a server's subdomains): the browser's library fills it |
url(int $zoom, int $x, int $y): string |
one tile's complete URL |
tileSize(): int |
in pixels: 256 nearly always (the conformance suite accepts 256 and 512) |
maxZoom(): int |
the deepest zoom served, 1 to 24 |
attribution(): string |
as HTML |
headers(): array<string, string> |
the headers a request for a tile must carry (a User-Agent naming the application); may throw when they cannot be given |
Models
All models are final readonly classes in Omnimap\Model. Coordinates are degrees (WGS 84),
sizes and positions pixels, zooms the Web Mercator zoom every provider shares (0 is the whole
world in one 256-pixel tile). Every model but Point and Image implements JsonSerializable
with the same array as its toArray().
LatLng
A point on Earth.
| Property | Type | |
|---|---|---|
lat |
float |
-90 to 90: anything else, or a value that is not finite, throws InvalidArgumentException |
lng |
float |
any finite value: 180.5 and -179.5 are the same meridian |
| Method | |
|---|---|
LatLng::fromArray(['lat' => ..., 'lng' => ...]) |
from an array; numeric strings accepted |
wrapped(): LatLng |
the same point, its longitude between -180 (included) and 180 (excluded); MapView::toArray(), hence every description, carries the centre and the markers wrapped, as schema/map-description.schema.json asks (new LatLng(10, 190) is described lng: -170) |
equals(LatLng $other, float $tolerance = 1e-9): bool |
both coordinates within the tolerance |
toArray(): array{lat, lng} |
|
__toString() |
"lat,lng" with at most 7 decimals and no trailing zeros (35.0116,135.7681), as URLs and geocoders write a point |
Bounds
A box on the map, from its south-west to its north-east corner. A box across the antimeridian has a western longitude greater than its eastern one.
| Property / method | |
|---|---|
southWest, northEast |
LatLng |
contains(LatLng $point): bool |
latitude between the corners; longitude compared wrapped, across the antimeridian too |
toArray() |
['south' => float, 'west' => float, 'north' => float, 'east' => float] |
Size and Point
Size(int $width, int $height): pixels, at least 1 each way (InvalidArgumentException
otherwise). Size::fromString('640x480') reads WIDTHxHEIGHT, scaled(int $scale) multiplies
both sides, (string) writes 640x480, jsonSerialize() gives ['width' => ..., 'height' => ...].
Point(float $x, float $y): a position in pixels, on the whole world at a zoom
(Projection\WebMercator) or on an image.
Marker
A pin on the map.
| Property | Type | Default | |
|---|---|---|---|
position |
LatLng |
- | |
title |
?string |
null |
shown on hover by the drivers that can (Leaflet's tooltip, Google's title, MapLibre's label) |
color |
?string |
null |
a CSS hex colour (#d33b3b): the dot's colour on an image made of tiles, Leaflet's and MapLibre's marker colour, Google's Static Maps marker colour; the Google live driver does not use it |
toArray() gives ['position' => [...], 'title' => ..., 'color' => ...].
MapView
What a map shows, whoever draws it.
| Property | Type | Default | |
|---|---|---|---|
center |
LatLng |
- | |
zoom |
int |
- | 0 to 24 (InvalidArgumentException otherwise) |
markers |
list<Marker> |
[] |
|
controls |
bool |
true |
whether the provider's own controls are shown (Google's default UI, Leaflet's zoom control, MapLibre's navigation control and its interactivity) |
options |
array<string, array> |
[] |
the options only one provider understands, under its name: ['google' => ['styles' => [...]]]; another provider ignores them |
id |
string |
'map' |
the page element the map is drawn into |
| Method | |
|---|---|
withMarker(Marker $marker): MapView |
a copy with one more marker |
withMarkers(list<Marker> $markers): MapView |
a copy with these markers instead |
withOptions(string $provider, array $options): MapView |
a copy with $options merged over that provider's options already set (array_replace) |
options(string $provider): array |
one provider's options ([] when none) |
toArray(): array |
['id', 'center', 'zoom', 'markers', 'controls']: the view of every description; options are not in it, each provider puts what it reads of them in its description's options |
Place and Address
A place a geocoder found.
| Property | Type | |
|---|---|---|
position |
LatLng |
|
label |
string |
the provider's one-line name of the place (Google's formatted address, Nominatim's display name), else the address's parts joined: street number + street, postcode + locality, region, country |
providedBy |
string |
geocoder-php's provider name (google_maps, nominatim): a service's terms often ask that its answers be shown with its name, and some forbid showing them on another provider's map |
address |
?Address |
|
bounds |
?Bounds |
the box the place covers, when the provider gives one |
id |
?string |
the provider's id of the place: Google's place_id; OpenStreetMap's object as type/id (relation/357794) |
Address holds what a geocoder says of the address, each part ?string, null when unknown:
streetNumber, streetName, postalCode, locality, subLocality, region (the first
administrative level), country (its name), countryCode.
Image
Image(string $bytes, string $mimeType, Size $size): an image a provider made, its media type as
the bytes are (image/png) and its size in pixels.
The projection
Omnimap\Projection\WebMercator is EPSG:3857, the projection of Google's, OpenStreetMap's and
nearly every web map's tiles: at zoom z the world is a square of 256 x 2^z pixels, x growing
eastward from the antimeridian, y southward from latitude 85.0511 (the square's edge).
| Member | |
|---|---|
TILE |
256 |
MAX_LATITUDE |
85.0511287798066: latitudes beyond are drawn on the edge |
worldSize(float $zoom): float |
the world's width at a zoom, in pixels |
point(LatLng $latLng, float $zoom): Point |
where a point lies on the world, in pixels |
latLng(Point $point, float $zoom): LatLng |
the point at a position (its longitude not wrapped) |
bounds(LatLng $center, int $zoom, Size $size): Bounds |
the box an image of that size shows around that centre |
The tile holding a point at zoom z is floor(point->x / 256), floor(point->y / 256) (Tiles).
The signer
Omnimap\Signer(?string $secret) signs with HMAC-SHA1, the secret given in URL-safe base64, the
signature in URL-safe base64: Google's rule for signing a Maps URL, and the rule
glitchr/ux-google named its captured maps by. An empty or null secret signs with an empty key,
as ux-google did.
| Method | |
|---|---|
sign(string $payload): string |
the signature without base64's = padding: the map cache's names (27 characters) |
signPadded(string $payload): string |
with its padding: what Google expects in a URL's signature= |
verify(string $payload, string $signature): bool |
constant-time comparison, padding ignored |
Signer::encode(string $bytes), Signer::decode(string $text) |
URL-safe base64 (- and _ for + and /) |
The map cache, in short
Snapshot\Snapshots names a view (signature()), finds its picture, makes it with a
StaticMapInterface or keeps one the browser captured; Snapshot\SnapshotStore cuts and keeps
pictures in a Snapshot\Storage; Snapshot\Snapshot describes one kept picture;
MapHtml and MapPrinter print the page's element. All of it is in The map cache.
Proposer une modification docs/concepts.md
A live map in the browser
A renderer (MapRendererInterface) turns a MapView into a description; the page carries it in a
data- attribute; assets/omnimap.js reads it and hands it to the provider's driver, which loads
the provider's library and draws the map once the visitor's consent allows it. PHP sends no code
to the browser and calls nothing: describing a view is plain data.
Describing a view
<?php
require __DIR__.'/vendor/autoload.php';
use Omnimap\MapHtml;
use Omnimap\MapLibre\MapLibreFactory;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Marker;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Registry;
$registry = new Registry([new OpenStreetMapFactory(), new MapLibreFactory()], [
'maps' => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0 (+https://example.org)']],
'vector' => ['factory' => 'maplibre'],
]);
$view = (new MapView(new LatLng(48.5818, 7.7509), 14, [new Marker(new LatLng(48.5818, 7.7509), 'Cathédrale', '#1a73e8')], id: 'map'))
->withOptions('openstreetmap', ['map' => ['scrollWheelZoom' => false]])
->withOptions('maplibre', ['map' => ['pitch' => 45]]);
$description = $registry->renderer('maps')->describe($view);
echo json_encode($description, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n\n";
echo (new MapHtml())->live('map', $description), "\n";
echo json_encode($registry->renderer('vector')->describe($view)['options'], JSON_UNESCAPED_SLASHES), "\n";
echo $registry->renderer('maps')->fingerprint($view), "\n";
{
"provider": "openstreetmap",
"consent": "MAP",
"view": {
"id": "map",
"center": {
"lat": 48.5818,
"lng": 7.7509
},
"zoom": 14,
"markers": [
{
"position": {
"lat": 48.5818,
"lng": 7.7509
},
"title": "Cathédrale",
"color": "#1a73e8"
}
],
"controls": true
},
"library": {
"script": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
"scriptIntegrity": "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=",
"style": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",
"styleIntegrity": "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
},
"tiles": {
"template": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"maxZoom": 19,
"tileSize": 256,
"attribution": "© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors"
},
"options": {
"map": {
"scrollWheelZoom": false
}
},
"attribution": "© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors"
}
<div id="map" data-omnimap="map" data-omnimap-description="{"provider":"openstreetmap","consent":"MAP","view":{"id":"map","center":{"lat":48.5818,"lng":7.7509},"zoom":14,"markers":[{"position":{"lat":48.5818,"lng":7.7509},"title":"Cathédrale","color":"#1a73e8"}],"controls":true},"library":{"script":"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js","scriptIntegrity":"sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=","style":"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css","styleIntegrity":"sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="},"tiles":{"template":"https://tile.openstreetmap.org/{z}/{x}/{y}.png","maxZoom":19,"tileSize":256,"attribution":"&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors"},"options":{"map":{"scrollWheelZoom":false}},"attribution":"&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors"}"></div>
{"map":{"pitch":45},"preserveDrawingBuffer":true}
openstreetmap|["https:\/\/tile.openstreetmap.org\/{z}\/{x}\/{y}.png",{"lat":48.5818,"lng":7.7509},14,[{"position":{"lat":48.5818,"lng":7.7509},"title":"Cathédrale","color":"#1a73e8"}],true]
(run on 2026-10-25 in the harness)
The same view went to two providers: each read its own options (openstreetmap's map,
maplibre's map) and ignored the other's. No factory was given an HTTP client: describing
needs none.
The description
What every renderer returns, as schema/map-description.schema.json (JSON Schema 2020-12) defines
it; Tests/MapDescriptionSchemaTest.php checks every installed renderer's description against it.
| Key | Type | Required | |
|---|---|---|---|
provider |
string, ^[a-z0-9][a-z0-9_-]*$ |
yes | the provider's name: the driver omnimap.js picks (register(name, driver)) |
consent |
string, not empty | yes | the consent feature the map waits for before reaching a third party: MAP for every provider today |
view |
object | yes | MapView::toArray(): id, center (lat -90..90, lng -180..180), zoom (0..24), markers (each position, title, color, the last two nullable), controls; no other key |
attribution |
string | yes | HTML shown beside a kept picture of the map; may be empty when the live map shows its own |
library |
object of strings | no | where the browser loads the provider's library: script, style, module, their integrity, a key |
options |
object | no | the provider's options for its library, from MapView::options(<provider>) |
Any other key is the provider's own (tiles for OpenStreetMap, style for MapLibre). The schema's
$id is https://raw.githubusercontent.com/glitchr-omnisense/omnimap/1.x/schema/map-description.schema.json.
Each provider's keys:
| Provider | library |
Own keys | options |
attribution |
|---|---|---|---|---|
google |
key (api_key), v (version), language, region - the empty ones left out |
- | styles (list, []), zoomStyles (object or null), map (object, {}) |
'': the live map draws Google's own |
openstreetmap |
script, scriptIntegrity, style, styleIntegrity (Leaflet; the empty ones left out) |
tiles: template, maxZoom, tileSize, attribution |
map (object, {}) |
the attribution option |
maplibre |
script, scriptIntegrity, style, styleIntegrity; or module, style, styleIntegrity when library_module is set |
style: the style's URL |
map (object, {}), preserveDrawingBuffer (bool) |
the attribution option |
A view's options of one provider
MapView::$options holds, under a provider's name, what only that provider understands; each
renderer reads its own and ignores the others. What each reads:
| Provider | Key | |
|---|---|---|
google |
styles |
a list of {name, rules: [{featureType, elementType, stylers: [{color: '#99d7fe'}]}], baseMapTypeId}: each becomes a google.maps.StyledMapType registered under its name (baseMapTypeId roadmap by default) |
google |
zoomStyles |
{default: <style name>, below: {4: <name>, 7: <name>}}: the map type shown, below giving the style shown under each zoom (the first stop above the current zoom wins); without below, default is applied once |
google |
map |
any other google.maps.MapOptions, as JSON, spread over the driver's center, zoom and disableDefaultUI |
openstreetmap |
map |
Leaflet's map options, spread over zoomControl and attributionControl |
maplibre |
map |
MapLibre's map options, spread over the driver's own, and part of the picture's fingerprint |
Google's Static Maps also reads styles: the rules of the zoomStyles.default style (the first
style when there is no default) are sent as style= parameters (Images).
Printing it
MapHtml::live(string $id, array $description): string prints
<div id="..." data-omnimap="map" data-omnimap-description="...">: the description as JSON
(slashes and Unicode unescaped), HTML-escaped into the attribute. The element is empty: give it a
height in CSS. mountAll() then draws every such element under a root
(The browser).
In Symfony and Laravel, the Twig function omnimap(view) prints it (or the cached picture, see
The map cache).
Consent
A live map loads a third party's script and tiles from the visitor's browser: the hosts are in
capabilities()->browserHosts (for a consent banner and a Content-Security-Policy), and the
driver runs only through the consent(feature, load) gate given to mountAll(), with the
description's consent as feature. Without a gate, the map loads at once. omnibase/consent's
Consent.use('MAP', {}, load) is one such gate. A page that must reach no third party at all
shows the cached picture instead (The map cache).
The fingerprint
fingerprint(MapView) is the text a picture of the view is named after, once signed. What each
provider puts in it:
| Provider | Fingerprint |
|---|---|
google |
|
openstreetmap |
`openstreetmap |
maplibre |
`maplibre |
test (TestTiles) |
`test |
The view's id is in none: the same view printed in two elements has one picture.
Proposer une modification docs/usage-rendering.md
Images made on the server
A static map (StaticMapInterface) makes an image of a view on the server: for an e-mail, a PDF,
a page that must reach no third party, or the map cache, which keeps such an image as
the view's picture. Two ways exist today: a provider's static map service (Google's Static Maps)
and tiles put together by Omnimap\Static\TileStitcher (OpenStreetMap, any tile source).
An image
<?php
require __DIR__.'/vendor/autoload.php';
use Omnimap\Google\GoogleFactory;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Marker;
use Omnimap\Model\Size;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Testing\ArrayCache;
use Omnimap\Testing\PlainTiles;
use Omnimap\Testing\RecordedHttpClient;
// Tiles answered here (PlainTiles); HttpClient::create() in production.
$http = new RecordedHttpClient(PlainTiles::answer());
$cache = new ArrayCache();
$osm = (new OpenStreetMapFactory($http, null, $cache))->create(['user_agent' => 'MySite/1.0 (+https://example.org; maps@example.org)']);
$view = new MapView(new LatLng(35.0116, 135.7681), 12, [new Marker(new LatLng(35.0036, 135.7780), 'Gion')]);
$image = $osm->image($view, new Size(640, 400), 2);
file_put_contents(sys_get_temp_dir().'/kyoto.png', $image->bytes);
echo $image->mimeType, ' ', $image->size, ', ', count($http->requests), " tiles fetched\n";
echo $http->requests[0]['url'], ' ', $http->requests[0]['options']['headers']['User-Agent'], "\n";
echo 'kept ', $cache->ttls[array_key_first($cache->ttls)], " s\n";
$osm->image($view, new Size(640, 400), 2);
echo count($http->requests), " tiles fetched after a second image\n";
$google = (new GoogleFactory())->create(['api_key' => 'browser-key', 'server_key' => 'server-key', 'secret' => 'vNIXE0xscrmjlyV-12Nj_BvUPaw=']);
echo $google->staticUrl($view, new Size(1512, 938), 2), "\n";
image/png 1280x800, 30 tiles fetched
https://tile.openstreetmap.org/13/7182/3242.png MySite/1.0 (+https://example.org; maps@example.org)
kept 695880 s
30 tiles fetched after a second image
https://maps.googleapis.com/maps/api/staticmap?center=35.0116,135.7681&zoom=12&size=640x397&scale=2&markers=35.0036%2C135.778&key=server-key&signature=rUXZxBfvqM0gblKazD4BVDZ7KaQ=
(run on 2026-10-25 in the harness; the tiles came from PlainTiles with
OpenStreetMap's own Cache-Control of 2026-10-23, max-age=91080, stale-while-revalidate=604800)
At scale 2 the stitcher took the tiles of zoom 13 for a view at zoom 12, and kept each tile for
max-age plus stale-while-revalidate (91080 + 604800 = 695880 seconds); the second image fetched
nothing. Google's URL was built without any request: staticUrl() is public.
Tiles put together: TileStitcher
new TileStitcher(TileSourceInterface $tiles, HttpClientInterface $http, ?CacheInterface $cache = null, string $background = '#aad3df', string $markerColor = '#d33b3b'),
then image(MapView $view, Size $size, int $scale = 1): Image:
- the scale is kept between 1 and 2; the zoom is the view's plus
scale - 1, at most the source'smaxZoom(); the image is$sizex scale pixels, centred on the view's centre; - a 512-pixel source (
tileSize()512) numbers its tiles as such servers do, 2^z a side at zoomz(Leaflet'szoomOffset: -1): the tiles of the zoom below are taken (a view at zoom 1 is the one tile0/0/0), and at zoom 0 that tile is drawn at half its size; - every tile the image covers is listed once; above and below the world (
youtside the grid) thebackgroundcolour shows; east and west the world repeats (xtaken modulo the grid); - tiles found in the PSR-16 cache (key
omnimap.tile.+ SHA-1 of the URL) are not fetched; the others are requested all at once (the HTTP client runs them side by side) with the source'sheaders(); each answer is kept for max-age plus stale-while-revalidate of itsCache-Control, or 604800 seconds (7 days) when it gives neither (TileStitcher::lifetime(), public); - a tile answered with a status other than 200 is a
ProviderException, a transport failure anUnreachableException; the tiles still on their way are cancelled. A body GD cannot read as an image is aProviderExceptiontoo; - each marker is drawn as a dot of radius 6 x scale in its
color(elsemarkerColor), ringed in white; the marker'stitleis not drawn; - the source's attribution, its tags stripped and its entities decoded, is written in the
bottom-right corner on a translucent white band with GD's built-in font (
©written(c), other non-ASCII characters dropped); - the result is a PNG.
Google Static Maps
GoogleProvider::image() fetches staticUrl($view, $size, $scale):
sizeclamped to 640 pixels on the longer side, the ratio kept (1512 x 938 becomes 640 x 397);scalekept between 1 and 2;- one
markers=per marker,color:0x...|before its position when it has a colour; - one
style=per rule of the view's default Google style (A live map); languageandregionwhen set, thenkey(theserver_key, else theapi_key; neither is anInvalidConfigException), and with asecretthesignature=of the path and query, HMAC-SHA1 padded (Google's URL signing).
The answer must be 200 and its bytes an image (their media type is read from the bytes, not from
the Content-Type): anything else is a ProviderException, a transport failure an
UnreachableException. Image::$size is the clamped size times the scale.
Without an HTTP client
A provider built without its HTTP client declares staticMap off and its image() throws
NotSupportedException before any request. omnimap/maplibre makes no image at all: vector
tiles are drawn by the browser's WebGL. Its picture reaches the map cache only through the
browser's capture (The map cache).
Proposer une modification docs/usage-static.md
Geocoding
A geocoder (GeocoderInterface) finds places from an address (geocode()) and addresses from a
point (reverse()). omnimap does not read a geocoding API itself: each provider puts one of
geocoder-php's providers (willdurand/geocoder) behind
Omnimap\Geocoding\GeocoderPhp, which turns its answers into omnimap's Places, keeps them in a
PSR-16 cache and paces the calls. The providers that geocode take a PSR-18 client for
geocoder-php, beside their Symfony HTTP client.
Places from an address, an address from a point
<?php
require __DIR__.'/vendor/autoload.php';
use Omnimap\Google\GoogleFactory;
use Omnimap\Model\LatLng;
use Omnimap\Testing\ArrayCache;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpClient\Response\MockResponse;
// Google's answer, written here; HttpClient::create() in production.
$answer = json_encode(['status' => 'OK', 'results' => [[
'formatted_address' => 'Osaka, Japan',
'place_id' => 'ChIJ4eIGNFXmAGAR5y9q5G7BW8U',
'address_components' => [
['long_name' => 'Osaka', 'short_name' => 'Osaka', 'types' => ['locality', 'political']],
['long_name' => 'Japan', 'short_name' => 'JP', 'types' => ['country', 'political']],
],
'geometry' => ['location' => ['lat' => 34.6937378, 'lng' => 135.5021651], 'location_type' => 'APPROXIMATE'],
'types' => ['locality', 'political'],
]]]);
$requests = 0;
$http = new MockHttpClient(function (string $method, string $url) use ($answer, &$requests) {
++$requests;
echo "GET ", preg_replace('~key=[^&]+~', 'key=…', $url), "\n";
return new MockResponse($answer, ['response_headers' => ['content-type' => 'application/json']]);
});
$google = (new GoogleFactory($http, new Psr18Client($http), new ArrayCache()))->create(['server_key' => 'server-key', 'language' => 'en']);
foreach ($google->geocode('Osaka', limit: 1) as $place) {
echo $place->label, ' ', $place->position, ' ', $place->address->countryCode, ' ', $place->id, ' (', $place->providedBy, ")\n";
}
$google->geocode('Osaka', limit: 1);
echo $requests, " request\n";
echo count($google->reverse(new LatLng(34.6937, 135.5022), 'ja', 1)), " place\n";
GET https://maps.googleapis.com/maps/api/geocode/json?address=Osaka&language=en&key=…
Osaka, Japan 34.6937378,135.5021651 JP ChIJ4eIGNFXmAGAR5y9q5G7BW8U (google_maps)
1 request
GET https://maps.googleapis.com/maps/api/geocode/json?latlng=34.693700,135.502200&language=ja&key=…
1 place
(run on 2026-10-25 in the harness, with nyholm/psr7 installed for
Psr18Client; the answer is written here, Google was not called)
The second geocode('Osaka') was answered by the cache. The locale given (ja) went to Google
instead of the provider's language. In production the client is HttpClient::create() (or the
application's) and the cache the application's PSR-16 pool.
What GeocoderPhp does
new GeocoderPhp(Geocoder\Provider\Provider $provider, ?CacheInterface $cache = null, int $ttl = 2592000, float $interval = 0.0):
geocode($address, $locale, $limit)asksGeocodeQuery::create($address)->withLimit($limit)(with the locale when notnull);reverse($position, $locale, $limit)asksReverseQuery::fromCoordinates();- the cache: the answer is kept under
omnimap.geocoder.+ SHA-1 of the provider's name, the question (geocode|<address>orreverse|<lat,lng>), the locale and the limit, for$ttlseconds (30 days by default: Nominatim's policy asks for results cached on the caller's side). A cached answer is returned without waiting and without a call; an empty answer is kept too; - the pace: before a call, it waits until
$intervalseconds have passed since the end of the previous call of this object (usleep). It paces one PHP process; across processes, a shared cache is what keeps the count down; - the answer: each geocoder-php
Locationwith coordinates becomes aPlace(see Concepts); one without coordinates is dropped; - the errors: a PSR-18
NetworkExceptionInterfaceis anUnreachableException; geocoder-php's own exceptions (an error status, an invalid answer, a quota) and other PSR-18 client exceptions are aProviderException; aTypeError,ValueErrororJsonExceptionraised while geocoder-php reads a body it did not expect is aProviderExceptiontoo. Each message names the provider and the question.
GeocoderPhp::place(Location $location): ?Place is public: it converts one geocoder-php answer.
The providers that geocode
| Provider | geocoder-php provider | Needs | Locale | Pace |
|---|---|---|---|---|
google |
Geocoder\Provider\GoogleMaps\GoogleMaps (geocoder-php/google-maps-provider), with region and the server key |
a PSR-18 client, and server_key or api_key |
the call's, else the language option |
none |
openstreetmap |
Geocoder\Provider\Nominatim\Nominatim (geocoder-php/nominatim-provider), at nominatim with user_agent and referer |
a PSR-18 client, a user_agent, a non-empty nominatim URL |
the call's | nominatim_interval (1 second) |
maplibre |
- | - | - | - |
Both keep answers for geocoder_ttl seconds (30 days) in the cache given to their factory's
constructor (none: no cache). Without what they need, capabilities()->geocoder is false and
geocode() / reverse() throw NotSupportedException before any request.
A geocoder of one's own choice
Any geocoder-php provider can sit behind GeocoderPhp (Here, Mapbox, Photon, a chain of several);
GeocoderPhp is not a GeocoderInterface itself (it is not a provider: it has no name, no
capabilities). A provider package exposes it: omnimap/openstreetmap's provider takes any
GeocoderPhp in its constructor, which is also how an application's test gives it a fixed
answer (Testing).
use Geocoder\Provider\Photon\Photon; // geocoder-php/photon-provider
use Omnimap\Geocoding\GeocoderPhp;
use Omnimap\OpenStreetMap\OpenStreetMapProvider;
$geocoder = new OpenStreetMapProvider(geocoder: new GeocoderPhp(Photon::withKomootServer($psr18), $cache, 86400, 1.0));
$geocoder->geocode('Strasbourg');
(not run: geocoder-php/photon-provider is not installed in the harness)
Terms
Nominatim's policy (https://operations.osmfoundation.org/policies/nominatim/): at most one request
a second, an identifying User-Agent or Referer, results cached, no autocomplete, no systematic
queries. Google's terms (https://cloud.google.com/maps-platform/terms) restrict caching
Geocoding results and showing them on a map other than Google's: Place::$providedBy says whose
answer a place is.
Proposer une modification docs/usage-geocoding.md
Tiles
A tile source (TileSourceInterface) gives the square tiles of the Web Mercator grid, one URL
each: what a browser library draws (Leaflet fills template() itself) and what
Static\TileStitcher fetches on the server. Of the providers, omnimap/openstreetmap is one, over
any raster tile server; the core's Testing\TestTiles is another.
<?php
require __DIR__.'/vendor/autoload.php';
use Omnimap\Model\LatLng;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Projection\WebMercator;
use Omnimap\Registry;
$registry = new Registry([new OpenStreetMapFactory()], [
'osm' => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0 (+https://example.org)']],
]);
$tiles = $registry->tiles('osm');
$point = WebMercator::point(new LatLng(35.0116, 135.7681), 12);
[$x, $y] = [intdiv((int) $point->x, $tiles->tileSize()), intdiv((int) $point->y, $tiles->tileSize())];
echo $tiles->template(), "\n";
echo $tiles->url(12, $x, $y), "\n";
echo $tiles->tileSize(), ' ', $tiles->maxZoom(), ' ', json_encode($tiles->headers()), "\n";
https://tile.openstreetmap.org/{z}/{x}/{y}.png
https://tile.openstreetmap.org/12/3592/1622.png
256 19 {"User-Agent":"MySite\/1.0 (+https:\/\/example.org)"}
(run on 2026-10-25 in the harness; nothing was fetched)
What a tile source answers
template(): the URL with{z},{x},{y}(and{s}, a server's subdomain, when the server has several). Leaflet fills it in the browser; the description carries it.url($zoom, $x, $y): one tile's complete URL.omnimap/openstreetmapreplaces{s}witha,borcby(x + y) mod 3, so a tile always has the same address (and the same cache entry).tileSize(): 256 for nearly every server; 512 for a server of 512-pixel tiles numbered 2^z a side at zoomz, whichTileStitcherandleaflet.jsread at the zoom below the view's (Leaflet'szoomOffset: -1).maxZoom(): the deepest zoom served;TileStitchernever asks beyond it: a deeper view is made at that zoom, its tiles not enlarged, so the image shows a wider area than the view.attribution(): HTML, the attribution the tiles' licence asks for.headers(): what every server-side request for a tile carries.omnimap/openstreetmapgives itsuser_agentand, when set, itsreferer; without auser_agentit throwsInvalidConfigException, so that no tile is fetched with a library's default User-Agent (the OpenStreetMap Foundation's tile policy blocks those). Without one itscapabilities()->staticMapis off andimage()throwsNotSupportedException.
Fetching a tile oneself
The URLs are plain: any HTTP client fetches them with headers(). TileStitcher already fetches,
caches and puts tiles together; fetch one directly only to proxy tiles, and then keep the server's
caching headers. The OpenStreetMap Foundation's policy forbids bulk downloads and prefetching, and
asks heavy users for another provider or their own server
(https://operations.osmfoundation.org/policies/tiles/).
Proposer une modification docs/usage-tiles.md
The map cache
A map drawn live costs the visitor a third party's script and tiles - their address sent to Google or to a tile server, a consent to ask, a quota spent. The cache shows a picture of the map instead: made once, kept in the host's storage, served by the host. It is the cache glitchr/ux-google kept for lesringostars, made independent of the provider: the same for Google, OpenStreetMap and MapLibre, only the source of the picture differs.
A whole example
<?php
require __DIR__.'/vendor/autoload.php';
use Omnimap\MapHtml;
use Omnimap\MapPrinter;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Signer;
use Omnimap\Snapshot\DirectoryStorage;
use Omnimap\Snapshot\Snapshots;
use Omnimap\Snapshot\SnapshotStore;
use Omnimap\Testing\PlainTiles;
use Omnimap\Testing\RecordedHttpClient;
use Omnimap\Testing\TestTiles;
$renderer = new TestTiles(new RecordedHttpClient(PlainTiles::answer())); // a renderer that makes no image here
$dir = sys_get_temp_dir().'/maps';
$store = new SnapshotStore(new DirectoryStorage($dir), 'omnimap', '{signature}/image-{id}', 'png');
$snapshots = new Snapshots($renderer, new Signer('c2VjcmV0'), $store); // no StaticMapInterface: pictures come from the browser
$printer = new MapPrinter($snapshots, new MapHtml(), cacheOnly: true);
$view = new MapView(new LatLng(35.0116, 135.7681), 12, id: 'map');
$signature = $snapshots->signature($view);
echo $printer->map($view), "\n"; // a visitor, nothing kept yet: an empty frame
echo substr($printer->map($view, mayChange: true), 0, 40), "...\n"; // whoever may change it: the live map
echo $printer->exportButton($view, true, "/omnimap/$signature/export", 'token', 'Save'), "\n";
// What the export route receives from capture.js: a data: URL.
$capture = 'data:image/png;base64,'.base64_encode(PlainTiles::png(12, 0, 0, 1000));
$snapshot = $snapshots->save($signature, $capture, 512);
echo json_encode($snapshot), "\n";
echo implode(' ', array_map('basename', glob("$dir/omnimap/$signature/*"))), "\n";
echo $printer->map($view), "\n"; // now the picture, for everyone
echo $printer->suppressButton($view, true, "/omnimap/$signature/suppress", 'token', 'Delete'), "\n";
$snapshots->delete($signature);
var_dump($snapshots->find($view));
<div id="map">
<div class="omnimap-snapshot" id="map_snapshot" data-omnimap="snapshot" data-src="/omnimap/%7Bsignature%7D/%7Bid%7D.webp" data-signature="0xkZGhEUccy1p6qE0cE-IRmuZgg" data-tilesize="" data-xtiles="0" data-ytiles="0" data-missing="" width="0" height="0"></div>
<div class="omnimap-attribution" style="position:absolute;right:0;bottom:0;z-index:1;padding:0 5px;font:11px/1.5 sans-serif;color:#333;background:rgba(255,255,255,.75)">© Test tiles</div>
</div>
<div id="map" data-omnimap="map" data-om...
<button type="button" data-omnimap-export="/omnimap/0xkZGhEUccy1p6qE0cE-IRmuZgg/export" data-omnimap-target="map" data-omnimap-token="token">Save</button>
{"signature":"0xkZGhEUccy1p6qE0cE-IRmuZgg","width":1000,"height":1000,"tilesize":512,"xtiles":2,"ytiles":2}
image-0.png image-1.png image-2.png image-3.png metadata.txt
<div id="map">
<div class="omnimap-snapshot" id="map_snapshot" data-omnimap="snapshot" data-src="/omnimap/%7Bsignature%7D/%7Bid%7D.webp" data-signature="0xkZGhEUccy1p6qE0cE-IRmuZgg" data-tilesize="512" data-xtiles="2" data-ytiles="2" data-missing="" width="1000" height="1000"></div>
<div class="omnimap-attribution" style="position:absolute;right:0;bottom:0;z-index:1;padding:0 5px;font:11px/1.5 sans-serif;color:#333;background:rgba(255,255,255,.75)">© Test tiles</div>
</div>
<button type="button" data-omnimap-suppress="/omnimap/0xkZGhEUccy1p6qE0cE-IRmuZgg/suppress" data-omnimap-target="map" data-omnimap-token="token">Delete</button>
NULL
(run on 2026-10-25 in the harness)
A name per view
Snapshots::signature($view) = Signer::sign($renderer->fingerprint($view)): HMAC-SHA1 of what
the picture depends on, with the host's secret (URL-safe base64, as Google gives its URL signing
secret), in URL-safe base64 without padding - 27 characters. Change the centre, the zoom, the
markers (all providers, but Google with legacy_signature) or the provider, and the name
changes: a new picture is needed, the old one is not served for the new view. What each provider
puts in its fingerprint is listed in A live map.
omnimap/google's legacy_signature option fingerprints a view as ux-google did (the Map's class
and its zoom, center, html2canvas, disableDefaultUI options; markers and styles not
included), so the pictures it captured keep their names.
A signature is checked before it reaches a storage path: only [A-Za-z0-9_-]{1,128} is accepted
(InvalidArgumentException otherwise), so no / or .. gets in.
The layout in the storage
SnapshotStore($storage, $directory, $pattern, $format):
<directory>/<signature>/image-<n>.<format> the tiles, n = y * xtiles + x
<directory>/<signature>/metadata.txt serialize(['status' => 'OK', 'image_width' => int,
'image_height' => int, 'image_tilesize' => '512',
'image_xtiles' => float, 'image_ytiles' => float])
metadata.txt keeps ux-google's keys and types (the tile size a string, the counts floats), so
a capture it wrote is read as it is and one omnimap writes is read by ux-google. Tiles are squares
of tilesize (512), the last row and column padded with transparency; with a tile size of null
the picture is kept whole as tile 0.
| Argument | Default | |
|---|---|---|
$storage |
- | a Snapshot\Storage |
$directory |
omnimap |
the folder under the storage's root; '' for the root itself (lesringostars keeps google) |
$pattern |
{signature}/image-{id} |
a tile's path under $directory, without its extension |
$format |
png |
png, jpeg, jpg or webp (else InvalidArgumentException): the tiles' encoding (JPEG at quality 92, WebP at 90) and their extension |
| Method | |
|---|---|
find(string $signature): ?Snapshot |
the picture kept: its metadata.txt read and unserialized (no classes allowed); null when it is missing, unreadable or its status is not OK |
tile(string $signature, int $id): ?string |
a tile's bytes in the store's format; null when there is none (or $id < 0) |
save(string $signature, string $image, ?int $tilesize = 512): Snapshot |
cuts an image GD can read into tiles (or keeps it whole with null or a size below 1), replacing what was kept under that name; not an image: InvalidArgumentException |
delete(string $signature): void |
the picture's folder, tiles and metadata |
format(), tilePath($signature, $id), metadataPath($signature) |
the format, and where a tile and the metadata are |
What find() answered is remembered for the store's life (one storage round trip per question:
on S3 each is an HTTP request); save() and delete() update it.
Snapshot is the kept picture: signature, width, height (pixels), tilesize (?int,
null for a whole picture), xtiles, ytiles; tiles() is their product, metadata() the
array written to metadata.txt, Snapshot::fromMetadata($signature, $array) reads one back
(missing counts read as 1, a missing size as 0).
The storage is an interface of four methods (Snapshot\Storage: read, write, exists,
deleteDirectory); paths are relative and /-separated:
| Storage | Constructor | |
|---|---|---|
DirectoryStorage |
(string $root) |
a directory of the server's disk; directories created 0775; a path holding .. is an \InvalidArgumentException |
FlysystemStorage |
(League\Flysystem\FilesystemOperator $filesystem) |
any Flysystem 3 storage (local, S3, SFTP...): the operator a host already has; needs league/flysystem |
DriveStorage |
(Omnidrive\Drive $drive) |
an omnisense/omnidrive Drive: the storage an application names, wherever it is; needs omnisense/omnidrive |
| the host's own | implements Storage |
read() returns null for a missing file, deleteDirectory() does nothing for a missing folder |
Where pictures come from
- The browser: whoever may change the map sees the live map and an export button
(
MapHtml::button('export', ...),assets/capture.js): html2canvas draws the element, the picture is posted as adata:URL (image,tilesize,_token) to the host's export route, which checks the right and the token and callsSnapshots::save($signature, $image). That is how Google's map is cached: Google's Static Maps are limited to 640 pixels a side, and its terms limit what may be kept of its content - whoever caches it answers for that. MapLibre's map keeps its drawing buffer so that it can be captured the same way. - The server: given a
StaticMapInterface,Snapshots::make($view)(orfindOrMake()) asks the provider for an image ofsizexscaleand cuts it. omnimap/openstreetmap makes it from its tile server's tiles (Static\TileStitcher): a 1512 x 938 picture at scale 1 is 35 tiles, fetched once with the application's User-Agent and kept as long as the server's Cache-Control allows.
new Snapshots(MapRendererInterface $renderer, Signer $signer, SnapshotStore $store, ?StaticMapInterface $staticMap = null, Size $size = new Size(1512, 938), int $scale = 1, ?int $tilesize = 512):
| Method | |
|---|---|
signature(MapView $view): string |
the view's name |
find(MapView $view): ?Snapshot |
the picture kept, or null |
canMake(): bool |
whether a static map was given |
make(MapView $view): Snapshot |
the picture made now by the server and kept, replacing any; without a static map a NotSupportedException (a \LogicException) |
findOrMake(MapView $view): ?Snapshot |
the picture kept; made now when there is none and the server can make one; else null |
save(string $signature, string $image, ?int $tilesize = null): Snapshot |
a picture the browser captured - an image's bytes, or a data: URL (its base64 after the comma, decoded strictly) - kept under the signature, cut in $tilesize (the constructor's when null) |
delete(string $signature): void |
the picture forgotten |
store(): SnapshotStore, renderer(): MapRendererInterface |
save() takes the signature the page posts, not a view: the host's route must check who posts
(the bridges allow control, ROLE_ADMIN, with a CSRF token), since anyone who can post may
replace the picture of any name.
The page: MapPrinter
MapPrinter decides what a page shows of a view, whatever the host - glitchr/ux-google's rule
(cache_only, cache_control), the provider aside:
- the picture kept, when there is one - made now by the server when it can (
findOrMake()); - else, when
$cacheOnly(the default) and the viewer may not change the map: the empty frame (no picture, no tile fetched, no third party reached) until a picture is captured; - else the live map.
new MapPrinter(Snapshots $snapshots, MapHtml $html = new MapHtml(), bool $cacheOnly = true, ?\Closure $onError = null):
| Method | |
|---|---|
map(MapView $view, bool $mayChange = false): string |
the element, by the rule above |
exportButton(MapView $view, bool $mayChange, string $url, string $token, string $contents, array $attributes = []): string |
the export button when $mayChange, a picture kept or not (the next capture replaces a kept one), else '' |
suppressButton(...) (same arguments) |
the delete button when $mayChange and a picture is kept, else '' |
signature(MapView $view): string, snapshots(): Snapshots |
A picture the server could not make (a tile server down, a missing user_agent) is handed to
$onError (the bridges log it as a warning) and the page shows no picture: map() never throws
for it. Whether the viewer may change the map is the host's to say (omnibridge's Twig extension
asks its Access for control).
The markup: MapHtml
new MapHtml(array $options = []), options class (omnimap-snapshot), suffix (_snapshot),
src (/omnimap/{signature}/{id}.webp) and missing ('') - the host's own names where it has
older ones to keep (lesringostars: class google-tilemap, suffix _html2canvas, src
/google/{signature}/{id}.webp).
snapshot(string $id, string $signature, ?Snapshot $snapshot, string $attribution = '') prints the
element $id holding the frame $id.$suffix and, when not empty, the attribution in a small
absolutely positioned band (bottom right). The frame's attributes:
| Attribute | Value |
|---|---|
class |
the class option |
data-omnimap |
snapshot |
data-src |
the src option, { and } written %7B and %7D (snapshot.js decodes them) |
data-signature |
the view's signature |
data-tilesize |
the picture's tile size; '' for a whole picture or none |
data-xtiles, data-ytiles |
the grid; 0 when there is no picture (nothing is laid out, nothing fetched) |
data-missing |
the missing option |
width, height |
the picture's size; 0 when there is none |
live(string $id, array $description) prints the live map's element
(A live map). button(string $action, string $id, string $url, string $token, string $contents, array $attributes = []) prints a type="button" with
data-omnimap-export (or data-omnimap-suppress when $action is suppress) set to $url,
data-omnimap-target set to the map's element and data-omnimap-token, the extra attributes
first. $contents is printed as it is: escape it.
The element is laid out by assets/snapshot.js and its tiles bound by assets/capture.js
(The browser); give the outer element a size and position: relative in CSS so
that the frame and the attribution band sit in it.
Serving
The page prints MapHtml::snapshot($id, $signature, $snapshot, $attribution): a frame with the
picture's size, its grid and the URL template of a tile (/omnimap/{signature}/{id}.webp by
default; a host keeps its own, lesringostars keeps /google/{signature}/{id}.webp).
assets/snapshot.js lays the tiles out to cover the frame and loads each when it is on screen.
The host's route reads SnapshotStore::tile($signature, $id) and answers the image:
omnibridge/symfony converts it to WebP when the route's path ends in .webp and may write it
under the public folder, so that the web server serves it without PHP the next time;
omnibridge/laravel serves it as kept, with its own media type (Symfony,
Laravel). No picture yet: the frame has no tile, nothing is fetched.
A route of one's own, in plain PHP:
// GET /omnimap/{signature}/{id}.webp
$bytes = $snapshots->store()->tile($signature, (int) $id); // InvalidArgumentException for a bad signature
if (null === $bytes) {
http_response_code(404);
exit;
}
header('Content-Type: image/'.('jpg' === $snapshots->store()->format() ? 'jpeg' : $snapshots->store()->format()));
header('Cache-Control: public, max-age=86400');
echo $bytes;
Expiry
None by time: a picture is kept until it is deleted (Snapshots::delete(), the suppress button,
or a new capture under the same name, which replaces it). A view that changes gets a new name;
the old picture stays in the storage until deleted. The tiles a server-made picture was made of
are kept in the PSR-16 cache for their own lifetime; the picture itself does not refresh: delete
it to make it again from fresh tiles.
Proposer une modification docs/cache.md
The browser
Three ES modules, no library, no build step needed (a bundler takes them as they are):
assets/omnimap.js draws a live map with a provider's driver, assets/snapshot.js lays a kept
picture's tiles out, assets/capture.js sends a live map's picture to the cache. Each provider
package ships its driver: omnimap/google's assets/google.js, omnimap/openstreetmap's
assets/leaflet.js, omnimap/maplibre's assets/maplibre.js. Installed with Composer, they sit
under vendor/omnisense/omnimap/assets/ and vendor/omnimap/<provider>/assets/: copy them to
the public folder, map them in an import map, or let the application's bundler take them.
import { register, mountAll } from '/assets/omnimap/omnimap.js';
import google from '/assets/omnimap/google.js';
import leaflet from '/assets/omnimap/leaflet.js';
import maplibre from '/assets/omnimap/maplibre.js';
import { autoInit } from '/assets/omnimap/snapshot.js';
import { bindCapture } from '/assets/omnimap/capture.js';
register('google', google);
register('openstreetmap', leaflet);
register('maplibre', maplibre);
mountAll(document, { consent: (feature, load) => window.Consent ? Consent.use(feature, {}, load) : load() });
autoInit();
bindCapture(document, { html2canvas: window.html2canvas });
(the paths are where the host serves the files)
omnimap.js - a live map
import { register, mountAll } from '.../omnimap/assets/omnimap.js';
import google from '.../omnimap/google/assets/google.js';
import leaflet from '.../omnimap/openstreetmap/assets/leaflet.js';
register('google', google);
register('openstreetmap', leaflet);
mountAll(document, { consent: (feature, load) => window.Consent ? Consent.use(feature, {}, load) : load() });
mountAll() draws every [data-omnimap="map"] element (MapHtml::live()) from its
data-omnimap-description. A driver is { draw(element, description, options) } returning the
map; it gets loadScript() and loadStyle() in its options. The third party (Google's API,
Leaflet and its tile server) is loaded through consent(feature, load), the feature being the
description's consent (MAP): with omnibase/consent, Consent.use('MAP', ...).
| Export | |
|---|---|
register(provider, driver) |
the driver of a provider's descriptions, by the provider's name (the description's provider); a second call replaces the first |
draw(element, description, options = {}) |
draws one description in an element: a Promise of the driver's map |
mountAll(root = document, options = {}) |
draws every [data-omnimap="map"] under root, once each: a Promise of all |
loadScript(src, attributes = {}) |
adds <script async src> to the head once per src; a Promise resolved when it has run |
loadStyle(href, attributes = {}) |
adds <link rel="stylesheet"> once per href |
draw():
- no driver registered for
description.provider: the promise is rejected (omnimap: no driver registered for "x".); - the gate is
options.consent, called asconsent(description.consent || 'MAP', load); without one,loadruns at once. Until the gate callsload, nothing is loaded and the promise stays pending; loadcallsdriver.draw(element, description, {...options, loadScript, loadStyle}); when the map is there, the element dispatchesomnimap:ready(aCustomEventthat bubbles,detail: { map, description }) and the promise resolves with the map.
mountAll() marks each element data-omnimap-mounted="1" (a second call skips it), logs an
unreadable data-omnimap-description to the console and skips the element, and on a failed draw
logs the error and hides the element (display: none).
loadScript() sets each attribute whose value is truthy (an integrity, a crossorigin), gives
the script the nonce of the page's first script[nonce] (a Content-Security-Policy with nonces),
and rejects with omnimap: <src> could not load. on an error. loadStyle() does nothing when a
stylesheet of that href is already in the document.
document.addEventListener('omnimap:ready', (event) => {
const { map, description } = event.detail; // a google.maps.Map, a Leaflet map, a maplibregl.Map
console.log(description.provider, 'drawn in', event.target.id);
});
The drivers
A driver is a module whose default export is { draw }; draw(element, description, options)
returns the map (or a promise of it). The three shipped:
google.js |
leaflet.js |
maplibre.js |
|
|---|---|---|---|
| library loaded | Google's dynamic library loader (importLibrary('maps'), importLibrary('marker')) from https://maps.googleapis.com/maps/api/js, with description.library's keys as parameters (camelCase written snake_case) and the page's nonce; reused when google.maps.importLibrary exists |
library.style (with its integrity, crossorigin="anonymous" when it has one) and library.script, unless window.L exists; no script: omnimap: no Leaflet to load. |
library.style, then library.module (a dynamic import()) or library.script, unless window.maplibregl exists; neither: omnimap: no MapLibre GL JS to load. |
| map | new Map(element, {center, zoom, disableDefaultUI: !controls, ...options.map}) |
L.map(element, {zoomControl: controls, attributionControl: true, ...options.map}), setView(); one L.tileLayer(tiles.template, {maxZoom, tileSize, attribution}) |
new maplibregl.Map({container, style, center, zoom, attributionControl: {compact: false}, preserveDrawingBuffer, interactive: controls, ...options.map}); a NavigationControl without compass when controls |
| markers | google.maps.Marker with the position and title (the colour is not used) |
L.circleMarker, radius 6, white ring of 2, filled with the marker's colour (#d33b3b by default), its title as a tooltip |
maplibregl.Marker in the marker's colour (#d33b3b by default), its title as title and aria-label |
| view options | styles (each a StyledMapType), zoomStyles (the map type by zoom, applied on idle and zoom_changed when below has stops), map |
map |
map |
| window events | tilesloaded, idle, drag, each time the map fires them |
idle, once the map is ready |
idle, the first time the map is idle |
The window events are glitchr/ux-google's: code that waited for Google's idle keeps working with
the other drivers.
A driver of one's own:
export async function draw(element, description, { loadScript, loadStyle }) {
loadStyle(description.library.style);
await loadScript(description.library.script);
const map = window.AcmeMaps.create(element, { center: description.view.center, zoom: description.view.zoom });
description.view.markers.forEach((marker) => map.addPin(marker.position, marker.title));
return map;
}
export default { draw };
(a sketch: AcmeMaps stands for the provider's own library)
snapshot.js - a kept picture
autoInit() lays out every [data-omnimap="snapshot"] frame once it is near the screen and
shown, again on resize, and after transparent.js swaps a page in. initSnapshots(root),
relayout(root) and layout(frame) for a host that drives it itself (React draws its own
canvas from the same data instead).
| Export | |
|---|---|
autoInit() |
initSnapshots() on load (at once when the document is complete), relayout() on resize, initSnapshots() again on transparent:ready and transparent:postactive |
initSnapshots(root = document) |
observes each frame not yet laid out; lays it out when it comes within 200 pixels of the screen and is shown (not display: none, not visibility: hidden, with an offset parent unless fixed); without IntersectionObserver, lays every frame out at once |
relayout(root = document) |
lays the frames already laid out and shown out again, then initSnapshots(root) |
layout(frame) |
lays one frame's tiles out, creating them the first time; a frame that is not a div throws |
COPYRIGHT_MARGIN |
0.04: the share of the grid pushed below the frame's bottom edge, as ux-google's tilemap.js did |
layout() reads the frame's attributes (The markup): data-src
(URI-decoded), data-signature, data-xtiles, data-ytiles, width, height and
data-tilesize (the larger of width and height when empty: a whole picture). No grid: nothing.
It sets the frame centred and full-size (object-fit: cover), fits the picture's real size to
cover the frame (the padding of the last row and column outside), widens the grid by
COPYRIGHT_MARGIN and lowers its bottom by as much, then places one absolutely positioned
<span id="<frame id>_<n>"> per tile with the tile's URL ({signature} and {id} replaced) in
data-background-image. Each tile is loaded only once it is on screen (an
IntersectionObserver; all at once without one), then shown as the span's background with a
0.25 s fade (none when the image was already in the browser's cache). The frame is marked
data-omnimap-initialized="1".
data-missing (the missing option, html.missing in the bridges), when not empty, is the
address of a picture shown for what is not there: over a frame with no picture kept (its
background, cover, centred) and in place of a tile that fails to load. Empty, a frame with no
picture stays empty and a failed tile transparent.
capture.js - sending a picture to the cache
bindCapture(document, { html2canvas, format: 'png', quality: 1, tilesize: 512 }) binds the
buttons MapHtml::button() prints. omnimap ships no html2canvas: the host gives its own (1.x
returns a promise; 0.4's onrendered is accepted too).
| Option | Default | |
|---|---|---|
html2canvas |
window.html2canvas |
the function that draws an element on a canvas |
format |
png |
the picture's media type, image/<format> |
quality |
1 |
canvas.toDataURL()'s quality (JPEG, WebP) |
tilesize |
512 |
sent with the picture: the tile size the server cuts it into |
html2canvasOptions |
{} |
merged over { useCORS: true } |
[data-omnimap-export]: on click, draws the element named bydata-omnimap-targetand postsimage(adata:URL),tilesizeand_token(data-omnimap-token) to the URL indata-omnimap-export;[data-omnimap-suppress]: on click, posts_tokento the URL indata-omnimap-suppress.
Posts are fetch() POST, credentials: 'same-origin', application/x-www-form-urlencoded,
Accept: application/json. The button's text turns #9ce62a99 (green) when the answer is JSON
with status: "OK", grey otherwise (an HTTP error, no html2canvas, no target element). Each button
is bound once (data-omnimap-bound="1"). The routes that receive the posts are the host's
(Symfony); the server answers a capture with the picture's
metadata, whose status is OK.
Data attributes and events
| Attribute | On | Set by | Read by |
|---|---|---|---|
data-omnimap="map" |
live map element | MapHtml::live() |
mountAll() |
data-omnimap-description |
live map element | MapHtml::live() |
mountAll() |
data-omnimap-mounted |
live map element | mountAll() |
mountAll() |
data-omnimap="snapshot" |
picture frame | MapHtml::snapshot() |
initSnapshots(), relayout() |
data-src, data-signature, data-tilesize, data-xtiles, data-ytiles, width, height |
picture frame | MapHtml::snapshot() |
layout() |
data-missing |
picture frame | MapHtml::snapshot() |
snapshot.js: the picture shown for a missing picture or tile |
data-omnimap-initialized |
picture frame | snapshot.js |
snapshot.js |
data-background-image |
a tile's span |
layout() |
removed when the tile loads |
data-omnimap-export, data-omnimap-suppress, data-omnimap-target, data-omnimap-token |
button | MapHtml::button() |
bindCapture() |
data-omnimap-bound |
button | bindCapture() |
bindCapture() |
| Event | Target | When |
|---|---|---|
omnimap:ready |
the map's element (bubbles), detail: {map, description} |
a driver drew the map |
idle |
window |
Google's idle (each time), Leaflet's map ready, MapLibre's first idle |
tilesloaded, drag |
window |
Google only, each time the map fires them |
load, resize, transparent:ready, transparent:postactive |
window |
listened to by autoInit() |
Proposer une modification docs/javascript.md
Configuration reference
Every option below is read from the code at this commit: the factories' populate() defaults and
what their build() reads, and the constructors of the core's classes. Options reach a factory
through create($options), a Registry's options, or the bridges' providers key
(Symfony, Laravel). A value may be given
as an environment variable gives it: Config::bool() reads 1, true, yes, on as true,
Config::int() and float() read numeric strings, and an empty string is "not set" for a string
option.
The registry
new Registry(iterable $factories, array $config);
| Argument | |
|---|---|
$factories |
ProviderFactoryInterfaces, keyed by their getName() (a later one of the same name replaces an earlier one) |
$config |
array<string, array{factory: string, options?: array}>: each provider's name, its factory's name, its options |
omnimap/google
new GoogleFactory(?HttpClientInterface $http = null, ?Psr\Http\Client\ClientInterface $psr18 = null, ?Psr\SimpleCache\CacheInterface $cache = null);
no required option.
| Option | Type | Default | |
|---|---|---|---|
api_key |
string | '' |
the browser key (Maps JavaScript API), written in the page: restrict it to your sites in Google's console |
server_key |
string | '' |
the key of the server's calls (Static Maps, Geocoding); the api_key when empty |
secret |
string | '' |
the URL signing secret: Static Maps' signature (none when empty) |
version |
string | weekly |
the Maps JavaScript API's channel or version: weekly, quarterly, beta, a number |
language |
string | '' |
the map's language and the geocoder's default locale |
region |
string | '' |
the map's region, and geocoder-php's region bias |
legacy_signature |
bool | false |
true: a view's fingerprint is glitchr/ux-google's, so the pictures it captured keep their names |
geocoder_ttl |
int | 2592000 |
seconds a geocoding answer is kept (30 days) |
Static Maps needs the HTTP client; geocoding needs the PSR-18 client and a key (server_key or
api_key); both answers are kept in $cache when given (geocoding only: Static Maps images are
not cached by the provider; the map cache keeps the picture).
omnimap/openstreetmap
new OpenStreetMapFactory(?HttpClientInterface $http = null, ?Psr\Http\Client\ClientInterface $psr18 = null, ?Psr\SimpleCache\CacheInterface $cache = null);
no required option.
| Option | Type | Default | |
|---|---|---|---|
tiles |
string | https://tile.openstreetmap.org/{z}/{x}/{y}.png |
the tile URL, {z}, {x}, {y} in it, {s} for a server's subdomains (a, b, c); the default is the Foundation's server, for modest use |
attribution |
string (HTML) | © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors |
shown on the live map, beside a kept picture and drawn on an image |
max_zoom |
int | 19 |
|
tile_size |
int | 256 |
the tiles' size in pixels; 512 for a server numbering 512-pixel tiles 2^z a side at z (Tiles) |
user_agent |
string | '' |
the application's name and a contact, sent with every request the server makes (tiles, Nominatim); required to fetch a tile and to geocode |
referer |
string | '' |
sent with them too, when set |
nominatim |
string | https://nominatim.openstreetmap.org |
the geocoder's URL; empty: no geocoding |
nominatim_interval |
float | 1.0 |
seconds between two geocoder calls of a process |
geocoder_ttl |
int | 2592000 |
seconds a geocoding answer is kept |
leaflet_script |
string | https://unpkg.com/leaflet@1.9.4/dist/leaflet.js |
where the browser gets Leaflet |
leaflet_script_integrity |
string | sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo= |
its Subresource Integrity hash; empty: none |
leaflet_style |
string | https://unpkg.com/leaflet@1.9.4/dist/leaflet.css |
|
leaflet_style_integrity |
string | sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY= |
|
background |
string | #aad3df |
the colour above and below the world on an image made of tiles |
The tile host and the Leaflet script's host are the provider's browserHosts.
omnimap/maplibre
new MapLibreFactory(?HttpClientInterface $http = null); no required option. The client is only
for MapLibreProvider::inspect() (reading a style's hosts on the server).
| Option | Type | Default | |
|---|---|---|---|
style |
string | https://tiles.openfreemap.org/styles/liberty |
the MapLibre style's URL |
attribution |
string (HTML) | OpenFreeMap, OpenMapTiles, OpenStreetMap (MapLibreFactory::OPENFREEMAP_ATTRIBUTION) |
shown beside a kept picture; the live map shows its sources' own |
hosts |
list or comma-separated string | [] |
the hosts the browser reaches for the style, its tiles, glyphs and sprite; the style's host when empty |
library_script |
string | https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js |
|
library_script_integrity |
string | MapLibreFactory::LIBRARY_SCRIPT_INTEGRITY (SHA-384) |
|
library_style |
string | https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css |
|
library_style_integrity |
string | MapLibreFactory::LIBRARY_STYLE_INTEGRITY (SHA-384) |
|
library_module |
string | '' |
an ES module instead of the script (MapLibre GL JS 6); set, the script and its integrity are left out |
preserve_drawing_buffer |
bool | true |
keeps the canvas readable for capture.js |
Map cache classes
| Class | Constructor | |
|---|---|---|
Signer |
(?string $secret) |
URL-safe base64; null or '' signs with an empty key |
Snapshot\SnapshotStore |
(Storage $storage, string $directory = 'omnimap', string $pattern = '{signature}/image-{id}', string $format = 'png') |
The layout |
Snapshot\Snapshots |
(MapRendererInterface $renderer, Signer $signer, SnapshotStore $store, ?StaticMapInterface $staticMap = null, Size $size = new Size(1512, 938), int $scale = 1, ?int $tilesize = 512) |
size, scale: a picture the server makes; tilesize: how pictures are cut (null: whole) |
Snapshot\DirectoryStorage |
(string $root) |
|
Snapshot\FlysystemStorage |
(League\Flysystem\FilesystemOperator $filesystem) |
|
Snapshot\DriveStorage |
(Omnidrive\Drive $drive) |
|
MapHtml |
(array $options = []) |
class (omnimap-snapshot), suffix (_snapshot), src (/omnimap/{signature}/{id}.webp), missing ('': the address of the picture snapshot.js shows for a missing picture or tile) |
MapPrinter |
(Snapshots $snapshots, MapHtml $html = new MapHtml(), bool $cacheOnly = true, ?\Closure $onError = null) |
The page |
Images and geocoding
| Class | Constructor | |
|---|---|---|
Static\TileStitcher |
(TileSourceInterface $tiles, HttpClientInterface $http, ?CacheInterface $cache = null, string $background = '#aad3df', string $markerColor = '#d33b3b') |
colours as CSS hex (#rgb or #rrggbb) |
Geocoding\GeocoderPhp |
(Geocoder\Provider\Provider $provider, ?CacheInterface $cache = null, int $ttl = 2592000, float $interval = 0.0) |
ttl in seconds, interval the pace in seconds |
Browser options
The options of mountAll()/draw() (consent), bindCapture() (html2canvas, format,
quality, tilesize, html2canvasOptions) and the description's keys are in
The browser.
Proposer une modification docs/configuration.md
Errors
Every exception of the family implements Omnimap\Exception\OmnimapException (an interface
extending \Throwable), so one catch (OmnimapException $e) takes them all, and each also
extends the SPL exception that fits, for code that catches those:
| Exception | Extends | Thrown when |
|---|---|---|
InvalidConfigException |
\InvalidArgumentException |
a provider is not configured or its factory not installed (Registry); a required option is missing (ProviderFactory::create()); a call needs an option that was not given: Google's staticUrl() without a key, an OpenStreetMap tile's headers() without a user_agent |
NotSupportedException |
\LogicException |
a provider asked for a contract it does not implement (Registry::renderer(), staticMap(), geocoder(), tiles()); a capability declared off, called anyway (no HTTP client, no key for Google or no user_agent for OpenStreetMap for an image, no PSR-18 client or key for geocoding, no client for MapLibre's inspect()); Snapshots::make() with no static map (check canMake() first, or call findOrMake()); cache.make: true in Symfony on a provider that makes no image |
ProviderException |
\RuntimeException |
the provider answered, and not with what was asked: a status other than 200 (401, 403, 404, 429, 5xx), a body that is not an image where an image was asked (a proxy's HTML page in a 200), geocoder-php's own errors (an error status, a quota, an answer it could not read), a MapLibre style that is not JSON or not a style |
UnreachableException |
\RuntimeException |
the provider could not be reached: DNS, connection, timeout (Symfony's HTTP client's transport exceptions, PSR-18's NetworkExceptionInterface); a copy of the client's exception is the previous one (no secret in it) |
InvalidArgumentException |
\InvalidArgumentException |
a value no map can hold: a latitude past a pole or not finite, a zoom outside 0..24, a size below one pixel or not WIDTHxHEIGHT; a signature that is not [A-Za-z0-9_-]{1,128}; a picture GD cannot read; a cache format other than png, jpeg, jpg, webp; a DirectoryStorage path holding .. (SnapshotStore never builds one: signatures are checked first) |
The messages name the provider (its title) and what was asked:
<?php
require __DIR__.'/vendor/autoload.php';
use Omnimap\Exception\OmnimapException;
use Omnimap\MapLibre\MapLibreFactory;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Size;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Registry;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
$down = new MockHttpClient(static fn () => new MockResponse('Unavailable', ['http_code' => 503]));
$cut = new MockHttpClient(static fn () => new MockResponse('', ['error' => 'Could not resolve host']));
$registry = new Registry([new OpenStreetMapFactory($down), new MapLibreFactory()], [
'osm' => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0']],
'vector' => ['factory' => 'maplibre'],
'other' => ['factory' => 'google'],
]);
$view = new MapView(new LatLng(35.0116, 135.7681), 12);
$attempts = [
'unknown name' => fn () => $registry->get('nope'),
'factory not installed' => fn () => $registry->get('other'),
'capability missing' => fn () => $registry->staticMap('vector'),
'provider answered 503' => fn () => $registry->staticMap('osm')->image($view, new Size(256, 256)),
'network error' => fn () => (new OpenStreetMapFactory($cut))->create(['user_agent' => 'MySite/1.0'])->image($view, new Size(256, 256)),
'geocoder switched off' => fn () => $registry->get('osm')->geocode('Kyoto'),
'latitude past a pole' => fn () => new LatLng(91, 0),
'zoom too deep' => fn () => new MapView(new LatLng(0, 0), 25),
];
foreach ($attempts as $label => $attempt) {
try {
$attempt();
} catch (OmnimapException $e) {
printf("%-22s %s: %s\n", $label, (new ReflectionClass($e))->getShortName(), $e->getMessage());
}
}
unknown name InvalidConfigException: No "nope" map provider; configured: osm, vector, other.
factory not installed InvalidConfigException: No "google" factory for the "other" map provider; installed: openstreetmap, maplibre.
capability missing NotSupportedException: The "vector" provider (Omnimap\MapLibre\MapLibreProvider) does not make a map image.
provider answered 503 ProviderException: OpenStreetMap answered 503 for https://tile.openstreetmap.org/12/3592/1621.png.
network error UnreachableException: OpenStreetMap could not be reached for https://tile.openstreetmap.org/12/3592/1621.png: Could not resolve host
geocoder switched off NotSupportedException: No PSR-18 client, no user_agent or no nominatim URL was given to the OpenStreetMap provider: no geocoding.
latitude past a pole InvalidArgumentException: A latitude lies between -90 and 90, not 91.
zoom too deep InvalidArgumentException: A zoom lies between 0 and 24, not 25.
(run on 2026-10-25 in the harness)
No secret in a failure
What omnimap throws keeps a key out. An address in a message (a tile's, a style's, a Static Maps
image's, the one geocoder-php quotes) has its key parameters (key=, api_key=, signature=)
and a password in its user part masked (key=***, Http\Redaction::text()), and so does the
HTTP client's message after it, with every secret of the request (a tile source's headers()
token, a PSR-18 request's headers). The previous exception of an UnreachableException - and of
a geocoder's ProviderException - is a copy of the client's (Redaction::copy(): the same class
and code, that message, without the request a PSR-18 exception carried), and no trace of the chain
keeps its arguments, where the URL and headers stood: a log that writes the whole chain, arguments
included (zend.exception_ignore_args off), holds no key.
What to catch
- Around a page's map: nothing.
MapPrinter::map()catches whatever making a picture throws, hands it to its$onErrorand shows no picture (the bridges log it as a warning). - Around a server-made image or a geocoding call:
ProviderExceptionandUnreachableException(the service failed or refused: retry later, show the address without a map) apart fromInvalidConfigExceptionandNotSupportedException(the application's configuration: fix it). - The conformance suite holds every provider to this rule: for each operation that calls the
service, statuses 401, 403, 404, 429, 500, 503 and an HTML page in a 200 are
ProviderExceptions, a transport failure anUnreachableException(Testing). - A 429's
Retry-Afteris not read:ProviderExceptioncarries no retry delay.
Outside the family
What a storage or a client throws itself passes through as it is:
| Where | Exception | When |
|---|---|---|
FlysystemStorage, DriveStorage |
Flysystem's FilesystemException, omnidrive's exceptions |
the storage failed to write or delete (a missing file on read is null, not an exception) |
Proposer une modification docs/errors.md
Testing
Testing an application that shows maps with omnimap, and the conformance suite every provider
runs. Everything in Omnimap\Testing ships with the core (it is autoloaded with it), so a test
needs no network and no other package; ProviderContractTests needs phpunit/phpunit and
symfony/http-client, the image helpers GD.
The fakes
| Class | |
|---|---|
TestTiles(HttpClientInterface $http, ?CacheInterface $cache = null) |
a provider (name test, title Test tiles): a renderer, a static map (through TileStitcher) and a tile source on https://tiles.example/{z}/{x}/{y}.png, browser host tiles.example, attribution © Test tiles, User-Agent omnimap-tests/1.0 (+https://github.com/glitchr-omnisense/omnimap); no factory |
PlainTiles::png(int $zoom, int $x, int $y, int $size = 256): string |
a PNG tile of one colour per zoom, x and y, its coordinates written on it |
PlainTiles::answer(): \Closure |
the answer RecordedHttpClient gives for any URL ending in /{z}/{x}/{y}.png: that tile, with Cache-Control: max-age=91080, stale-while-revalidate=604800, stale-if-error=604800; a 404 otherwise |
RecordedHttpClient(\Closure $answer) |
a Symfony HTTP client answering from a closure of the method, the URL and the options, returning [status, body, headers]; every request kept in $requests (method, url, options); stream() is not supported |
RecordedResponse |
its responses: getStatusCode(), getHeaders() (names lowercased), getContent(), toArray() ([] when the body is not a JSON object), never throwing |
ArrayCache |
a PSR-16 cache in memory; $ttls keeps the lifetime each key was set with (a \DateInterval read as seconds) |
FixedGeocoder(array $places) |
a geocoder-php provider (name fixed) that knows places by their exact text: ['Kyoto' => ['lat' => ..., 'lng' => ..., 'locality' => ..., 'country' => ..., 'countryCode' => ...]]; reverse finds a place within 0.01 degree; $calls counts the questions |
Symfony's MockHttpClient and MockResponse work as well wherever a provider takes an HTTP
client (status codes, transport errors: new MockResponse('', ['error' => '...'])).
An application's test
<?php
use Omnimap\Geocoding\GeocoderPhp;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\OpenStreetMap\OpenStreetMapProvider;
use Omnimap\Signer;
use Omnimap\Snapshot\DirectoryStorage;
use Omnimap\Snapshot\Snapshots;
use Omnimap\Snapshot\SnapshotStore;
use Omnimap\Testing\ArrayCache;
use Omnimap\Testing\FixedGeocoder;
use Omnimap\Testing\PlainTiles;
use Omnimap\Testing\RecordedHttpClient;
use Omnimap\Testing\TestTiles;
use PHPUnit\Framework\TestCase;
final class StoreMapTest extends TestCase
{
public function testTheStoresMapIsMadeOnceAndKept(): void
{
$http = new RecordedHttpClient(PlainTiles::answer()); // every tile drawn here, no network
$tiles = new TestTiles($http, new ArrayCache());
$snapshots = new Snapshots($tiles, new Signer('c2VjcmV0'), new SnapshotStore(new DirectoryStorage(sys_get_temp_dir().'/maps-test')), $tiles);
$view = new MapView(new LatLng(48.5818, 7.7509), 14, id: 'store');
$snapshot = $snapshots->findOrMake($view);
self::assertSame([1512, 938, 3, 2], [$snapshot->width, $snapshot->height, $snapshot->xtiles, $snapshot->ytiles]);
self::assertCount(35, $http->requests);
self::assertEquals($snapshot, $snapshots->findOrMake($view)); // read back, not made again
self::assertCount(35, $http->requests);
$snapshots->delete($snapshot->signature);
}
public function testTheStoresAddressIsGeocoded(): void
{
$places = new FixedGeocoder(['1 place de la Cathédrale, Strasbourg' => ['lat' => 48.5818, 'lng' => 7.7509, 'locality' => 'Strasbourg', 'country' => 'France', 'countryCode' => 'FR']]);
$geocoder = new OpenStreetMapProvider(geocoder: new GeocoderPhp($places, new ArrayCache()));
$found = $geocoder->geocode('1 place de la Cathédrale, Strasbourg');
self::assertSame('Strasbourg, France', $found[0]->label);
self::assertSame('fixed', $found[0]->providedBy);
self::assertSame([], $geocoder->geocode('nowhere'));
$geocoder->geocode('1 place de la Cathédrale, Strasbourg');
self::assertSame(2, $places->calls); // the repeated question answered by the cache
}
}
$ vendor/bin/phpunit --testdox tests/
Store Map
✔ The stores map is made once and kept
✔ The stores address is geocoded
Tile Server Down
✔ A tile server down is a provider exception
OK (3 tests, 9 assertions)
(run on 2026-10-25 in the harness, PHPUnit 11.5, with TileServerDownTest below)
A geocoder that knows its answers
GeocoderPhp is not itself a GeocoderInterface; to hand application code a geocoder that
answers from a list, build OpenStreetMapProvider directly with a GeocoderPhp over
FixedGeocoder, as the second test does: capabilities()->geocoder is true, and nothing else
of the provider is used.
A provider on recorded answers
To test the application's handling of a real provider's answers and failures, give its factory a
MockHttpClient (and a Psr18Client over it for geocoding), as the third test of the run above:
<?php
use Omnimap\Exception\ProviderException;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Size;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class TileServerDownTest extends TestCase
{
public function testATileServerDownIsAProviderException(): void
{
$http = new MockHttpClient(static fn () => new MockResponse('Unavailable', ['http_code' => 503]));
$osm = (new OpenStreetMapFactory($http))->create(['user_agent' => 'tests/1.0']);
$this->expectException(ProviderException::class);
$osm->image(new MapView(new LatLng(35.0116, 135.7681), 12), new Size(256, 256));
}
}
A closure makes one response per request: an image needs several tiles. Given a single
MockResponse object instead, the same call ends in Symfony's own ServerException (seen in the
harness), not in the family's ProviderException.
In a Symfony or Laravel test
Replace the HTTP client the bridge gives the factories: http_client in Symfony's test
environment, or bind omnimap.http_client in Laravel, with a MockHttpClient answering
PlainTiles::png(); point the cache's storage at a temporary directory. omnibridge/symfony's and
omnibridge/laravel's Tests/MapTest.php do exactly that (Symfony,
Laravel).
Conformance suite: each test
Omnimap\Testing\ProviderContractTests is a PHPUnit trait: a provider's test case uses it and
gives its factory, built over the HTTP client the suite hands it. No test reaches the network:
every answer is a MockResponse. The core runs it on TestTiles
(Tests/TestTilesContractTest.php); each provider in Tests/<Name>ContractTest.php. Writing one:
Writing a provider.
The hooks:
| Hook | Default | |
|---|---|---|
contractFactory(?HttpClientInterface $http): ProviderFactoryInterface |
abstract | the factory over $http; null builds a provider without a client, its network capabilities off. A geocoding provider wraps $http in a Psr18Client |
contractOptions(): array |
[] |
options that switch every capability on (fake keys, a User-Agent, nominatim_interval: 0) |
contractAnswer(string $method, string $url, array $options): array |
[200, PlainTiles::png(1, 0, 0), content-type image/png, max-age 3600] for any request |
[status, body, headers] of a request that succeeds: a geocoder answers its service's recorded answer for its geocoding URLs |
contractGeocodeQuery(): string |
Osaka, Japan |
a free-text question the recorded geocoding answer answers |
The view every test uses: Osaka (34.6937, 135.5023) at zoom 11, one marker at Umeda, id
contract.
| Test | What it checks |
|---|---|
testTheFactoryNamesWhatItBuildsAndTheRegistryBuildsItOnce |
the factory's name is the provider's, matches ^[a-z0-9][a-z0-9_-]*$; a title that is not blank; a Registry builds it from {factory, options}, the same object twice |
testAMissingRequiredOptionIsRefusedByName |
each option in omnimap.required_options, removed, is an InvalidConfigException whose message names it; with none, create([]) builds a provider |
testCapabilitiesAreWhatTheProviderAnswers |
with and without a client: at least one contract; each declared contract is implemented; browser hosts are host names (no scheme, no path); terms start with https://; toArray() survives JSON. Without a client: a static map or geocoder implemented but declared off throws NotSupportedException (never a TypeError) |
testARendererDescribesTheViewAsPlainData |
provider is the provider's name, consent a string, view is MapView::toArray(), attribution a string equal to attribution(); JSON and back gives the same; every http(s) address in it (but the attribution's links) is on a declared browser host ({s} read as a); describing sends no request; the fingerprint is stable and changes with the centre, the zoom and the markers |
testAStaticMapIsAnImageOfTheSizeAsked |
a 300 x 200 image: bytes that are an image of the media type it says, no larger than asked, fetched over https:// only |
testTileAddressesAreComplete |
the template holds {z}, {x}, {y}; url(11, 1798, 812) is https://, has no {, holds 1798; tile size 256 or 512; max zoom 1 to 24; headers() answers with the contract's options |
testAGeocoderAnswersPlacesOfItsOwn |
geocode(contractGeocodeQuery(), 'en', 1): at least one place, at most one, providedBy not empty, coordinates on the globe; reverse() of it keeps the limit |
testErrorsAreTheFamilysExceptions |
for image() and geocode() when declared: answers 401, 403, 404, 429 (with Retry-After), 500, 503, an HTML page in a 200 are each a ProviderException, a transport failure (Could not resolve host) an UnreachableException; never an answer, never another exception. A provider that fetches nothing on the server has nothing to check |
A check that does not apply (a renderer-only provider's image test) passes by asserting the capability is off: such a test is not skipped, and the provider is counted as running the suite.
Proposer une modification docs/testing.md
The Docker harness
docker/ composes this core and every provider (docker/harness/plugins.php) into one
installation, PHP 8.4 with GD:
docker compose run --rm omnimap test # every package's tests, no network
docker compose run --rm omnimap live # the checks that reach OpenStreetMap, within its policies
docker compose run --rm omnimap bare # the bare script: plain PHP, and what PHP loaded
docker compose run --rm omnimap phpstan # PHPStan at the highest level, each package
docker compose run --rm js # the browser scripts' tests, on node's test runner
docker compose down -v
Providers come from GitHub (branch 1.x) unless OMNIMAP_PLUGINS (in docker/.env) points at a
directory holding their checkouts - ../.. when this core sits in the workspace beside them.
What it is made of
| File | |
|---|---|
docker/Dockerfile |
php:8.4-cli with GD (PNG, JPEG, WebP, FreeType), git, unzip and Composer 2; image omnisense/omnimap-harness |
docker/compose.yml |
the service omnimap, on Docker's default bridge (no network of its own); mounts the core at /omnimap/core, the providers' checkouts read-only at /omnimap/plugins, and a volume harness at /harness (the composed installation and Composer's cache). The service js (node:22-alpine, no network) runs node --test over Tests/js/*.test.mjs of the core and of each provider's checkout, the core and the providers mounted read-only |
docker/.env |
OMNIMAP_PLUGINS=../..: the checkouts beside the core |
docker/harness/plugins.php |
the providers installed beside the core and their tests' namespaces: google, openstreetmap, maplibre |
docker/harness/setup.php |
writes /harness/composer.json and /harness/phpunit.xml: the core and each provider as path repositories (symlinked) when their checkout is there, else a git clone of https://github.com/glitchr-omnisense/omnimap-<name>.git; the dev tools (PHPUnit 11, PHPStan 2, symfony/http-client and cache, nyholm/psr7, Flysystem and its memory adapter, omnidrive, opis/json-schema); one test suite per package; /harness/packages/<name> linked to each provider for PHPStan. Files are rewritten only when they change |
docker/entrypoint.sh |
runs setup.php, then composer install (no vendor/) or composer update (composer.json newer than the lock), then the command |
docker/harness/bin/bare |
the plain-PHP script Tests/BareTest.php runs |
Commands
| Command | |
|---|---|
test [phpunit arguments] |
PHPUnit over every package, the group live excluded; arguments go to PHPUnit (test --filter Signer, test --testdox /scratch/tests) |
live [phpunit arguments] |
the group live only: an image of Kyoto from tile.openstreetmap.org and one Nominatim query (omnimap/openstreetmap), OpenFreeMap's style, tiles, glyphs and sprite and unpkg's MapLibre hashes (omnimap/maplibre) |
bare |
docker/harness/bin/bare: a view of Kyoto with two markers described, its picture made of tiles answered by PlainTiles, signed, cut into a directory, read back and printed, made again from cached tiles, deleted, Kyoto geocoded twice; prints what happened and every class and file PHP loaded, as JSON |
phpstan |
PHPStan on the core and on each package that has a phpstan.neon, at its baseline |
composer ..., php ..., sh |
as they are, in /harness |
Running a script of one's own
The examples of this manual were run this way: put the script in a directory, mount it, and require the harness's autoloader.
cd docker
docker compose run --rm -v "$PWD/../scratch:/scratch" omnimap php /scratch/example.php
<?php
require '/harness/vendor/autoload.php'; // in the harness; __DIR__.'/vendor/autoload.php' in an application
--rm removes the container after the run; the harness volume stays (the installation is reused
by the next run) until docker compose down -v.
Run on 2026-10-25: test 92 tests, 568 assertions, green (PHP 8.4.26, PHPUnit 11.5.57).
Proposer une modification docs/harness.md
Writing a provider
A provider is a package omnimap/<name> with a factory and a provider class:
- the factory extends
Omnimap\ProviderFactory:populate()names it, lists its required options and their defaults;build()returns the provider. Clients (HTTP, PSR-18) and caches come through the factory's constructor, never created inside. - the provider implements the contracts it can answer -
MapRendererInterface,StaticMapInterface,GeocoderInterface,TileSourceInterface- and says so incapabilities(), with the hosts a visitor's browser reaches and the terms that apply. - geocoding goes through
Geocoding\GeocoderPhpover a geocoder-php provider: omnimap does not parse a geocoding API itself. - a renderer's
describe()is plain data; its driver (assets/<name>.js) is an ES module exportingdraw(element, description, options). fingerprint()is everything the picture of a view depends on: two views with the same fingerprint must look the same.- tests run in the core's harness (
docker/harness/plugins.phplists the providers), with aBareTestloading every class with the package's requirements alone. - the bridges list the factories they build by class (omnibridge/symfony's
OmnimapExtension, omnibridge/laravel'sMapServiceProvider::FACTORIES): a new provider is wired there too, or an application registers its factory itself (Symfony: a service taggedomnimap.provider_factory).
The package layout (omnimap/openstreetmap):
openstreetmap/
OpenStreetMapFactory.php # name, title, options and their defaults, the provider built
OpenStreetMapProvider.php # the contracts it answers, its capabilities, its description
assets/leaflet.js # the browser driver: draw(element, description, options)
Tests/OpenStreetMapContractTest.php # the conformance suite
Tests/OpenStreetMapProviderTest.php # what is particular to the provider
Tests/BareTest.php # every class loads with the package's requirements alone
Tests/LiveTest.php # #[Group('live')]: the real service, excluded by default
A whole provider
A raster tile service with a key: a renderer, a static map made of its tiles, a tile source. This code, with the contract test after it, ran green in the harness on 2026-10-25 (the suite's 8 tests).
<?php
use Omnimap\Capabilities;
use Omnimap\Config;
use Omnimap\Exception\NotSupportedException;
use Omnimap\MapRendererInterface;
use Omnimap\Model\Image;
use Omnimap\Model\MapView;
use Omnimap\Model\Size;
use Omnimap\ProviderFactory;
use Omnimap\ProviderInterface;
use Omnimap\Static\TileStitcher;
use Omnimap\StaticMapInterface;
use Omnimap\TileSourceInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class AcmeFactory extends ProviderFactory
{
public function __construct(private readonly ?HttpClientInterface $http = null)
{
}
protected function populate(Config $c): void
{
$c->defaults([
'omnimap.factory_name' => 'acme',
'omnimap.factory_title' => 'Acme Maps',
'omnimap.required_options' => ['api_key'],
'max_zoom' => 18,
]);
}
protected function build(Config $c): ProviderInterface
{
return new AcmeProvider((string) $c->string('api_key'), $c->int('max_zoom', 18), $this->http);
}
}
final class AcmeProvider implements MapRendererInterface, StaticMapInterface, TileSourceInterface
{
public function __construct(private readonly string $key, private readonly int $maxZoom, private readonly ?HttpClientInterface $http)
{
}
public function getName(): string { return 'acme'; }
public function getTitle(): string { return 'Acme Maps'; }
public function capabilities(): Capabilities
{
return new Capabilities(renderer: true, staticMap: null !== $this->http, tiles: true, browserHosts: ['tiles.acme.example'], terms: ['https://acme.example/terms']);
}
public function describe(MapView $view): array
{
return ['provider' => 'acme', 'consent' => 'MAP', 'view' => $view->toArray(), 'tiles' => ['template' => $this->template()], 'attribution' => $this->attribution()];
}
public function fingerprint(MapView $view): string
{
return 'acme|'.json_encode([$view->center, $view->zoom, $view->markers, $view->controls], \JSON_THROW_ON_ERROR);
}
public function attribution(): string { return '© Acme'; }
public function image(MapView $view, Size $size, int $scale = 1): Image
{
$http = $this->http ?? throw new NotSupportedException('No HTTP client was given to Acme: no image.');
return (new TileStitcher($this, $http))->image($view, $size, $scale);
}
public function template(): string { return 'https://tiles.acme.example/{z}/{x}/{y}.png?key='.rawurlencode($this->key); }
public function url(int $zoom, int $x, int $y): string { return str_replace(['{z}', '{x}', '{y}'], [(string) $zoom, (string) $x, (string) $y], $this->template()); }
public function tileSize(): int { return 256; }
public function maxZoom(): int { return $this->maxZoom; }
public function headers(): array { return ['User-Agent' => 'acme-tests/1.0']; }
}
The key is in the tile URL the browser reaches: a service whose keys must stay secret is drawn from the server's images only (no renderer), or through a proxy route of the host's.
The conformance suite
Omnimap\Testing\ProviderContractTests is what every provider is held to, as PHPUnit tests,
offline: the provider's test case uses the trait and gives its factory built over the HTTP
client the suite hands it (recorded answers, no network).
final class AcmeContractTest extends TestCase
{
use ProviderContractTests;
protected function contractFactory(?HttpClientInterface $http): ProviderFactoryInterface
{
return new AcmeFactory($http);
}
protected function contractOptions(): array
{
return ['api_key' => 'test-key'];
}
}
Acme Contract
✔ The factory names what it builds and the registry builds it once
✔ A missing required option is refused by name
✔ Capabilities are what the provider answers
✔ A renderer describes the view as plain data
✔ A static map is an image of the size asked
✔ Tile addresses are complete
✔ A geocoder answers places of its own
✔ Errors are the familys exceptions
A provider that geocodes gives its factory a PSR-18 client over the suite's HTTP client, and its recorded geocoding answer:
final class MyMapContractTest extends TestCase
{
use ProviderContractTests;
protected function contractFactory(?HttpClientInterface $http): ProviderFactoryInterface
{
return new MyMapFactory($http, null === $http ? null : new Psr18Client($http), new ArrayCache());
}
protected function contractOptions(): array { return ['api_key' => 'test-key']; } // every capability on
// Optional: the recorded answer of a request that succeeds (default: a PNG tile for any URL).
protected function contractAnswer(string $method, string $url, array $options): array { ... }
}
It checks the factory and the Registry (the same name, built once, a missing required option
refused by name), that capabilities() is what the provider answers (a capability declared is
implemented; one implemented but off, without its client, throws NotSupportedException), a
renderer's description (plain data, the provider's name for its driver, the view, the consent, every
address in it on a declared browser host, a fingerprint that follows centre, zoom and markers), a
static map's image (the bytes are the image type it says, no larger than asked), a tile source's
addresses, a geocoder's places, and the family's rule for errors:
- an answer other than 200, or a body that is not what was asked (a proxy's HTML page in a 200,
JSON of another shape), is a
ProviderException; - a request that never got an answer (DNS, connection, timeout) is an
UnreachableException; - a capability the provider does not have, or has switched off, is a
NotSupportedException.
The provider's require-dev holds phpunit/phpunit and symfony/http-client. The core runs the suite on
its own Testing\TestTiles (Tests/TestTilesContractTest.php). Each test and each hook:
Testing.
Checklist
- the factory names the provider (
omnimap.factory_name, the configuration'sfactory), its title, its required options and every default; building calls nothing; -
capabilities()is what the provider answers, built without a client too; -
describe()holds toschema/map-description.schema.json(add the factory toTests/MapDescriptionSchemaTest.php's list in the core) and names every host the browser reaches inbrowserHosts; - the driver
assets/<name>.jsexports{ draw }and uses theloadScript()/loadStyle()it is given; - errors are
ProviderException/UnreachableException, a capability offNotSupportedException; - the service's terms are in
terms, its rate limits and its required User-Agent kept in code; -
docs/index.md: options, capabilities, behaviour, errors, verified and not (CONTRIBUTING.md of omnimap/maplibre is the family's guide to testing, documenting and releasing a provider).
Proposer une modification docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-25 in the harness, the core and its three providers from their
checkouts, PHP 8.4.26 with GD, PHPUnit 11.5.57: 92 tests, 568 assertions, green (the live group
excluded). The core's own tests:
- the models and the projection: a latitude past a pole refused, a longitude wrapped, a box
across the antimeridian, the slippy-map tile of Kyoto at zoom 12, the world's corners, a point
there and back, the box of a view (
ModelTest,WebMercatorTest); - the registry and the configuration: providers by name built once, a required option, values
as an environment gives them (
RegistryTest); - the signer against
hash_hmac()and against geocoder-php's own implementation of Google's URL signing, an empty secret (SignerTest); - the cache: a capture glitchr/ux-google wrote read as it is (its
metadata.txtbyte for byte, written back identical); cutting, padding with transparency, deleting, in a directory, in Flysystem's memory adapter and on an omnidriveDrive; a whole picture; only a signature reaching a path; not an image refused (SnapshotStoreTest); the cache the same whichever makes the picture (SnapshotsTest); - the page: the picture, the live map or an empty frame by
MapPrinter's rule, a picture the server could not make (MapPrinterTest); the markup with a host's own names, no picture yet, the live map and the buttons (MapHtmlTest); - images made of tiles: each tile once with the source's headers then from the cache, kept as
long as max-age plus stale-while-revalidate, twice the density from the next zoom, the world
repeating across the antimeridian, the markers drawn, an error status (
TileStitcherTest); - the geocoder adapter: a place found then kept, the pace between two calls (
GeocoderPhpTest); - every installed renderer's description against
schema/map-description.schema.json(opis/json-schema), and what is not a description refused (MapDescriptionSchemaTest); - in plain PHP, a view described, made, cached, printed and geocoded loading nothing but omnimap
and what it requires, and every class loading with the required packages alone (
BareTest); - Installation's script, run as it is written (
DocsTest); - the conformance suite on
TestTiles(TestTilesContractTest), on omnimap/google, omnimap/openstreetmap and omnimap/maplibre (Testing).
The capability table of the index was probed with capabilities() on
2026-10-25. Every example of this manual marked "run" was run in the harness on 2026-10-25 and
its output pasted as it came.
Verified live
docker compose run --rm omnimap live, 2026-10-23: an image of Kyoto made of tile.openstreetmap.org's tiles and one Nominatim query, with omnimap/openstreetmap's User-Agent.- omnimap/maplibre's live group, 2026-10-25 (its README): OpenFreeMap's style, its TileJSON, a
vector tile, a glyph range and the sprite on the declared host; unpkg's MapLibre GL JS 5.24.0
against its SHA-384 hashes;
assets/maplibre.jsdrawn throughomnimap.jsin headless Chrome. - On lesringostars, 2026-10-23 (the providers' READMEs): the names of the pictures ux-google
captured kept, their tiles served at the same addresses; with
MAP_PROVIDER=openstreetmap, the page's map made by the server and kept, no request to Google nor to the tile server from the visitor's browser (headless Chrome).
What is not verified
- The core's JavaScript in a browser:
snapshot.js's layout anddata-missing, andleaflet.js's tile layer, are tested on node's test runner over a stand-in DOM and Leaflet (docker compose run --rm js);omnimap.js,capture.jsand the Google driver have no test of their own; onlymaplibre.jswas drawn in a browser. - Google's live answers (Static Maps, Geocoding, the Maps JavaScript API): no key in the harness.
- Tile servers other than the OpenStreetMap Foundation's; 512-pixel tile sources are tested on
tiles drawn by the tests (the numbering of Leaflet's
zoomOffset: -1), not against a real 512-pixel server. - The Symfony and Laravel pages of this manual were written from omnibridge's code and its
MapTests, not run in an application for this manual. - The scripts that reach a real service (the second script of Installation, the Photon example of Geocoding) were not run.
Responsibility
omnisense/omnimap and its omnimap/* providers are unofficial clients, affiliated with none of
the services they reach. The MIT licence covers the code only and grants no right to any service
or its data; whoever runs them complies with each service's terms (named in each provider's
README and in Capabilities::$terms) and obtains what they require - including whether a
picture of a provider's map may be kept in the map cache.