npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@la-trace/map-sdk

v0.4.0

Published

LaTrace cartography SDK — drop-in MapLibre-based map with LaTrace basemaps, smart pins and tracks.

Readme

@la-trace/map-sdk

Drop-in interactive map for LaTrace integrations: ship the same basemaps, typed POI markers, tracks and shapes the LaTrace product uses, in any web app, in a few lines.

Built on top of MapLibre GL JS. Framework-agnostic — works in vanilla, React, Vue, Svelte, Angular.

npm version


Two entry points

This package ships two independent APIs; pick the one that matches your need.

| | createLaTraceMap | createLaTraceExplore | |---|---|---| | What | A MapLibre map with La Trace basemaps, typed pins, tracks & shapes that you drive yourself. | The full La Trace Explore experience (map + search + filters + POI panels) embedded in an isolated iframe. | | You provide | Pins/tracks/shapes, camera calls, your own UI around it. | POIs (setPois) + config overrides; the SDK renders the whole UX and emits events. | | Peer dep | maplibre-gl (rendered in your page). | None (rendering happens inside the iframe). | | style.css | Required (import '@la-trace/map-sdk/style.css'). | Not needed (styles live inside the iframe). |

createLaTraceMap is documented first (below); createLaTraceExplore has its own section further down. Thanks to "sideEffects": false, importing only one of them tree-shakes the other out of your bundle.


Features

  • 3 LaTrace basemaps out of the box: Plan, Satellite, Relief — MapTiler-backed, MapLibre-rendered.
  • Smart pin layer: collision-aware marker placement + density-sampled circles below the marker zoom threshold. Same algorithm as the LaTrace product.
  • 179 LaTrace POI categories baked-in (Castle, Restaurant, Bakery, Beach, …) with the LaTrace droplet design and the matching color palette.
  • Tracks for GPX-like content (LineString / MultiLineString / GeoJSON Features), with hover & click events.
  • Shapes for zones / overlays (polygon, rectangle, circle by radius), interactive.
  • 3D terrain toggle (MapTiler RGB DEM), one method call.
  • Active POIs: mark a pin as selected — the SDK promotes it to a marker, runs an emphasized animation, and bypasses collision so it never disappears.
  • Built-in MapNav control matching the LaTrace UI (zoom group, basemap picker with previews, 3D, GPS, fullscreen). Each section is individually toggleable, or you can drop the whole nav.
  • Mobile bottom-sheet for the basemap picker, portaled into document.body so it always sits above your app's overlays.
  • Typed events for everything (clicks, hovers, camera, basemap, terrain, active pins).

Install

npm install @la-trace/map-sdk maplibre-gl
# or pnpm add @la-trace/map-sdk maplibre-gl
# or yarn add @la-trace/map-sdk maplibre-gl

maplibre-gl is a peer dependency.


Quick start

<div id="map" style="width: 100%; height: 480px;"></div>
import '@la-trace/map-sdk/style.css';
import { createLaTraceMap, laTracePoiPin } from '@la-trace/map-sdk';

const map = createLaTraceMap({
  container: '#map',
  apiKey: 'your-latrace-api-key',
  basemap: 'plan',
  center: [2.3522, 48.8566],
  zoom: 5,
});

map.on('ready', () => {
  map.addPin(
    laTracePoiPin({
      id: 'eiffel',
      lat: 48.8584,
      lng: 2.2945,
      label: 'Tour Eiffel',
      type: 'Monument',
      score: 100,
    }),
  );
});

map.on('pin:click', ({ pin }) => {
  console.log('Clicked', pin.id);
  map.setActivePins([pin.id]);
});

The bundled stylesheet already embeds maplibre-gl.css, so the import above is the only one you need.


API reference

createLaTraceMap(options)

