Skip to content

Latest commit

 

History

102 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Blaeu

CI License: MIT Node

Blaeu is a geospatial kernel, not a map viewer. The core owns five extension mechanisms — a typed event bus with cancellable before: hooks, a plugin registry, two middleware pipelines (one synchronous for interaction, one asynchronous for commits), a command bus, and a feature store — plus the small set of services those mechanisms cannot work without and no plugin may replace: CRS, layers, tools, validation, theme and i18n. Nothing domain-specific. Drawing, snapping, editing, measurement, selection, undo/redo, topology, even layer types are plugins that register through extension points the core owns. Domains ship as presets: composable, plain-data bundles of plugins, config, layers, validation rules, theme and messages. The kernel has never heard of a parcel, and it never will.

The claim that follows from that, and the one this repository exists to make good on: the same kernel drives a land-registry cadastre tool, an urban-planning tool, and a tile-based game level editor — with no forks and no if (domain === …) anywhere inside it.

git clone https://github.com/celikgo/blaeu-lib && cd blaeu-lib
npm install && npm run build
npm run dev -w @blaeu/example-02-cadastre   # a parcel editor, in a projected Turkish CRS

Not on npm yet. @blaeu/* is not published, so npm install @blaeu/core does not resolve and nothing on this page asks you to run it. The twelve packages build, pack and carry their own types today — the missing step is the registry, not the packaging — and docs/PUBLISHING.md is the checklist that closes it. Until then the clone above is the whole install, the four apps in examples/ run against the workspace sources, and every import { … } from '@blaeu/core' below resolves through npm workspaces exactly as it will once published. The npm i @blaeu/… column in Packages describes what each package will be; it is marked as such there.

It measures land correctly, which is rarer than it should be

The sharpest thing in here is not the plugin architecture — it is that the survey mathematics is right. The store is WGS84, but area, distance, bearing, snap tolerances and the topology grid are all evaluated in a projected working CRS, in metres. All seven Turkish TUREF/TM belts (EPSG:5253–5259) ship built in, along with the legacy ED50 Gauss-Krüger belts (EPSG:2319–2325) that decades of cadastral archive live in.

That is not a detail. One 2 000 m² parcel near Ankara, measured three ways by this library:

Working CRS Area Off by
EPSG:5255 TUREF / TM33 — its belt 2000.000 m²
EPSG:5254 TUREF / TM30 — next belt over 2002.894 m² +2.894 m²
EPSG:3857 Web Mercator 3405.467 m² +1405.467 m² (+70 %)

All three look like plausible areas, and a land registry would reject two of them. Blaeu also quantises to the millimetre grid of the projected plane rather than to decimal places of longitude, and gives shared parcel corners a single topological identity so neighbours cannot be dragged apart into a sliver of unowned land.

docs/CRS.md — the belts, the guarantees, and a worked parcel with real coordinates.

Quick start

import { createBlaeuMap } from '@blaeu/core'
import { drawPlugin } from '@blaeu/plugin-draw'
import { snapPlugin } from '@blaeu/plugin-snap'
import { historyPlugin } from '@blaeu/plugin-history'

const map = await createBlaeuMap({
  container: '#map',
  crs: { working: 'EPSG:5254', display: 'projected', precision: 3 }, // TUREF/TM30, mm
  plugins: [snapPlugin({ tolerance: 12 }), drawPlugin({ collection: 'parcels' }), historyPlugin()],
})

map.tools.activate('draw:polygon')

map.events.on('draw:complete', (e) => {
  const parcel = e.payload.feature
  console.log(map.i18n.area(map.crs.area(parcel.geometry))) // planar m², in the working CRS
})

That is the whole setup, and the polygon you draw already snaps to the corners of every parcel already on the map, is quantised to the millimetre grid of EPSG:5254, and is undoable with Ctrl+Z. The draw plugin is responsible for none of that.

The tier model

┌──────────────────────────────────────────────────────────────────────┐
│  Applications        your product. Knows about presets, not plugins. │
├──────────────────────────────────────────────────────────────────────┤
│  Presets             judgement: tolerances, severities, units, CRS,  │
│  preset-cadastre     layers, theme, locale. Plain data. Composable.  │
│  preset-urban        A preset is a value, not a subclass.            │
│  preset-game                                                         │
├──────────────────────────────────────────────────────────────────────┤
│  Plugins             capability: draw, snap, edit, select, measure,  │
│  plugin-draw …       history, topology, ui. Domain-agnostic. They    │
│                      peer-depend on core and never import each other.│
├──────────────────────────────────────────────────────────────────────┤
│  Core (the kernel)   EventBus · PluginManager · SyncInteractionPipe- │
│  @blaeu/core         line · AsyncCommitPipeline · CommandBus ·       │
│                      FeatureStore (+ spatial & topology indexes)     │
│                      + the seams: Renderer, CrsService, LayerManager,│
│                      ToolManager, ThemeManager, I18n, Validation     │
└──────────────────────────────────────────────────────────────────────┘
        the arrows only ever point down. CI enforces it:
        `npm run lint:boundaries` fails on a core→plugin or plugin→plugin import.

The distinction between the two middle tiers is the one worth internalising, because getting it wrong is how every extensible library eventually stops being extensible:

  • A plugin adds a capability. Snapping exists. Drawing exists. It is domain-agnostic; the snap plugin has never heard of a parcel.
  • A preset adds judgement. Snap tolerance is 12 px because a looser one invents slivers. Overlaps are errors but gaps are warnings, because an overlap is a dispute and a gap is usually a slipped mouse. Area is planar because a land registry rejects spherical area.

If you find yourself writing if (domain === 'cadastre') inside a plugin, judgement has leaked into the wrong tier.

The same kernel powers all three

Cadastre — TUREF/TM projected working CRS, millimetre readouts, topological editing, parcel-to-parcel topology rules, ada/parsel attribute schema.

import { createBlaeuMap } from '@blaeu/core'
import { cadastrePreset } from '@blaeu/preset-cadastre'

const map = await createBlaeuMap({
  container: '#map',
  preset: cadastrePreset({ crs: 'EPSG:5253', locale: 'tr' }), // İzmir belt (TUREF/TM27, 27°E)
})
map.tools.activate('draw:polygon')

Urban planning — the same plugins, almost every number different, and one severity inverted. A 20 px snap because a planner is sketching rather than reproducing a boundary that legally exists; a 5 m planning grid, which cadastre would never accept; overlaps as warnings, because a planner dragging a commercial zone across a residential one to see what it looks like is doing their job.

import { createBlaeuMap } from '@blaeu/core'
import { urbanPlanningPreset } from '@blaeu/preset-urban'

const map = await createBlaeuMap({
  container: '#map',
  preset: urbanPlanningPreset({ crs: 'EPSG:5254', locale: 'tr' }),
})

const scenarios = map.plugin('scenario') // typed ScenarioApi, no cast
scenarios.create('Mevcut')
// …redraw a few blocks…
scenarios.create('Yoğun')
console.table(scenarios.compare('Mevcut', 'Yoğun').categories)

A game level editor — no basemap, no geodesy, no topology plugin (so the bundle does not carry JSTS at all). The world is a plane in arbitrary units, registered through crs.register(); the tile grid is a layer type the core has never heard of; procedural generation is commit middleware — the same seam cadastre uses for topology validation.

import { createBlaeuMap } from '@blaeu/core'
import { gameMapPreset } from '@blaeu/preset-game'

const map = await createBlaeuMap({
  container: '#map',
  preset: gameMapPreset({ gridSize: 32, gridType: 'square' }),
})

const entities = map.plugin('game-entity')
entities.setCurrent('tree')
await entities.place([128, -64]) // world units, snapped to the tile grid

That third one is the falsification test for the whole design. A game world has no Earth under it. If the core had assumed geodesy anywhere — in the store, in the snap engine, in the measurement maths — preset-game would be a fork rather than a package.

The load-bearing decisions

Each of these is a constraint we accepted deliberately, and each is paid for by a property you cannot get any other way. Full write-ups, including the alternatives we turned down, are in docs/adr/.

Every mutation is a Command, and history is a subscriber

The Command interface (execute/undo, with the contract that undo(execute(s)) restores s to deep equality) is the only way anything in a Blaeu map changes state.

The payoff is that undo works across plugins that have never heard of each other. The history plugin does not know what a "move vertex" is; it subscribes to commands.onDidExecute, keeps two stacks of Commands, and calls undo() on them. A plugin written by a stranger in three years gets Ctrl+Z for free by implementing the interface — no registration in history, no import of history, no coupling in either direction. That is also why history is a plugin and not a core feature: a read-only viewer or a kiosk does not pay for an undo stack it will never use, and a collaborative product can swap in a history plugin backed by a CRDT without the kernel noticing.

CommandBus.transaction(label, fn) groups transient work — a preview plus its handles — into one undo step, and rolls the store back if fn throws. CommandBus.commitTransaction(label, async (tx) => …) is its durable counterpart: each tx.commit() clears the commit pipeline on its own, and a veto anywhere rolls the whole group back to the snapshot taken before it started — so a rejected second half of a parcel split cannot leave the original parcel deleted with nothing in its place. Command.coalesceWith() merges a 200-frame vertex drag into a single Ctrl+Z, which is what every user already assumes happens.

Snapping is interaction middleware, not something the draw tool calls

This is the central architectural fact of the library, and it is worth stating flatly: the draw plugin has never heard of the snap plugin and does not import it.

The snap plugin registers exactly one interaction middleware, at priority 100. On every pointer event it queries its providers, picks a winner, and rewrites ctx.lngLat before the pipeline reaches any tool. The draw tool then reads a position that is already exactly on the parcel corner. So does the measure tool. So does the edit tool. So does a tool written next year by someone who has never read this README — they get snapping for free, because they read ctx.lngLat like everyone else.

Alt suppresses snapping for one event, as it does in every CAD package on earth. That behaviour, too, is implemented once, in the middleware, and every tool inherits it.

The inverse arrangement — a snapTo() method that each tool remembers to call — appears simpler for exactly as long as you have one tool, and then costs you a bug in every tool that forgot.

The interaction pipeline is synchronous; the commit pipeline is not

InteractionMiddleware returns void, not Promise<void>, and the type system enforces it. That pipeline runs on every pointermove — up to 120 Hz. An async middleware there adds a frame of latency and reorders events under load, which the user perceives as the cursor lagging behind the snap indicator. Middleware that genuinely needs async work does it speculatively and caches: the snap engine rebuilds its spatial index on camera:idle, never on pointermove.

CommitMiddleware is async for the opposite reason. A real topology check calls a parcel registry over the network. Blocking the main thread on that would freeze the map, and callers already await the write, so the cost lands where it is visible.

Two pipelines, because the two jobs have opposite requirements. One pipeline would have to be async, and then the cursor would lag.

The store is WGS84; survey maths happens in a projected working CRS

Everything in the store, the events and the renderer is [lng, lat] in EPSG:4326, without exception. One interior coordinate system means nothing downstream ever has to ask "which one is this?".

But sphere maths is not survey maths. A spherical area on a 2 000 m² parcel at 39°N is wrong by square metres — enough to move a boundary in a dispute, and a land registry cares. So every precise operation projects into the working CRS, does planar geometry in metres, and projects back:

const plane = map.crs.working // EPSG:5254 — TUREF / TM30, metres
const xy = ring.map(plane.forward) // 4326 → projected metres
const out = offsetPolygonPlanar(xy, 2.5) // the real maths, in real metres
const back = out.map(plane.inverse) // → 4326, back into the store

map.crs.area(), .length(), .distance() and .bearing() are convenience wrappers around that sandwich. bearing() returns grid bearing, clockwise from grid north, not a geodesic azimuth — surveyors care about the difference. The working CRS is also what coordinate readouts and exports use, because a Turkish surveyor wants to see Y=458123.456 X=4421987.123, not a pair of decimal degrees. The Turkish TUREF/TM belts (EPSG:5253–5259) and the legacy ED50 Gauss-Krüger belts (EPSG:2319–2325) ship built in, and crs.register() takes a municipality's local system. UTM 35N/36N/37N (EPSG:32635–32637) ship too, because imported data arrives in them. If you set no working CRS you get EPSG:3857 — never catastrophically wrong for a map that has not said where it is, and never survey-grade either: at 40°N a Web Mercator "metre" is 1.3 real metres, so a planar area is ~70 % too large. Set a belt before measuring anything that matters.

docs/CRS.md is the full treatment — all seven TUREF belts with the provinces each covers, the ED50 archive and its two traps, the quantisation and vertex-identity guarantees, and a worked Ankara parcel whose area comes out at 2000.000 m² in TM33, 2002.894 m² in the neighbouring TM30, and 3405.467 m² in Web Mercator. Every number there was produced by running the library.

The plugin registry is typed by declaration merging

declare module '@blaeu/core' {
  interface BlaeuPluginRegistry {
    draw: DrawApi
  }
}

After that, map.plugin('draw') is a DrawApi — no cast, no generic parameter, no import of an internal type — and autocomplete lists every installed plugin by id. The same trick on BlaeuEventMap makes map.events.on('draw:complete', (e) => e.payload.feature) type-check with full inference, so a typo in an event name is a compile error rather than a listener that silently never fires. BlaeuPluginRegistry ships empty on purpose. It is not an oversight; it is the seam.

The before: prefix on an event name is not a convention either — it is a capability. EventBus.emitCancellable() only accepts keys matching `before:${string}`, so a cancellable hook and a past-tense notification cannot be confused by anyone, including us.

Topological vertex identity: shared corners move together

FeatureStore.topology maps a quantised coordinate — snapped to the working CRS's precision grid, 1 mm for cadastre — to every vertex sitting on it. Two adjacent parcels sharing a corner therefore resolve to one key with two VertexRefs, and with editPlugin({ topological: true }) moving that corner moves both parcels in one command, so one undo restores both.

The quantisation is the load-bearing part. Exact float equality would treat corners 10⁻¹² m apart as distinct; they would drift apart under editing; and you would have manufactured a sliver. In a land registry a sliver is not a rendering artefact — it is a strip of land with no owner, and eventually a court case.

Write a plugin

A plugin is a function that returns an object. Here is a complete one — a snap provider for parcel corners, the kind of thing a domain adds — including the registry augmentation that buys the typed handle.

import type { BlaeuPlugin, LngLat, SnapCandidate, SnapProvider } from '@blaeu/core'

export interface CornerApi {
  readonly collection: string
}

declare module '@blaeu/core' {
  interface BlaeuPluginRegistry {
    'parcel-corner': CornerApi // → map.plugin('parcel-corner') is CornerApi, no cast
  }
}

export function parcelCornerPlugin(collection = 'parcels'): BlaeuPlugin<CornerApi> {
  return {
    id: 'parcel-corner',
    version: '1.0.0',
    // Optional, and genuinely so: with no snap engine installed the map still works,
    // it simply does not snap to corners. That claim owes a degradation test.
    dependencies: [{ id: 'snap', optional: true }],

    setup(ctx): CornerApi {
      const provider: SnapProvider = {
        id: 'parcel-corner',
        priority: 110, // above the built-in vertex provider (100): a corner outranks a vertex

        // Called on every pointer move, so it queries the spatial index. Never scans.
        query(point, tolerancePx, q): readonly SnapCandidate[] {
          const out: SnapCandidate[] = []
          for (const parcel of ctx.store.collection(collection).query(q.bbox)) {
            if (q.exclude.has(parcel.id)) continue // never snap to what you are dragging
            if (parcel.geometry.type !== 'Polygon') continue
            for (const ring of parcel.geometry.coordinates) {
              for (const position of ring) {
                const corner: LngLat = [position[0]!, position[1]!]
                const a = q.project(corner)
                const b = q.project(point)
                const distancePx = Math.hypot(a.x - b.x, a.y - b.y)
                if (distancePx > tolerancePx) continue
                out.push({
                  kind: 'parcel-corner',
                  point: corner,
                  distancePx,
                  priority: 110,
                  feature: parcel.id,
                  hint: ctx.i18n.t('corner.hint'),
                })
              }
            }
          }
          return out
        },
      }

      // Core invariant 5: every subscription goes into ctx.disposables, which the
      // kernel disposes on destroy. `?.` because the dependency is optional.
      const snap = ctx.tryPlugin('snap')
      if (snap) ctx.disposables.add(snap.addProvider(provider))
      else ctx.log.warn('no snap engine installed; parcel corners will not snap.')

      return { collection }
    },
  }
}

Install it and every tool in the product — draw, edit, measure, and the one you have not written yet — snaps to parcel corners. You did not touch any of them.

The three tests this plugin now owes are in CONTRIBUTING.md: degradation (it works without snap), teardown (removing it leaks nothing), and, for any plugin that dispatches commands, the undo round-trip.

Build a product preset

A preset is a pure function returning plain data. No map, no DOM, no globals. That is what makes it inspectable, snapshot-testable, and composable.

import { definePreset, type Preset } from '@blaeu/core'
import { drawPlugin } from '@blaeu/plugin-draw'
import { snapPlugin } from '@blaeu/plugin-snap'
import { historyPlugin } from '@blaeu/plugin-history'
import { topologyPlugin, noOverlapWithNeighbours, closedRings } from '@blaeu/plugin-topology'

export interface UtilityOptions {
  readonly crs?: string
  readonly snapTolerance?: number
}

export function utilityNetworkPreset(options: UtilityOptions = {}): Preset {
  return definePreset({
    id: 'utility-network',
    description: 'Pipe and cable networks: metric working CRS, tight snapping, no overlaps.',

    config: {
      crs: { working: options.crs ?? 'EPSG:5254', display: 'projected', precision: 2 },
      interaction: { doubleClickZoom: false }, // double-click ends a run
    },

    // Tuple form: the factory stays *un-invoked* until the map installs it, which is
    // what lets a later preset retune these options without re-declaring the plugin.
    plugins: [
      [snapPlugin, { tolerance: options.snapTolerance ?? 8, providers: ['vertex', 'edge'] }],
      [drawPlugin, { defaultMode: 'line', collection: 'mains' }],
      [topologyPlugin, { autoFix: false }],
      [historyPlugin, { limit: 200 }],
    ],

    // The plugin knows how to *find* an overlap. Only this preset knows that in a
    // pipe network an overlap is fatal. Severity is judgement; judgement is preset.
    validation: [
      closedRings({ severity: 'error' }),
      noOverlapWithNeighbours({ severity: 'error' }),
    ],

    layers: [{ id: 'mains', type: 'vector', source: 'mains' }],
    locale: 'tr',
  })
}

Retuning a preset without forking it

composePresets is how a municipality customises a national preset. Later wins:

import { composePresets, definePreset, createBlaeuMap } from '@blaeu/core'
import { cadastrePreset } from '@blaeu/preset-cadastre'
import { snapPlugin } from '@blaeu/plugin-snap'
import { minParcelArea } from '@blaeu/plugin-topology'

const izmir = composePresets(
  cadastrePreset({ crs: 'EPSG:5253' }), // national base, on İzmir's belt (TUREF/TM27)
  definePreset({
    id: 'izmir',
    plugins: [[snapPlugin, { tolerance: 8 }]], // retunes the base's snap; keeps its provider list
    validation: [minParcelArea({ severity: 'error', minArea: 250 })], // appended
    config: { crs: { precision: 4 } }, // deep-merged over the base
    layers: [{ id: 'izmir-zoning', type: 'vector', source: 'zoning' }],
  }),
)

const map = await createBlaeuMap({ container: '#map', preset: izmir })

The merge semantics are the whole contract of the preset system, and they are deliberate:

Field Rule
config deep merge
theme deep merge — except basemap and css, which replace wholesale (a style JSON is one value, not a bag of fields to interleave)
plugins append — but a repeated plugin id deep-merges its options into the existing entry, in place
validation, layers, interactionMiddleware, commitMiddleware append
i18n merge per locale, later wins per key
id, description, locale later wins

In place matters: the retuned snap plugin keeps the base preset's install position, so anything depending on it still finds it where the base put it. When append is genuinely wrong — a demo environment that must not enforce minimum parcel area — overridePreset(base, { validation: [] }) replaces instead. Needing that often is a smell that the base preset should have exposed an option.

Packages

Not yet on npm. The package names are final; the install commands are what they will be at 0.1.x. Until the first publish, run them from source — see the note under the first install fence above.

Package Install (once published) What it is
@blaeu/core npm i @blaeu/core maplibre-gl The kernel, the MapLibre renderer, and @blaeu/core/testing
@blaeu/plugin-snap npm i @blaeu/plugin-snap Snapping as interaction middleware; 7 built-in providers, pluggable
@blaeu/plugin-draw npm i @blaeu/plugin-draw point / line / polygon / rectangle / circle / freehand
@blaeu/plugin-edit npm i @blaeu/plugin-edit Vertex editing, transforms, split, merge. Topological mode. JSTS
@blaeu/plugin-select npm i @blaeu/plugin-select Click, multi-select, box, lasso
@blaeu/plugin-measure npm i @blaeu/plugin-measure Distance, area, grid bearing — planar, in the working CRS
@blaeu/plugin-history npm i @blaeu/plugin-history Undo/redo for every plugin, including the ones that do not exist yet
@blaeu/plugin-topology npm i @blaeu/plugin-topology Overlap, gap, sliver, self-intersection, ring closure. JSTS
@blaeu/plugin-ui npm i @blaeu/plugin-ui Framework-free chrome: toolbar, readouts, undo buttons, issue panel
@blaeu/preset-cadastre npm i @blaeu/preset-cadastre Turkish cadastre: TUREF/TM, mm precision, topological editing, ada/parsel
@blaeu/preset-urban npm i @blaeu/preset-urban Zoning legend, 5 m planning grid, scenario comparison
@blaeu/preset-game npm i @blaeu/preset-game Tile/hex level editor: custom world CRS, entity placement, procedural generation

Every plugin declares @blaeu/core as a peerDependency, never a dependency. Two copies of the core in a user's node_modules means two event buses and two stores; nothing throws, the plugin just silently never receives an event, and someone loses a day to it.

What this is not, and what is unfinished

A library honest about its edges is worth more than one that is not, so:

  • Hit testing is only verified on a GPU runner. The node suite (789 tests across 44 files) runs headless against FakeRenderer, which implements the full Renderer contract with deterministic, analytically-invertible project/unproject. That is what makes a pixel-denominated snap tolerance testable at all, and it proves the renderer seam is real rather than aspirational. MapLibreRenderer itself is now covered too: a separate browser suite (npm run test:browser, vitest.browser.config.ts, 22 tests) mounts a real MapLibre Map in headless Chromium and asserts the two things a fake cannot honestly reach — that MapLibre accepts our LayerStyle→paint/layout translation, and that our pointer and touch normalisation matches the events a browser actually makes. What it cannot reach on a GPU-less runner is queryRenderedFeatures: hit testing needs a completed render pass, and headless SwiftShader never delivers one, so four of those tests are gated behind a probe and skip with a warning. That is the remaining edge, and it closes on a GPU runner. Beyond the two suites, npm run test:mutation runs Stryker over the kernel (stryker.config.json) and npm run bench runs the store benchmarks (packages/core/src/store/store.bench.ts); neither is in npm run verify, because a mutation run is minutes rather than seconds and its score is a number to read rather than a gate to trip. It runs nightly instead.
  • Durable writes are async; interaction writes are not. A write that must be validated goes through await commands.commit(cmd), which runs the async commit pipeline and applies the write only if nothing rejected — preset-game's entity placement is the reference. Transient scaffolding (previews, handles) uses the synchronous commands.dispatch(cmd), which skips the pipeline and refuses a feature-writing command outright. That asymmetry is deliberate — making dispatch async would make every tool's click handler async; making the pipeline sync would forbid a server-side check — so a tool fires the commit without awaiting it and reacts to the resulting event. See ADR 0009.
  • No React (or Vue, or Svelte) binding. @blaeu/plugin-ui is framework-free DOM on purpose, and every subscription returns a Disposable that maps cleanly onto an effect cleanup — but there is no @blaeu/react package yet.
  • No 3D and no terrain. The Renderer interface is the seam a Three.js renderer would come through, and Camera already carries pitch, but nothing behind that seam exists.
  • Collaboration is designed for, not built. The command bus is the right seam for it (see ADR 0002 and the roadmap), and nothing in the architecture fights it. That is not the same as it working.
  • Not a basemap or a tile server. Bring your own MapLibre style.
  • Not a replacement for a GIS. No reprojection of raster data, no geoprocessing suite, no attribute joins across datasets. It is an editing kernel.

Further reading

  • examples/ — four runnable apps built against the workspace sources, one per claim this README makes: 01-basic, 02-cadastre, 03-urban-planning, 04-game-map. npm install && npm run build at the root, then npm run dev -w @blaeu/example-02-cadastre.
  • docs/CRS.md — the Turkish coordinate reference systems: TUREF/TM27–TM45 and the ED50 belts, why the store is WGS84 while survey maths runs in a projected plane, the millimetre and vertex-identity guarantees, and a worked parcel with real coordinates.
  • docs/PUBLISHING.md — what is packaged, what is verified, and the two credentialled steps between here and npm install @blaeu/core working.
  • docs/theming.md — the theme system: switching light/dark, the seven built-in themes (including the Twitter/X palettes), theme-following layer styles, and writing your own theme.
  • docs/contrast.md — the WCAG audit, generated: every pair the code actually paints, in all seven themes, with the call site that paints it and the floor it answers to. Regenerated and diffed by the test suite, so it cannot drift.
  • ARCHITECTURE.md — every core abstraction, the life of a pointer event, the life of a mutation, plugin dependency resolution, and the extension-point catalogue.
  • ROADMAP.md — what is next, and why in that order.
  • CONTRIBUTING.md — the boundary rules, the three tests every plugin owes, and when a change needs an ADR.
  • docs/adr/ — the decisions, each with the alternatives we rejected.

MIT.

About

A plugin-first geospatial editing kernel in TypeScript: MapLibre GL rendering, a GeoJSON feature store, proj4/EPSG working CRS, snapping as interaction middleware, cross-plugin undo/redo, and JSTS topology validation — with cadastre, urban-planning and game level-editor presets on one unforked core.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages