NativeScript plugin for MassifMaps SDK
- Installation
- API
- The surface API
- The example gallery
- Usage in Svelte
- Usage in Vue
- Demos and Development
- Contributing
- Questions
Run the following command from the root of your project:
ns plugin add @nativescript-community/ui-massifmaps
The API documentation for this plugin is available here.
There are two public surfaces. The object API — MassifMap, VectorTileLayer, HTTPTileDataSource — is what the rest of this README shows and is unchanged. The surface API is the SDK's id/handle + JSON facade, and is what new code should use: it is six verbs (create, destroy, set, get, call, on) over a table generated from the SDK itself, so a new SDK option costs no plugin code at all.
It is exported namespaced, because both surfaces spell a layer MassifLayer and a map MassifMap:
import { api } from '@nativescript-community/ui-massifmaps';// from the mapReady event - the map's Options must exist
const map = api.attach(mapView, { projection: 'EPSG:4326' });The view's own event listener is chained, not replaced, so whatever the plugin (or your app) had installed keeps being called.
api.createSource('osm', { type: 'http', minZoom: 0, maxZoom: 14, url: 'https://tiles/{z}/{x}/{y}.pbf' });
map.addLayer('base', {
type: 'vector',
opacity: 0.8,
source: 'osm', // an id, or an inline spec of the kind that builds it
style: { type: 'mbvt', project: { type: 'project', assets: { type: 'dir', path: '/sdcard/massif_style' }, name: 'osm' } }
});A spec's keys are the constructor's parameters plus every writable property of the class it builds, so opacity (declared on Layer) works even though no layer constructor takes one. Creating an id that already exists with an identical spec returns the same object — that is how two maps share one source. A different spec under the same id is refused rather than silently replacing it.
map.set('fogOptions.rangeStart', 2.5); // a dotted path walks object properties
map.set('fog.rangeStart', 2.5); // `fog` is an alias for `fogOptions` - same property
map.fog().set('rangeEnd', 4); // or scope onto one
map.terrain().apply({ exaggeration: 1.4, viewDistanceFactor: 2 }); // ONE crossing, not two
const speed: number = base.get('labelBlendingSpeed');
const at = e.getPos('clickPos', 'EPSG:4326'); // a coordinate, in the projection you wantA style's CartoCSS param:: values, an HTTP header set and a layer's metadata are bags: the
rest of the path is a key of your own, and the property itself takes every key at once.
style.set('params.water_color', '#0af');
style.apply({ params: { water_color: '#0af', show_buildings: 'false' } });
source.set('HTTPHeaders.User-Agent', 'my-app/1.0');apply is one native call whatever the key count, which is what makes it worth using over a loop
of set.
Values cross as the JavaScript type they should be: an enum is its constant name, a position is [lon, lat], a struct is the JSON it encodes, a colour is an ARGB integer.
The handful of properties every app touches also have named, chainable accessors — one call reads, one writes:
base.visible(true).opacity(0.5).zoomRange([4, 18]).refresh();
if (!base.visible()) { … }That list is closed (opacity, visible, zoomRange, plus refresh, clearTileCaches, moveTo, detach, elevations). A named accessor per property is a non-goal — there are 700 and the list grows with the SDK, which is what set / apply / group are for.
A camera move is a flight with a duration, not a property, so it lives on its own object. Set the duration once and every move after it uses that:
map.camera().animate(400).zoom(14).tilt(45);
// position, zoom, rotation and tilt in ONE flight - four separate setters animate
// independently and visibly fight each other
map.camera().moveTo([5.7606, 45.2442], { zoom: 13.6, tilt: 25, duration: 800 });
map.camera().fitBounds([[5.7, 45.1], [5.8, 45.3]]);
const [lon, lat] = map.camera().position();Positions are accepted either way round — [lon, lat] or the plugin's own { lat, lon } — so the two surfaces mix freely.
map.on('map.clicked', (e) => console.log(e.getPos('clickPos')));
base.on('vectortile.clicked', (e) => {
console.log(e.get('featureLayerName'), e.get('featureId'), e.get('feature.properties.name'));
});on, once and off are NativeScript's own. The native subscription is taken when the first handler for an event is added and dropped when the last one goes. Use subscribe(event, handler, options) when one handler needs a different projection from the object's default.
Every handler runs on the main thread. There is no delivery option, because in NativeScript there is no other answer: the SDK emits from its render and tile threads, and there is no JavaScript runtime on those — a callback there does not run late, it fails to run at all. The plugin's own native listener hops onto the main thread and waits before calling into JavaScript, which is also what lets consumed get back to the SDK in time and keeps the payload alive while the handler reads it.
For an event that fires above frame rate — map.moved is 47–159 a second during a drag — use throttle:
map.subscribe('map.moved', reposition, { throttle: 16 });That is a plugin-side drop, not the facade's coalescing. Coalescing replaces a pending payload, and nothing is ever pending when the producer and the handler are the same thread. Never throttle a consumable event: a dropped click is one the SDK is still waiting on.
The payload is valid only while the handler runs — read what you need, do not keep the object.
Claim an event by setting consumed, the way a DOM handler calls preventDefault:
base.on('vectortile.clicked', (e) => {
if (e.get('featureLayerName') === 'poi') {
e.consumed = true; // nothing behind this handler sees the click
}
});e.consumable says whether that will do anything. Only a consumable event can be claimed — vectortile.clicked, vectorelement.clicked and celestial.clicked; map.clicked cannot, because MapEventListener::onMapClicked returns void and there is nothing to tell. Setting it anywhere else is accepted, ignored, and warned about once.
const tile = source.call('loadTile', [8467, 5852, 14]);
const bytes = tile.getData('data'); // an ArrayBuffer, never a string
tile.destroy(); // an object result is yours
const metres = hillshade.call('getElevations', [[5.76, 45.24], [5.77, 45.25]]); // number[], one crossing
// on a worker; `extract` runs while the result is alive, because the facade frees it afterwards
const data = await source.callAsync('loadTile', [[8467, 5852, 14]], (t) => t.getData('data'));A method can be addressed through a path: base.call('tileDecoder.loadTile', [8467, 5852, 14]). A style parameter is not one of them any more - it is a property, base.set('style.params.buildings', 'false').
Routing needs no plugin code — it is create plus callAsync, like everything else:
const router = map.object('routing', 'valhalla', {
type: 'valhalla-online',
profile: 'bicycle',
customServiceURL: 'https://valhalla1.openstreetmap.de/{service}'
});
const request = map.object('routing', 'trip',
{ type: 'request', projection: 'EPSG:4326', points: [[5.72, 45.18], [5.74, 45.24]] },
'massif::RoutingRequest');
request.set('params.language', 'fr-FR'); // a bag again - valhalla's parameters nest, so
request.set('params.costing_options.bicycle.cycling_speed', 22); // a dotted key stays one key
const trip = await router.callAsync('calculateRoute', [request.handle], (result) => ({
metres: result.get('totalDistance'),
// the path is the FLAT channel - a 9 km route is 562 positions
path: result.call('getPoints'),
steps: result.collect((step) => step.get('streetName'),
{ countPath: 'instructionCount', method: 'getInstruction' })
}));profile, customServiceURL and timeout are ordinary properties, so they complete and type-check. The request is one of the few things the SDK still builds with a hand-written factory, so it names its class — that is what the third argument is for, and it is required precisely so a misspelt key in a kind the schema does describe cannot slip through it.
Geocoding is not reachable yet, and that is an SDK gap rather than a plugin one: no geocoding class carries a !spec declaration or a registered method, and the module is absent from the generated schema entirely. Nothing here has to change once it lands — a spec kind and a method row are data.
const base = api.adoptLayer('base', existingLayer.getNative());
base.on('vectortile.clicked', …);The concrete class is read off the native object, so an adopted VectorTileLayer answers to a vector tile layer's properties rather than only to Layer's.
src/ui-massifmaps/api/massif-api.d.ts and schema.ts are generated from the SDK's own docs/api/massif-api.json — the same schema its C++ property table is built from.
massif-api.d.ts is types only — a .d.ts, so it is never compiled and never reaches your bundle, whatever its size. The only runtime cost is schema.ts (~34 KB): each class' own properties, its base, the enum constants, and the method and event tables — the minimum a dotted path needs to be resolved the way the C++ resolves it. It is tree-shaken away entirely if you never import api.
Regenerate them after an SDK bump:
npm run typings.api # reads $MASSIF_SDK_HOME/docs/api/massif-api.json
npm run typings.api.check # typechecks the layer and its typing testsThat is what makes a wrong path, a misspelt spec key, an enum constant from the wrong enum, a method on the wrong class and an unknown event name all compile errors.
- Needs an SDK built with
all/native/api(com.massifmaps.api.MassifApi/MSFMassifApi).api.isAvailable()says whether it is there, and every call throws a clear error when it is not. On Android,-PmassifApi=falsedrops the plugin's own listener shim so the object API still builds against an older SDK. e.consumedneeds an SDK whoseMassifApi.ontakes the consume flag. The rest of the chain is already there — the listener's return value is forwarded all the way toContext::emit, which only acts on it for a subscription that asked to consume, andMassifApi::onpasses a literalfalse.api.canConsume()reports it,e.consumablereports it per event, and a handler that setsconsumedanyway is warned once instead of silently doing nothing.- An object property cannot be read (
get('fogOptions')) — the facade has nogetObject. Usegroup('fogOptions'), which is what you want anyway. - A collection is read one element per crossing (
collect()); a route's path and an elevation profile use the flat numeric channel instead. - Geocoding has no facade surface — see Routing. An SDK gap, not a plugin one.
- An object property's value type is a bare
Handle. The brand is invariant, so branding it would reject the subclasses the SDK accepts (aPolygonGeometrywhere aGeometryis declared). The class is still known —group()stays fully typed — and the SDK downcasts and refuses a wrong class at runtime.
The demo app opens on the same gallery the Android demo does — the SDK's own examples, grouped into the same sections, in the same order, with the same titles, descriptions and screenshots.
That is not a copy: npm run examples reads the SDK's docs/examples/examples.json — the manifest generated from each example's @ExampleInfo annotation, and the one the Android app and the website also build from — and writes demo-snippets/svelte/examples/generated.ts. A title can only be changed in one place, and the three galleries cannot drift.
npm run examples # rebuild the metadata from the manifest
npm run examples.check # typecheck every example's script against the plugin typingsScreenshots are linked, not bundled — raw.githubusercontent.com/massif-maps/MassifMaps/master/docs/examples/screenshots/<id>.png. That keeps 1.6 MB of PNG out of the package, needs no image rule in the host app's bundler, and means a recaptured screenshot shows up without a resync. The grid wants the network the first time it is opened. --image-base <url> points it elsewhere — a fork, a branch, or a local server.
The examples themselves are hand-written ports (the Java is Java, the Svelte is Svelte), written against the surface API. The generator pairs them by id — display-a-map ↔ DisplayAMap.svelte — and reports a manifest entry with no component rather than dropping it.
The menu offers a grid whenever the snippets carry that metadata: section headings, a card per example with its screenshot, title and description. The toolbar switches to the plain list; a snippet package that exports only demos gets the list and nothing changes for it.
examples.check is worth knowing about: a <script lang="ts"> block is ordinary TypeScript, so each one is extracted and run through tsc against the real typings. A misspelt spec key or a property path that does not exist is a build failure rather than a warning on a device.
The repo uses submodules. If you did not clone with --recursive then you need to call
git submodule update --init
The package manager used to install and link dependencies must be pnpm or yarn. npm wont work.
To develop and test:
if you use yarn then run yarn
if you use pnpm then run pnpm i
Interactive Menu:
To start the interactive menu, run npm start (or yarn start or pnpm start). This will list all of the commonly used scripts.
npm run build.allWARNING: it seems yarn build.all wont always work (not finding binaries in node_modules/.bin) which is why the doc explicitly uses npm run
npm run demo.[ng|react|svelte|vue].[ios|android]
npm run demo.svelte.ios # ExampleDemo setup is a bit special in the sense that if you want to modify/add demos you dont work directly in demo-[ng|react|svelte|vue]
Instead you work in demo-snippets/[ng|react|svelte|vue]
You can start from the install.ts of each flavor to see how to register new demos
You can update the repo files quite easily
First update the submodules
npm run updateThen commit the changes Then update common files
npm run syncThen you can run yarn|pnpm, commit changed files if any
npm run readmenpm run docThe publishing is completely handled by lerna (you can add -- --bump major to force a major release)
Simply run
npm run publishThe repo uses https:// for submodules which means you won't be able to push directly into the submodules.
One easy solution is t modify ~/.gitconfig and add
[url "ssh://git@github.com/"]
pushInsteadOf = https://github.com/
If you have any questions/issues/comments please feel free to create an issue or start a conversation in the NativeScript Community Discord.
The repo uses submodules. If you did not clone with --recursive then you need to call
git submodule update --init
The package manager used to install and link dependencies must be pnpm or yarn. npm wont work.
To develop and test:
if you use yarn then run yarn
if you use pnpm then run pnpm i
Interactive Menu:
To start the interactive menu, run npm start (or yarn start or pnpm start). This will list all of the commonly used scripts.
npm run build.allWARNING: it seems yarn build.all wont always work (not finding binaries in node_modules/.bin) which is why the doc explicitly uses npm run
npm run demo.[ng|react|svelte|vue].[ios|android]
npm run demo.svelte.ios # ExampleDemo setup is a bit special in the sense that if you want to modify/add demos you dont work directly in demo-[ng|react|svelte|vue]
Instead you work in demo-snippets/[ng|react|svelte|vue]
You can start from the install.ts of each flavor to see how to register new demos
You can update the repo files quite easily
First update the submodules
npm run updateThen commit the changes Then update common files
npm run syncThen you can run yarn|pnpm, commit changed files if any
npm run readmenpm run docThe publishing is completely handled by lerna (you can add -- --bump major to force a major release)
Simply run
npm run publishThe repo uses https:// for submodules which means you won't be able to push directly into the submodules.
One easy solution is t modify ~/.gitconfig and add
[url "ssh://git@github.com/"]
pushInsteadOf = https://github.com/
If you have any questions/issues/comments please feel free to create an issue or start a conversation in the NativeScript Community Discord.