| Option | Type | Default | Notes | |---|---|---|---| | container | string \| HTMLElement | required | CSS selector, element id, or DOM node. | | apiKey | string | required | Your LaTrace API key (pass-through for now, future-gated). | | basemap | 'plan' \| 'satellite' \| 'topo' | 'plan' | Initial style. | | center | [lng, lat] | [2.3522, 48.8566] | Initial center. | | zoom | number | 5 | Initial zoom. | | minZoom / maxZoom | number | — | Optional clamps. | | pins | PinClusteringOptions | see below | Marker clustering tuning. | | mapNav | false \| MapNavOptions | {} (all on) | Built-in nav control config. |

PinClusteringOptions

| Field | Default | Notes | |---|---|---| | markerExclusionRadiusPx | 180 | Half-size of the no-collision square around each marker. | | minMarkerZoom | 4.5 | Below this zoom, every pin is a circle (active pins still render as markers). | | maxCirclePoints | 1500 | Cap on circles drawn after density sampling. | | circleColor | '#0D1D27' | Default circle fill. Per-pin override via pin.circleColor. |

MapNavOptions

| Field | Default | Notes | |---|---|---| | fullscreen | true | Show the fullscreen button. | | stylePicker | true | Show the basemap picker. | | terrain3d | true | Show the 3D toggle. | | gps | true | Show the GPS button. | | zoom | true | Show the zoom group. | | position | 'bottom-right' | MapLibre ControlPosition. |

LaTraceMap instance

// Pins
map.addPin(pin);
map.addPins(pins);
map.removePin(id);
map.clearPins();
map.setPinOptions({ circleColor: '#15803d' });
map.addLaTracePin({ id, lat, lng, type: 'Castle' });

// Active selection (force-promote to marker + emphasized variant)
map.setActivePin(id);
map.setActivePins([id1, id2]);
map.unsetActivePin(id);
map.clearActivePins();
map.getActivePins();

// Tracks (GeoJSON LineString / MultiLineString / Feature / FeatureCollection)
map.addTrack({ id, geometry, color, width, opacity });
map.updateTrack({ id, geometry, ... });
map.removeTrack(id);
map.clearTracks();

// Shapes
map.addShape({ kind: 'rectangle', id, sw, ne, fillColor, ... });
map.addShape({ kind: 'circle', id, center, radiusMeters, ... });
map.addShape({ kind: 'polygon', id, rings, ... });
map.updateShape(shape);
map.removeShape(id);
map.clearShapes();

// Camera
map.flyTo({ center, zoom, duration });
map.fitBounds([[w, s], [e, n]], padding);
map.setBasemap('satellite');
map.getBasemap();
map.setTerrain3d(true);
map.getTerrain3d();
map.toggleTerrain3d();

// Geolocation (drives MapLibre's GeolocateControl, blue pulsing dot)
map.triggerGeolocate();

// Lifecycle
map.destroy();

// Escape hatch — the underlying maplibregl.Map
map.raw;

Events

map.on('ready', () => {});
map.on('pin:click',        ({ pin, lngLat }) => {});
map.on('pin:mouseenter',   ({ pin, lngLat }) => {});
map.on('pin:mouseleave',   ({ pin, lngLat }) => {});
map.on('track:click',      ({ track, lngLat }) => {});
map.on('track:mouseenter', ({ track, lngLat }) => {});
map.on('track:mouseleave', ({ track, lngLat }) => {});
map.on('shape:click',      ({ shape, lngLat }) => {});
map.on('shape:mouseenter', ({ shape, lngLat }) => {});
map.on('shape:mouseleave', ({ shape, lngLat }) => {});
map.on('map:click',        ({ lngLat, point }) => {});
map.on('map:move',         (snapshot) => {});  // continuous
map.on('map:moveend',      (snapshot) => {});  // settled
map.on('map:zoom',         ({ zoom }) => {});
map.on('map:zoomend',      ({ zoom }) => {});
map.on('basemap:change',   ({ basemap }) => {});
map.on('terrain3d:change', ({ enabled }) => {});
map.on('activepins:change', ({ ids }) => {});

MapCameraSnapshot (passed to map:move / map:moveend):

