tec-mapkit
v3.0.0
Published
The TEC campus map as a licensed npm package. Ships the basemap, its districts and listings, trek trails, geofenced side-quests and directions over the campus road network — all offline, no tile server and no data to supply. Requires a licence key.
Maintainers
Readme
tec-mapkit
The TEC campus map, as an npm package.
Install it, add your key, and you have the map: the basemap, its districts, its business listings, and directions between them. There is no data to supply, no tile server and no configuration step.
tec-mapkit is a commercial package. There is no free tier and no degraded mode: without a valid key the map renders nothing and downloads no data. See Licensing.
Getting started
1. Install.
npm install tec-mapkit maplibre-gl2. Check the setup before you write anything.
npx tec-mapkit doctor $MAPKIT_KEYtec-mapkit doctor v3.0.0
Environment
✓ Node — 22.15.0
✓ WebCrypto — available
Dependencies
✓ maplibre-gl — 4.7.1
✓ react — 18.3.1 — tec-mapkit/react is usable
Licence key
✓ signature — valid — issued by tec-mapkit
✓ licensed to — Acme Ltd
✓ domains — acme.com
✓ expiry — 2027-01-30 (356 days)Every one of those checks fails the same way in a browser — an empty rectangle —
so it's worth ten seconds up front. The doctor exits non-zero on a real problem,
which makes it a usable CI step. It makes no network calls: keys verify offline,
so it works on a plane and behind a firewall. Keys are read from the argument,
then MAPKIT_KEY, then VITE_MAPKIT_KEY.
3. Render it. The map fills its parent, so the parent needs a height.
import 'maplibre-gl/dist/maplibre-gl.css';
import { MapCanvas } from 'tec-mapkit/react';
<div style={{ height: '100vh' }}>
<MapCanvas apiKey={process.env.MAPKIT_KEY} directions />
</div>That's the whole integration. If something is wrong — no key, an expired one, a missing peer dependency, a container with no height — the map says so on screen rather than rendering blank.
What it is (and isn't)
This package renders one map. The data is the product: the campus geometry, the
districts and listings curated through packages/admin, and the road
network directions run over. The API is about how that map looks and behaves —
camera, palette, clustering, directions, which layers are visible.
It deliberately does not accept your own basemap, zones, markers, road network or style spec. Passing one logs a warning and is ignored. If you need a map that renders arbitrary GeoJSON, this is the wrong package — that's a different product with different licensing and support questions attached.
<MapCanvas basemap="/mine.geojson" /> // warns, renders the campus map anywayLicensing
A key is a signed token carrying your organisation, your licensed domains and an expiry. It's verified in the browser against a public key embedded in the package — there's no call home, so your map works offline, behind a firewall, and doesn't go down when we do. It also means no per-request billing and no tile bill.
<MapCanvas apiKey="mk_live_…" />// Verify one yourself — to fail a build early, or show your own message.
import { verifyLicense } from 'tec-mapkit';
const { customer, domains, expires } = await verifyLicense(key);Keys are safe to ship in your bundle, the same way a publishable map token is: editing
one to extend its term or add a domain breaks its signature, so it stops verifying.
localhost always passes the domain check, so developing against a production key
works without a second key.
When something's wrong
Setup problems render an explanation in place of the map instead of leaving a
blank rectangle — a missing key, an expired one, a key for another domain, a
missing maplibre-gl, or a container with no height. Each also fires onError,
so you can replace the built-in message with your own.
onError receives a LicenseError with a code:
| code | means |
|---|---|
| missing | No apiKey was passed |
| malformed | Not a key — wrong prefix or truncated |
| invalid | Signature failed — not issued by us, or edited |
| expired | Past the key's expiry date; renew it |
| domain | Valid key, but not licensed for this hostname |
| no-crypto | WebCrypto unavailable — usually plain HTTP instead of HTTPS |
On what this does and doesn't enforce. The map data ships inside the package, so verification is offline and therefore a licence rather than a lock: someone determined can patch the check out or read
node_modules. That's true of every offline-licensed library. Keys are signed and attributable precisely because the meaningful remedy for that is commercial rather than technical. Genuine metering would require serving the data from an API instead of shipping it.
Issuing keys (maintainers)
node scripts/gen-keypair.mjs # once, ever
node scripts/issue-key.mjs -c "Acme" -d acme.com --months 12.license-key.json is the private signing key: gitignored, never published, and the
entire security of the scheme — anyone holding it can mint unlimited keys. Back it up,
and note that regenerating it invalidates every key ever issued.
Keys can't be revoked before their expiry (there's nothing to revoke against), so prefer yearly terms over perpetual ones.
Contents
| | |
|---|---|
| Basemap | The campus OSM extract — roads, landuse, water — clipped to ~11 × 11 km around campus and simplified. ~320 KB of GeoJSON |
| Districts | Named campus areas with hover, zone:click and region:change. Authored via packages/admin |
| Businesses | Clickable listings with category icons. Authored via packages/admin |
| Trails | Trek routes with difficulty and measured length, drawn dashed so they never read as roads |
| Side-quests | Geofenced points that unlock when someone physically stands inside their radius |
| Directions | Dijkstra over the shipped road network, weighted by travel time per road class, drawn on the map |
| Themes | vale (default), midnight, slate, daylight, or your own token bag |
The map fills its parent, so the parent needs a height — the most common cause of a blank map. The engine logs a warning naming the container size when it sees one.
Directions
directions renders the built-in panel: pick two of the map's own places, or drop a
pin anywhere, and the route is drawn with distance and duration.
<MapCanvas directions onRouteChange={r => setEta(r?.durationSeconds)} />Imperatively:
const engine = new MapEngine({ container });
await engine.mount();
engine.getLocations(); // [{ id, name, kind: 'business' | 'district', lng, lat }, …]
engine.showRoute([77.9689, 30.4078], [77.9700, 30.4150]);
// -> { distanceMeters, durationSeconds, path, snappedFrom, snappedTo }
engine.clearRoute();showRoute() returns null when the two points aren't connected — clearing any
previously drawn route, so the map never shows a stale path beside a failed lookup.
route() is the same computation without drawing anything, and estimateTravel()
gives a straight-line figure for a rough "how far" that doesn't need the network.
Routing uses the shipped basemap, which carries highway and oneway, so it needs no
setup. Endpoints snap to the nearest road; a point implausibly far from any road
(maxSnapMeters, default 500 m) yields null rather than a misleading path.
Trails
Trek routes ship alongside the listings, drawn dashed and coloured by difficulty.
distanceMeters is measured from the geometry at build time, so it can never disagree
with the line it describes.
engine.getTrails(); // [{ id, name, difficulty, distanceMeters, geometry }, …]
engine.showTrail('bidholi-ridge-loop'); // frames it
engine.setTrailsVisible(false);Clicking one opens its card and fires trail:click.
Side-quests
A quest is a point, a radius and something to say when someone stands inside it.
watchQuests starts a high-accuracy position watch and activates them as they're
reached; quest:enter fires once on the way in and quest:leave once on the way out.
<MapCanvas apiKey={key} watchQuests onQuestEnter={q => celebrate(q.reward)} />engine.startQuests(); // watch the device position
engine.updatePosition([lng, lat]); // or drive it from a position you already have
engine.getQuests(); // each flagged with `active`
engine.resetQuests();Entering uses the radius, but leaving needs 25% beyond it. Without that dead band a GPS fix wandering at the boundary — which is what real fixes do standing still — fires and unfires the quest repeatedly, which as a UI is a card flickering in and out.
For the same reason a radius under 20 m is rejected at build time: it's smaller than the positioning error that has to land inside it, so it would mostly never trigger.
updatePosition() being public is what makes this buildable and demoable at all —
otherwise testing a geofence means physically walking to it.
Partner actions
A listing's category decides which call-to-action its card gets: scooter rentals book through Rentie, restaurants and cafés order through Owlit. Anything else gets no button, which is better than an irrelevant one.
Links resolve per-listing first (rentieUrl / owlitUrl on the record, set in the
admin tool) and fall back to a per-partner template. Matching is substring-based and
case-insensitive, because "Scooty", "Scooter Rental" and "Two-Wheeler Rentals" are all
the same thing to whoever typed them.
// Override the mapping, or add partners of your own.
<MapCanvas apiKey={key} partners={[...DEFAULT_PARTNERS, myPartner]} />import { partnerAction } from 'tec-mapkit';
partnerAction(listing); // { id, label, href } | nullOptions
Everything below is on both <MapCanvas> and new MapEngine({...}).
| | |
|---|---|
| palette | 'midnight' · 'slate' · 'daylight' · your own tokens |
| center / zoom | Default the campus centre at zoom 14 |
| minZoom / maxZoom | Default 12.5 / 18 — below 12.5 the viewport is wider than the data |
| maxBounds | Defaults to the box the geometry was clipped to |
| districts / businesses / trails / quests | Show or hide any bundled layer |
| watchQuests | Watch the device position so quests activate. Off by default — it prompts for permission |
| infoCard / partners | The card on selection, and its partner call-to-actions |
| clustering | Off by default while listings show — clustering hides individual pins below clusterMaxZoom |
| directions / routeColor | The directions panel and the colour of its line |
| interactive / dragRotate / flyIn | Camera behaviour |
| categoryColors / categorySymbols | Per-category pin colour and glyph |
Events
ready · move · marker:click · cluster:click · zone:click · zone:enter ·
region:change · route:change · trail:click · quest:enter · quest:leave ·
locate · error
Available as on* props and via engine.on(), which returns an unsubscribe function.
Methods
| method | purpose |
|---|---|
| mount() / destroy() | Lifecycle; mount() resolves once the style is loaded |
| getLocations() | Every routable place on the map |
| getTrails() / showTrail() / setTrailsVisible() | Trails |
| getQuests() / startQuests() / stopQuests() / resetQuests() | Quests |
| updatePosition(point) | Test a position against every geofence |
| showRoute() / clearRoute() / getRoute() | Directions |
| route() / estimateTravel() | Distance and time without drawing |
| setActiveMarker(id) | Highlight one listing |
| setZonesVisible(bool) | Toggle the district overlay |
| flyTo / easeTo / fitBounds / zoomIn / zoomOut / recenter | Camera |
| getView() / getRegion() | Current camera, and the district under it |
| startLocate() / stopLocate() | Device position with a pulsing dot |
Without React
import { MapEngine } from 'tec-mapkit';
import maplibregl from 'maplibre-gl';
const engine = new MapEngine({
container: document.getElementById('map'),
apiKey: 'mk_live_…',
maplibregl,
});
await engine.mount(); // rejects with a LicenseError if the key doesn't check out
engine.on('marker:click', m => console.log(m.id, m.meta));MapLibre is a peer dependency and the engine finds it on its own: the maplibregl
option first, then window.maplibregl (how a CDN <script> tag exposes it), then the
package itself. Import maplibre-gl/dist/maplibre-gl.css once in your app; if you
don't, the engine injects a minimal fallback so the controls still land in the corners
instead of stacking underneath the map.
Server rendering is safe — MapLibre is only pulled in by a dynamic import() inside a
browser-only effect. Mark the component 'use client' in the Next.js App Router.
Reading the data
tec-mapkit/campus exposes what ships, for listing places or testing a point against
the covered area. You can read it; you can't feed it back in, and you don't need to —
the map loads it itself.
import { campusBasemap, campusDistricts, campusBusinesses, CAMPUS_BOUNDS, CAMPUS_VIEW }
from 'tec-mapkit/campus';It's a separate lazy chunk behind a dynamic import(), so it isn't fetched until a map
actually mounts.
Editing the shipped data
data/districts.json, data/businesses.json, data/trails.json and data/quests.json
are the source of truth. Districts and businesses are edited through a local-only admin
app — draw a district by clicking points, place a business by clicking where it sits;
it writes straight to disk and is never built or deployed. Trails and quests are hand-
edited JSON for now, and validated on the way into the bundle.
npm run admin # author districts and businesses
node scripts/gen-overlays.mjs # bake data/*.json into the shipped bundleThe basemap itself is regenerated from an OSM extract:
node scripts/gen-basemap.mjs [path/to/map.geojson]Both steps are deliberately manual — nothing rebuilds the bundle automatically, so an editing session only ships when you say it does.
CLIP_RADIUSingen-basemap.mjsis the coverage/size dial, andCAMPUS_VIEW'smaxBoundsis derived from it, so the camera can never reach a region with no data. Widening it grows the bundle roughly with its square. Note that simplification there preserves shared vertices on purpose: plain Douglas-Peucker deletes the vertex where a side street meets a main road, which leaves the map looking perfect while routing silently returnsnull— see the comment in that file before changing it.
Attribution
The basemap is a real OpenStreetMap extract, so it carries the standard "Map data ©
OpenStreetMap contributors" credit by default via MapLibre's AttributionControl. This
isn't cosmetic: OSM data is licensed under
ODbL, which requires attribution when the
data is redistributed. It's on by default because a missing credit is a compliance gap,
not a style preference.
<MapCanvas attribution="© Your Company · Map data © OpenStreetMap contributors" />License
Commercial and proprietary — see LICENSE. The Software is licensed, not sold: one licence per product, for the term of your key, on the domains recorded in it. Redistributing the package, or bypassing the key check, is not permitted.
The bundled basemap is derived from OpenStreetMap and remains under ODbL, which this licence does not supersede. The "Map data © OpenStreetMap contributors" attribution the map displays is a licence obligation on that data, not a style choice — don't remove it. Fees cover the software and the curation around the data, not the OpenStreetMap data itself.