{ center: [lng, lat], zoom, bearing, pitch, bounds: { sw: [lng, lat], ne: [lng, lat] } }

map.on(event, handler) returns an unsubscribe function. map.off(event, handler) works too.

Pin shape

interface Pin {
  id: string;          // stable, used for events / updates / removal
  lat: number;
  lng: number;
  score?: number;      // higher = wins collision priority
  label?: string;      // echoed back in events; SDK does not render it
  circleColor?: string;
  markerHtml?: string;          // see security note below
  markerHtmlEmphasized?: string;
  anchor?: 'center' | 'top' | 'bottom' | 'left' | 'right';
  data?: unknown;      // arbitrary, echoed back
}

A pin without markerHtml stays a circle forever — even if you call setActivePin on it. To get the LaTrace droplet automatically, build the pin via laTracePoiPin({ type }):

import { laTracePoiPin } from '@la-trace/map-sdk';

const pin = laTracePoiPin({
  id: 'p1', lat: 48.85, lng: 2.34, score: 90,
  type: 'Restaurant',  // one of 179 LaTrace categories
});

type is a LaTracePoiType string union — your editor will autocomplete the full list.


La Trace Explore (createLaTraceExplore)

The Explore API embeds the complete La Trace Explore experience (map, search, filters, POI panels) into any web page or native webview. You mount it in a container, push your POIs and config overrides; the SDK renders the UX inside an isolated iframe and emits typed events back to your page.

Key model:

  • Isolated iframe + typed bridge. The SDK mounts a La Trace app iframe and exposes a typed JS API over a postMessage bridge. You never write raw postMessage.
  • Zero-storage. La Trace stores none of your POI data. You push it (setPois); search and filters run over that corpus in memory.
  • Config = base + overrides. The base config (theming, sections, wording, rights) is resolved server-side from configId. You only push targeted ConfigOverrides on top.
  • No style.css. Unlike createLaTraceMap, Explore needs no stylesheet import; all styling lives inside the iframe.

Install

npm install @la-trace/map-sdk
# maplibre-gl is NOT needed for createLaTraceExplore

Quick start

<div id="map" style="width: 100%; height: 100vh;"></div>
import { createLaTraceExplore } from '@la-trace/map-sdk';
// No stylesheet import needed for Explore.

const explore = createLaTraceExplore({
  container: '#map',
  apiKey: 'pk_live_xxx',                 // publishable key (pk_live_* / pk_test_*)
  configId: '9c2f98f1-394a-41d9-a7ba-99de654b7e6d',
  locale: 'fr',
  config: { poiDetailMode: 'externalPreview' },
});

// createLaTraceExplore returns synchronously; the map is ready after `ready()`.
await explore.ready();

const result = explore.setPois(pois);    // see PushResult below
if (result.rejected.length) console.warn('rejected POIs', result.rejected);

explore.on('pin:click', ({ poi }) => console.log('clicked', poi.id));

ExploreOptions (required: container, apiKey, configId):

| Option | Type | Notes | |---|---|---| | container | string \| HTMLElement | CSS selector, id, or DOM node. | | apiKey | string | Publishable key. Pass-through for now, server-validated later. | | configId | string | Base config + stats key. | | config | ConfigOverride | Targeted overrides (see Theming below). | | locale | 'fr' \| 'en' \| 'nl' | Precedence: option > config.locale > browser > 'fr'. | | initialView | { center?, zoom?, bbox? } | center is [lng, lat], bbox is [w, s, e, n]. | | pois | Poi[] | POIs at boot (same as calling setPois after ready). | | initialFilters | ActiveFilters | Pre-filtering at boot. | | initialFavorites | string[] | Pre-filled hearts (rehydration). | | state | ExploreState | Full state restore (deep-link). | | allow | string[] | iframe permissions. Default ['geolocation','fullscreen']. |

Pushing POIs

You own the corpus; the SDK never stores it. Push methods return a PushResult, so you always know what was accepted vs. rejected.

interface PushResult {
  accepted: number;
  rejected: Array<{ id?: string; reason: PushRejectReason }>;
}
type PushRejectReason =
  | 'missing_id' | 'duplicate_id' | 'invalid_coords'
  | 'unknown_category' | 'invalid_field';

An invalid POI is rejected (never rendered wrong) and reported both in PushResult.rejected and via the pois:rejected event. The rest of the batch is still accepted. unknown_category is a warning: the POI is accepted with a neutral marker but still listed in rejected.

A minimal Poi (see the full shape in the exported Poi type):

explore.setPois([
  {
    id: 'r-42',                 // opaque host id, the key everywhere (required)
    coords: [2.3611, 48.8674],  // [lng, lat] WGS84 (required)
    category: 'restaurant',     // mapped to a La Trace PoiType (required)
    name: 'Chez X',             // string or { fr, en, nl }
    priceRange: '€€',
    externalUrl: 'https://host.example/chez-x',
  },
]);

Push methods:

| Method | Effect | |---|---| | setPois(pois) | Replaces the whole displayed set. Diffed by id (no flicker). | | addPois(pois) | Upsert by id. | | updatePoi(poi) | Full replacement of one POI by id (not a partial patch). | | removePois(ids) | Remove by id. | | clearPois() | Remove all. | | setLoading(loading) | Show the native skeleton while you fetch. |

Recommended corpus size: ≤ ~5 000 POIs. Beyond that, push by zone (addPois/removePois on viewport:change / search:area).

"Search this area" is yours

In pushed-corpus mode the SDK never searches by area itself. When the user clicks "Search this area", the map only reports the current viewport bbox via the search:area event. You query your own backend and re-push:

explore.on('search:area', async ({ bbox }) => {
  explore.setLoading(true);
  try {
    const pois = await myBackend.search(bbox); // [west, south, east, north]
    explore.setPois(pois);                     // REPLACES the corpus
  } finally {
    explore.setLoading(false);                 // always, even on error
  }
});

setPois replaces the corpus (POIs absent from the list disappear); addPois adds to it. The map does not recenter after a push — the user chose that area, so the camera stays put. Call fitBounds(bbox) to recenter explicitly.

Instance methods

// Lifecycle
explore.ready();      // Promise<void>
explore.resize();     // force a resize if the container changed outside ResizeObserver
explore.destroy();

// Live config
explore.setConfigOverride(partialConfig);
explore.setLocale('en');

// Camera
explore.flyTo({ center: [lng, lat], zoom }, { durationMs: 600 });
explore.fitBounds([w, s, e, n], { padding: 40 });
explore.setCenter([lng, lat]);
explore.setZoom(12);
explore.getViewport();   // last-known cache, refreshed by `viewport:change` (not real-time)

// Selection / panels
explore.openPoi(poiId);
explore.closePanel();
explore.highlightPoi(poiId);   // null to clear

// Search & filters
explore.search(query);
explore.clearSearch();
explore.setActiveFilters(filters);
explore.clearFilters();
explore.setRecentSearches(items);

// Favorites
explore.setFavorites(ids);     // rehydrate full hearts

// State (deep-link / restore)
explore.getState();            // last-known cache
explore.applyState(state);

// Native / webview geolocation
explore.setUserLocation({ lng, lat, accuracy });   // null to clear

// Events
explore.on(event, handler);
explore.off(event, handler);

Getters (getViewport, getState) return the last-known cached state refreshed by events over the async bridge, not a real-time read. For a guaranteed-fresh value, listen to the matching event.

Events

explore.on('ready', () => {});
explore.on('pin:click', ({ poi }) => {});

| Event | Payload | Usage | |---|---|---| | ready | {} | Init finished. | | error | { code, message } | Auth / network / config / render. | | pin:click | { poi } | Marker clicked. | | pin:hover | { poiId \| null } | Map hover (enter/leave). | | poi:view | { poiId } | Panel / preview shown. | | gallery:open | { poiId } | Gallery opened. | | pois:rejected | { rejected[] } | Invalid POIs at push time. | | preview:open | { poiId } | externalPreview preview opened. | | preview:close | { poiId, navigated } | Preview closed (dismiss vs. navigation). | | external:open | { poiId, url } | Navigate to external page. | | reservation:click | { poiId, url } | Reservation CTA. | | search:query | { query } | Debounced typing. | | search:submit | { query, resultCount } | Search submitted. | | search:area | { bbox } | "Search this area" — you query and re-push (see below). | | results:empty | { context } | No results ('search' \| 'filters' \| 'viewport'). | | filter:change | { filters } | Active filters changed. | | viewport:change | Viewport | Pan/zoom settled. | | basemap:change | { basemap } | Basemap changed. | | geolocate | { coords } | "My location". | | state:change | ExploreState | State mutation (sync your URL). | | back | {} | Back triggered inside the iframe (mobile). | | favorite:toggle | { poiId, favorited } | Persist favorite host-side. | | locale:change | { locale } | Language switch (re-push translated corpus if needed). |

Every poiId payload uses the id you pushed (pass-through), so events wire straight into your analytics.

Theming & config overrides

config (at init) and setConfigOverride(...) (live) merge targeted overrides on top of the base config resolved from configId. You only push what you override.

const explore = createLaTraceExplore({
  container: '#map',
  apiKey: 'pk_live_xxx',
  configId: '9c2f98f1-...',
  config: {
    poiDetailMode: 'externalPreview',        // 'panel' | 'externalPreview'
    theme: {                                  // CSS tokens --lt-*
      '--lt-action-primary-surface-default': '#FFDD33',
      '--radius-md': '0',
    },
    fonts: { headings: { family: 'DIN 1451', url: 'https://cdn.example/din.woff2' } },
    poiColors: {                              // SINGLE source for markers + filters
      restaurant: { background: '#FFF3B0', text: '#FFDD33' },
      wineshop:   { background: '#E7DCEE', text: '#774192' },
    },
    poiIcons: {                               // custom marker logos, replacing the glyph
      Restaurant: 'https://cdn.example/pins/resto.svg', // key = PoiType (exact case)
      wineshop:   'https://cdn.example/pins/cave.png',  // key = host category (case-insensitive)
    },
    wording: {                                // relabels the result counter
      poiNounSingle: 'Résultat',
      poiNounPlural: 'Résultats',
    },
    filters: [
      { key: 'category', label: "Type", type: 'category', options: [
        { value: 'restaurant', label: 'Restaurants', color: '#FFDD33' },
        { value: 'bar', label: 'Bars' },
      ] },
      { key: 'terrasse', label: 'Terrasse', type: 'toggle', group: 'essentials' },
    ],
    search: { placeholder: 'OÙ ALLEZ-VOUS ?', geocoding: true, recentSearches: true },
    favorites: { mode: 'host' },              // 'off' | 'stateless' | 'host'
    units: 'metric',                          // 'metric' | 'imperial'
  },
});

Notable ConfigOverride fields: theme (--lt-* tokens), fonts, poiColors (single source for marker + filter colors), poiIcons, filters, sections, poiDetailMode, wording, ads, mapNav, ui (preset: 'bare' for a naked map), basemaps, search, favorites, units, locale. See the exported ConfigOverride type for the full shape.

poiIcons replaces the glyph at the centre of the marker with your own logo; the pin's shape and colour stay driven by poiColors. Precedence: poiType > category > La Trace glyph. Accepted values: an https URL (SVG or PNG) or a data:image/svg+xml URI — a data URI of any other type is rejected, as is http:. A rejected or broken URL silently falls back to the glyph, never an empty pin.

wording.poiNounSingle / poiNounPlural relabel the result counter ("1 Résultat" / "128 Résultats"). The map picks the form from the total (plural from 2); the fallback is key-by-key, so pushing only the plural leaves the singular on La Trace i18n rather than printing "1 Résultats". These are raw strings: re-push them on locale:change for a multilingual site.

Place search (createLaTraceGeocoder) — browser

Driving your own search bar (outside the Explore iframe)? Use the geocoder helper instead of hand-rolling fetch + a prediction cache + bbox normalisation:

import { createLaTraceGeocoder } from '@la-trace/map-sdk';

const geocoder = createLaTraceGeocoder({
  apiKey: 'pk_live_xxx',
  apiBase: 'https://api.latrace.com/sdk/v1',
  countries: 'fr,be', // optional — omit to let the server resolve scope from the key
});

const predictions = await geocoder.autocomplete('canal saint martin');
const location = await geocoder.geocode({ predictionId: predictions[0].id });
// location = { center: { lat, lng }, viewport?: { west, south, east, north }, formattedAddress }

viewport is normalised to min/max internally (Photon can return the extent with unordered corners). The Explore iframe already has its own internal search (config.search.geocoding); this helper is only for a host-owned search UI.

Structured address — for a form that auto-fills city / department / region / country in separate fields, opt into the enriched response:

const geocoder = createLaTraceGeocoder({
  apiKey: 'pk_live_xxx',
  apiBase: 'https://api.latrace.com/sdk/v1',
  fields: ['address'],       // -> fills `location.address`
  langs: ['fr', 'en', 'nl'], // -> resolves place names per locale
});

const location = await geocoder.geocode({ predictionId: predictions[0].id });
location.address?.city?.nl;       // 'Gent'
location.address?.department?.fr; // 'Loire-Atlantique'  (Photon `county`)
location.address?.postcode;       // '9000'  — NOT localized, a plain string

postcode and countryCode are plain strings; city / department / region / country are { fr?, en?, nl? } objects, and every locale key is optional. The address travels through the prediction cache, so autocompletegeocode costs no second fetch.

nl limitation: our Photon index is built en,fr,de, so for nl we return local names (Gent, Brussel) rather than true Dutch exonyms (Luik for Liège). Accurate for Flanders, inaccurate for Wallonia. Index rebuild pending.

Navigation deep-links (buildNavigationUrl)

Making an editorial static-map thumbnail clickable? buildNavigationUrl builds the Google Maps URL that starts navigation to a POI. These are public Google Maps URLs, not the Maps API: no key, no quota. The helper is pure (no fetch), so it works server-side too.

import { buildNavigationUrl } from '@la-trace/map-sdk';

buildNavigationUrl(poi);
// -> 'https://www.google.com/maps/dir/?api=1&destination=48.8674%2C2.3611'

buildNavigationUrl({ lng: 2.3611, lat: 48.8674 }, { travelmode: 'walking' });
<a href={buildNavigationUrl(poi)} target="_blank" rel="noopener">
  <img src={staticMapSrc} alt={`Map — ${poi.name}`} />
</a>

Google expects destination=<lat>,<lng> — the reverse of the SDK's canonical [lng, lat]. The helper flips it for you; that inversion is the whole reason it exists. It never "fixes" a pair that looks swapped but is valid (48.8674 is a legal longitude): the documented [lng, lat] order wins.

Static map images (signStaticMapUrl) — backend only

For editorial pages you can request a server-rendered map image with La Trace markers and DA. An <img> cannot send an auth header, so the static-map endpoint authenticates by query-param + a signed, expiring URL. Sign it on your backend with the per-key signingSecret, then emit the resulting <img src>.

// BACKEND ONLY — never import this in browser code.
import { signStaticMapUrl } from '@la-trace/map-sdk/static-map';

const url = signStaticMapUrl(process.env.LATRACE_SIGNING_SECRET, {
  baseUrl: 'https://api.latrace.com/sdk/v1/static-map',
  configId: '9c2f98f1-...',
  key: 'pk_live_xxx',
  center: [2.3611, 48.8674],
  zoom: 15,
  width: 640,
  height: 360,
  // 'lng,lat[,type[,color[,icon]]]' joined by ';', max 50. `type` is a La Trace
  // PoiType (capitalised), `color` a hex fill, `icon` an https logo URL.
  markers: '2.3611,48.8674,Restaurant,#c7fb0e',
  scale: 2,
});
// -> <img src={url} />

Warning: the signingSecret is a real secret and is not the publishable key. It must stay on your backend, never ship to the browser, never bundle into client code. This helper lives on a separate entry point (@la-trace/map-sdk/static-map) and uses node:crypto precisely so it cannot be pulled into a browser bundle by accident.


Security

HTML injection in custom markers

The SDK inserts pin.markerHtml, pin.markerHtmlEmphasized, and any custom iconSvg you pass to renderLaTracePoiMarkerHtml via innerHTML. If you build those strings from arbitrary user data, you are responsible for escaping it.

The SDK exports an escapeHtml helper you can use for the common case:

import { escapeHtml } from '@la-trace/map-sdk';

const pin = {
  id: place.id, lat: place.lat, lng: place.lng,
  markerHtml: `<div class="poi">${escapeHtml(place.userSubmittedName)}</div>`,
};

For richer untrusted content (HTML coming from a CMS, a third-party API, etc.), run it through your usual sanitizer (DOMPurify, sanitize-html, …) before handing it to the SDK.

MapTiler API key

The MapTiler API key used to fetch the LaTrace basemaps is embedded in the SDK bundle at build time. It is not a secret in the defense-in-depth sense — anyone inspecting the JavaScript can extract it — and that's expected for client-side maps. The key will be locked down to a list of LaTrace-approved origin domains via the MapTiler dashboard once integration partners are onboarded; until then, the same key is shared across all SDK consumers.

LaTrace API key (apiKey option)

The apiKey you pass to createLaTraceMap({ apiKey }) is stored but not validated in this version. It will be checked server-side in a future release, alongside the MapTiler-key origin whitelisting, so plan your integration around providing a real key per environment now — swapping it later won't require a code change on your side.

What is sent over the network

The SDK only talks to MapTiler (basemap tiles, optional terrain DEM tiles, optional geocoding) — no LaTrace endpoint is contacted by default. Anything you display via pins/tracks/shapes stays in the browser unless your own code uploads it elsewhere.


TypeScript

Everything is typed:

import type {
  CreateLaTraceMapOptions,
  Basemap,
  Pin,
  Track,
  Shape,
  PinClusteringOptions,
  MapNavOptions,
  LaTracePoiType,
  MapCameraSnapshot,
  // …
} from '@la-trace/map-sdk';

The escape hatch map.raw is typed as maplibregl.Map.


Frameworks

The SDK is vanilla — no framework lock-in. Wrap it in whatever you use:

// React
import { useEffect, useRef } from 'react';
import { createLaTraceMap, type LaTraceMap } from '@la-trace/map-sdk';

export function Map({ apiKey }: { apiKey: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const mapRef = useRef<LaTraceMap | null>(null);

  useEffect(() => {
    if (!ref.current) return;
    const map = createLaTraceMap({ container: ref.current, apiKey });
    mapRef.current = map;
    return () => map.destroy();
  }, [apiKey]);

  return <div ref={ref} style={{ width: '100%', height: 480 }} />;
}

Examples

Live demos covering the main integration patterns are maintained in a dedicated public repo: latrace-code/la-trace-sdk-examples.

| Example | Stack | What it shows | |---|---|---| | basic-vanilla | Vite + TS | Full-feature tour: typed POIs, custom markers, tracks, shapes, every event logged. | | react-app | Vite + React + TS | Reusable <LaTraceMapView> component, sidebar-driven selection. | | tracking-trip | Vite + TS | Animated GPX-style replay with follow-camera and scrubber. |

Clone the repo (or open one of the examples on StackBlitz / CodeSandbox using the GitHub import URL) — each folder runs with pnpm install && pnpm dev, no monorepo setup required.


License

Proprietary — see LICENSE. Use is permitted as part of a LaTrace integration; redistribution requires a written agreement.