@zwishing/emap 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +250 -1
  2. package/FEATURES.md +455 -0
  3. package/README.md +415 -205
  4. package/dist/core/event-map.d.ts +67 -0
  5. package/dist/core/feature-event-dispatcher.d.ts +70 -0
  6. package/dist/core/handler-manager.d.ts +49 -0
  7. package/dist/core/handler.d.ts +48 -0
  8. package/dist/core/handlers/box-select.d.ts +54 -0
  9. package/dist/core/handlers/click-select.d.ts +31 -0
  10. package/dist/core/handlers/drag-pan.d.ts +28 -0
  11. package/dist/core/handlers/draw-feature.d.ts +76 -0
  12. package/dist/core/handlers/lasso-select.d.ts +57 -0
  13. package/dist/core/handlers/scroll-zoom.d.ts +24 -0
  14. package/dist/core/handlers/select-geometry.d.ts +24 -0
  15. package/dist/core/handlers/select-mode.d.ts +14 -0
  16. package/dist/core/handlers/transform-feature.d.ts +41 -0
  17. package/dist/core/handlers/vertex-edit.d.ts +98 -0
  18. package/dist/core/pointer-event-dispatcher.d.ts +40 -0
  19. package/dist/core/tween.d.ts +1 -0
  20. package/dist/emap.css +42 -8
  21. package/dist/emap.js +2 -2
  22. package/dist/emap.mjs +1 -1
  23. package/dist/geo/camera.d.ts +100 -0
  24. package/dist/geo/projection.d.ts +8 -1
  25. package/dist/geo/viewport.d.ts +18 -0
  26. package/dist/index.d.ts +26 -2
  27. package/dist/map/edit-state-store.d.ts +1 -1
  28. package/dist/map/map.d.ts +89 -2
  29. package/dist/map/selection.d.ts +5 -2
  30. package/dist/renderer/edit-overlay-renderer.d.ts +2 -1
  31. package/dist/source/source.d.ts +2 -2
  32. package/dist/source/topology-source.d.ts +33 -4
  33. package/dist/ui/box-select-control.d.ts +13 -37
  34. package/dist/ui/draw-feature-control.d.ts +6 -71
  35. package/dist/ui/lasso-select-control.d.ts +14 -61
  36. package/dist/ui/status-control.d.ts +2 -2
  37. package/dist/ui/vertex-edit-control.d.ts +5 -100
  38. package/package.json +5 -1
  39. package/dist/core/drag-pan-handler.d.ts +0 -28
package/README.md CHANGED
@@ -1,47 +1,121 @@
1
1
  # emap
2
2
 
3
- A topology-oriented geographic map rendering and editing engine built around [Mapshaper](https://github.com/mbloch/mapshaper) data structures and HTML5 Canvas. Browser-only, framework-agnostic TypeScript.
4
-
5
- ## What you get
6
-
7
- - **Topology rendering** — draws shared-arc datasets directly without re-tessellating per layer
8
- - **Loaders** — GeoJSON, TopoJSON, ZIP Shapefile (with per-archive resource limits)
9
- - **Editing** — pan / wheel-zoom / hover, plus vertex, feature, and topology-aware editing
10
- - **30+ data ops** under `emap.ops.*` — clip, erase, dissolve, buffer, simplify, project, snap, clean, mosaic, union, divide, points / lines / polygons conversions, join-table, filter, sort, uniq, split, merge, rename, …
11
- - **Worker pool** — expensive ops dispatch to off-thread workers automatically based on dataset size + command family
12
- - **Undo / redo** with command coalescing, dataset-replace barriers, and structured `OpResult` errors
13
- - **Selection model** single / multi / box / lasso, with attribute-only ops preserving feature ids
14
- - **Snapshots** pack / unpack a full session (datasets + history) to a compact binary blob
15
- - **HTML5 Canvas 2D rendering** — `CanvasPainter` with batched-stroke optimization for large topology datasets
16
- - **Built-in controls** — navigation, status, basemap, vertex edit, feature draw, history, box / lasso selection
3
+ `emap` is a browser-only TypeScript map rendering and editing engine built on
4
+ Mapshaper topology data structures and HTML5 Canvas 2D. It is designed for
5
+ framework-agnostic GIS applications that need client-side loading, rendering,
6
+ selection, topology-aware editing, data operations, undo/redo, and custom UI
7
+ integration without a backend service.
8
+
9
+ ## Project Status
10
+
11
+ `emap` is an AI-driven development project and is still evolving quickly. The
12
+ API, behavior, and implementation details may change without the compatibility
13
+ guarantees expected from a mature GIS engine. It is not recommended for
14
+ production environments yet; evaluate it in prototypes, demos, research tools,
15
+ or controlled internal workflows first.
16
+
17
+ ## Highlights
18
+
19
+ - Topology-first rendering for Mapshaper datasets, with shared-arc reuse and LOD.
20
+ - GeoJSON, TopoJSON, ZIP Shapefile, and Mapshaper snapshot loading.
21
+ - MapLibre-style layer specs: `fill`, `line`, and `circle`.
22
+ - Named interaction handlers: `dragPan`, `scrollZoom`, `clickSelect`,
23
+ `boxSelect`, `lassoSelect`, `vertexEdit`, `drawFeature`, and
24
+ `transformFeature`.
25
+ - Handler-driven customization: use built-in interactions without the bundled UI
26
+ controls, or build your own toolbar around the same handlers.
27
+ - Delegated feature events: `feature:click`, `feature:hover`, `feature:enter`,
28
+ and `feature:leave`.
29
+ - MapLibre-parity camera methods: `jumpTo`, `easeTo`, `flyTo`, `fitBounds`,
30
+ `panBy`, `panTo`, `zoomTo`, and `stop`.
31
+ - 30+ `map.ops.*` data operations backed by Mapshaper commands.
32
+ - Undo/redo, transactions, validators, feature accessors, highlighting, and
33
+ full-session snapshots.
34
+ - Optional Web Worker offloading for expensive dataset-replace operations.
35
+ - CSS design tokens for theming built-in UI surfaces.
36
+
37
+ For the full capability matrix, see [FEATURES.md](FEATURES.md).
17
38
 
18
39
  ## Install
19
40
 
20
41
  ```bash
21
- pnpm add @zwishing/emap
22
- # or
23
42
  npm install @zwishing/emap
43
+ # or
44
+ pnpm add @zwishing/emap
24
45
  ```
25
46
 
26
- ## Hello map
47
+ ## Quick Start
48
+
49
+ ### Bundlers
50
+
51
+ ```ts
52
+ import {
53
+ Emap,
54
+ TopologySource,
55
+ NavigationControl,
56
+ HistoryControl,
57
+ } from '@zwishing/emap';
58
+ import '@zwishing/emap/style.css';
59
+
60
+ const map = new Emap({ container: 'map' });
61
+ map.addControl(new NavigationControl(), 'top-left');
62
+ map.addControl(new HistoryControl(), 'top-left');
63
+
64
+ const source = await TopologySource.fromUrl(
65
+ 'china',
66
+ 'https://geojson.cn/api/china/1.6.3/china.topo.json',
67
+ );
68
+
69
+ map.addSource('china', source);
70
+ map.setExtent(source.getExtent());
71
+ ```
72
+
73
+ The ESM build is browser-ready. Mapshaper is shipped as a sibling ES module
74
+ resolved from `node_modules/@zwishing/emap/dist/`, so Vite, webpack, esbuild,
75
+ and Bun consumers should not need a `Buffer` polyfill or a custom alias.
76
+
77
+ ### Static Assets
78
+
79
+ Some runtime files are loaded by URL instead of being inlined into your
80
+ application bundle:
81
+
82
+ | Asset | When it is needed |
83
+ |---|---|
84
+ | `dist/emap.css` | Built-in controls, context menus, and default UI styling |
85
+ | `dist/mapshaper-vendor.js` | IIFE script usage and worker bootstrapping |
86
+ | `dist/emap-worker.js` | `useWorker: true` or `useWorker: 'auto'` |
87
+
88
+ For bundlers, prefer:
89
+
90
+ ```ts
91
+ import '@zwishing/emap/style.css';
92
+ ```
93
+
94
+ For script-tag or static-host deployments, copy the files from
95
+ `node_modules/@zwishing/emap/dist/` into your public assets directory. When
96
+ using the worker, keep `emap-worker.js` and `mapshaper-vendor.js` in the same
97
+ directory unless you build your own worker bundle; the worker imports the vendor
98
+ file with a relative `importScripts('mapshaper-vendor.js')` call.
99
+
100
+ ### IIFE Script
27
101
 
28
102
  ```html
29
- <!DOCTYPE html>
103
+ <!doctype html>
30
104
  <html>
31
105
  <head>
32
106
  <link rel="stylesheet" href="node_modules/@zwishing/emap/dist/emap.css" />
33
- <style>#map { width: 100vw; height: 100vh; margin: 0; }</style>
107
+ <style>
108
+ html, body, #map { width: 100%; height: 100%; margin: 0; }
109
+ </style>
34
110
  </head>
35
111
  <body>
36
112
  <div id="map"></div>
37
113
  <script src="node_modules/@zwishing/emap/dist/mapshaper-vendor.js"></script>
38
114
  <script src="node_modules/@zwishing/emap/dist/emap.js"></script>
39
115
  <script>
40
- const { Emap, TopologySource, NavigationControl, EditToolbar } = emap;
41
-
116
+ const { Emap, TopologySource, NavigationControl } = emap;
42
117
  const map = new Emap({ container: 'map' });
43
118
  map.addControl(new NavigationControl(), 'top-left');
44
- map.addControl(new EditToolbar(), 'top-left');
45
119
 
46
120
  (async () => {
47
121
  const source = await TopologySource.fromUrl(
@@ -56,298 +130,434 @@ npm install @zwishing/emap
56
130
  </html>
57
131
  ```
58
132
 
59
- ### Bundlers
133
+ ## Loading Data
60
134
 
61
135
  ```ts
62
- import { Emap, TopologySource, type MapOptions } from '@zwishing/emap';
63
- import '@zwishing/emap/style.css';
136
+ const source = await TopologySource.fromUrl('roads', '/data/roads.geojson');
137
+ map.addSource('roads', source);
138
+ map.setExtent(source.getExtent());
64
139
 
65
- const options: MapOptions = { container: 'map' };
66
- const map = new Emap(options);
140
+ const local = new TopologySource('local');
141
+ await local.setData({ filename: 'upload.zip', content: bytes });
142
+ map.addSource('local', local);
67
143
  ```
68
144
 
69
- The ESM build is browser-ready and bundles the mapshaper runtime it needs, so
70
- Vite/Webpack/Bun consumers should not need to add a `Buffer` polyfill.
71
-
72
- `BasemapControl` loads MapLibre only when a basemap is first enabled. If you use
73
- that control, also include MapLibre's stylesheet in your app:
74
-
75
- ```ts
76
- import 'maplibre-gl/dist/maplibre-gl.css';
77
- ```
145
+ `TopologySource.setData()` is awaitable. It resolves after the dataset is
146
+ populated and the `data` event has fired. `map.addSource()` is order-independent:
147
+ you can add a loaded source or add the source first and load it later.
78
148
 
79
- Live demos in `test/examples/*.html` cover every feature area below.
149
+ Supported inputs:
80
150
 
81
- ## Browser bundlers (Vite / webpack / esbuild / Bun)
151
+ - GeoJSON and TopoJSON.
152
+ - ZIP Shapefile archives containing `.shp`, `.dbf`, `.shx`, and optional `.prj`.
153
+ - Mapshaper `.msx` snapshots via `map.loadSnapshot()`.
82
154
 
83
- `import "@zwishing/emap"` works out of the box — no `resolve.alias`, no
84
- `Buffer` polyfill, no bundler config. mapshaper is shipped as a sibling ES
85
- module (`dist/mapshaper-vendor.mjs`) that `dist/emap.mjs` imports by relative
86
- path; your bundler resolves it automatically from
87
- `node_modules/@zwishing/emap/dist/`.
155
+ ## Layers and Rendering
88
156
 
89
157
  ```ts
90
- import { Emap, TopologySource } from "@zwishing/emap";
91
- import "@zwishing/emap/style.css";
158
+ map.addLayer({
159
+ id: 'roads-line',
160
+ source: 'roads',
161
+ type: 'line',
162
+ paint: {
163
+ 'line-color': '#4a5568',
164
+ 'line-width': 1,
165
+ },
166
+ });
167
+
168
+ map.addLayer({
169
+ id: 'district-fill',
170
+ source: 'districts',
171
+ type: 'fill',
172
+ paint: {
173
+ 'fill-color': 'rgba(66, 153, 225, 0.25)',
174
+ 'line-width': 0,
175
+ },
176
+ });
92
177
  ```
93
178
 
94
- The Web Worker is only needed if you opt into off-thread ops via `workerUrl`;
95
- deploy `dist/emap-worker.js` and `dist/mapshaper-vendor.js` as same-directory
96
- static assets then.
179
+ When no user layers are added, `emap` creates default display layers from the
180
+ source geometry type. Polygon defaults are outline-only for large-data browsing;
181
+ add an explicit `fill` layer when you want area fills.
97
182
 
98
- ## API tour
183
+ ## Interactions Without Built-In Controls
99
184
 
100
- ### Core map
185
+ Most editing and selection tools are exposed as named handlers. You can use
186
+ them directly from your own UI instead of adding `BoxSelectControl`,
187
+ `DrawFeatureControl`, `VertexEditControl`, or `EditToolbar`.
101
188
 
102
189
  ```ts
103
- const map = new Emap({ container: 'map' });
190
+ map.boxSelect.setOptions({
191
+ layers: ['district-fill'],
192
+ dragActivator: 'shift',
193
+ dragThreshold: 4,
194
+ });
195
+ map.boxSelect.enable();
104
196
 
105
- map.addSource(id, source);
106
- map.addLayer({ id, source: 'china', type: 'fill', paint: { 'fill-color': '#eee' } });
107
- map.setExtent(source.getExtent());
197
+ map.lassoSelect.setOptions({ layers: ['district-fill'] });
198
+ map.lassoSelect.enable();
108
199
 
109
- map.queryFeatures(point, { layers: ['borders'] });
110
- map.on('click', (e) => { /* … */ });
200
+ map.drawFeature.setOptions({
201
+ source: 'editing',
202
+ sourceLayer: 'areas',
203
+ type: 'polygon',
204
+ snapSources: ['editing', 'reference'],
205
+ });
206
+ map.drawFeature.enable();
207
+
208
+ map.vertexEdit.enable();
209
+
210
+ map.clickSelect.enable();
211
+ map.transformFeature.setOptions({ mode: 'translate' });
212
+ map.transformFeature.enable();
111
213
  ```
112
214
 
113
- ### Sources
215
+ All handlers expose the same basic surface:
114
216
 
115
217
  ```ts
116
- const source = await TopologySource.fromUrl('id', url); // GeoJSON / TopoJSON / ZIP
117
- const source2 = await TopologySource.fromBytes('id', buf, {
118
- filename: 'local.geojson',
119
- }); // ArrayBuffer / Uint8Array
120
- source.getExtent();
121
- source.getLayers();
218
+ handler.enable();
219
+ handler.disable();
220
+ handler.isEnabled();
221
+ handler.setOptions({...});
222
+ handler.getOptions();
122
223
  ```
123
224
 
124
- ### Editing operations (`emap.ops`)
225
+ Available named handlers:
125
226
 
126
- All ops return `Promise<OpResult>` — a discriminated `{ ok: true, value? } | { ok: false, error: OpError }`. Errors are typed (`not-found`, `validation`, `expression-disabled`, `mapshaper`, `host-removed`).
227
+ | Handler | Purpose |
228
+ |---|---|
229
+ | `map.dragPan` | Pointer drag map panning |
230
+ | `map.scrollZoom` | Wheel zoom |
231
+ | `map.clickSelect` | Click selection |
232
+ | `map.boxSelect` | Shift-drag rectangular selection |
233
+ | `map.lassoSelect` | Free-form lasso selection |
234
+ | `map.vertexEdit` | Topology-aware vertex editing |
235
+ | `map.drawFeature` | Point, polyline, and polygon drawing |
236
+ | `map.transformFeature` | Translate, rotate, or scale selected features |
127
237
 
128
- ```ts
129
- // Whole-dataset CLI ops
130
- await map.ops.clipLayer({ source: 'roads', target: 'streets', mask: 'park' });
131
- await map.ops.dissolveLayer({ source: 'us', target: 'counties', field: 'STATE' });
132
- await map.ops.bufferLayer({ source: 'roads', target: 'streets', radius: 50 });
133
- await map.ops.simplifyLayer({ source: 'world', percentage: 10 });
134
- await map.ops.projectLayer({ source: 'world', crs: 'EPSG:3857' });
135
-
136
- // Layer management
137
- await map.ops.renameLayer({ source: 's', target: 'old', name: 'new' });
138
- await map.ops.mergeLayers({ source: 's', targets: ['a', 'b'], name: 'combined' });
139
- await map.ops.splitLayer({ source: 's', target: 'us', expression: 'STATE' });
140
- await map.ops.dropLayer({ source: 's', target: 'tmp' });
141
-
142
- // Attribute / data
143
- await map.ops.applyExpression({ source: 's', target: 'l', expression: 'this.area = $.area' });
144
- await map.ops.filterFeatures({ source: 's', target: 'l', expression: 'this.pop > 1e6' });
145
- await map.ops.joinTable({
146
- source: 's', target: 'states',
147
- data: { csv: csvBytes }, keys: ['STATE_FIPS', 'fips'],
148
- });
238
+ ## Building Your Own UI
149
239
 
150
- // Topology repair / inspection
151
- await map.ops.cleanLayer({ source: 's', target: 'l' });
152
- await map.ops.snapLayer({ source: 's', target: 'l', interval: 0.001 });
153
- const report = await map.ops.checkGeometry({ source: 's' });
154
- // → { ok: true, value: { ok, intersections: [...], intersectionCount } }
240
+ Built-in controls are optional. A custom toolbar can call the public map API and
241
+ handlers directly:
155
242
 
156
- // Selection-driven
157
- await map.ops.mergeSelected();
158
- ```
243
+ ```ts
244
+ toolbar.querySelector('[data-tool="box"]').onclick = () => {
245
+ map.lassoSelect.disable();
246
+ map.boxSelect.enable();
247
+ };
159
248
 
160
- A full list of ops + their option types lives in `src/index.ts` (search for `*Options`).
249
+ toolbar.querySelector('[data-tool="undo"]').onclick = () => map.undo();
250
+ toolbar.querySelector('[data-tool="redo"]').onclick = () => map.redo();
161
251
 
162
- ### Transactions
252
+ map.on('historychange', (e) => {
253
+ undoButton.disabled = !e.canUndo;
254
+ redoButton.disabled = !e.canRedo;
255
+ });
256
+ ```
163
257
 
164
- Stage multiple ops as one atomic edit — commit collapses them into a single undo entry, rollback restores every touched source's dataset (and the selection) to the pre-transaction state.
258
+ For a custom control that still plugs into the corner layout, implement:
165
259
 
166
260
  ```ts
167
- const tx = map.beginTransaction();
168
- const r1 = await map.ops.clipLayer({ source, target, mask });
169
- if (!r1.ok) { tx.rollback(); return; }
170
- const r2 = await map.ops.dissolveLayer({ source, target, field: 'STATE' });
171
- if (!r2.ok) { tx.rollback(); return; }
172
- await tx.commit('Process boundaries');
261
+ const control = {
262
+ onAdd(map) {
263
+ const el = document.createElement('div');
264
+ el.textContent = 'My tool';
265
+ return el;
266
+ },
267
+ onRemove() {},
268
+ };
269
+
270
+ map.addControl(control, 'top-right');
173
271
  ```
174
272
 
175
- Nesting is not supported — opening a transaction while one is active throws.
273
+ ### Custom Box Selection
176
274
 
177
- ### Undo / redo
275
+ If you want your own rectangular selection UI instead of `BoxSelectControl`, use
276
+ the built-in handler as the gesture engine and keep your buttons outside
277
+ `emap`:
178
278
 
179
279
  ```ts
180
- map.undo();
181
- map.redo();
182
- map.clearHistory();
280
+ const boxButton = document.querySelector<HTMLButtonElement>('[data-tool="box"]')!;
281
+ const clearButton = document.querySelector<HTMLButtonElement>('[data-tool="clear"]')!;
282
+
283
+ map.boxSelect.setOptions({
284
+ layers: ['district-fill'],
285
+ dragActivator: 'shift',
286
+ dragThreshold: 4,
287
+ mode: 'replace',
288
+ });
183
289
 
184
- map.on('historychange', (e) => {
185
- console.log(e.canUndo, e.canRedo, e.label);
290
+ function activateBoxSelect() {
291
+ map.lassoSelect.disable();
292
+ map.vertexEdit.disable();
293
+ map.drawFeature.disable();
294
+ map.transformFeature.disable();
295
+ map.boxSelect.enable();
296
+ boxButton.dataset.active = 'true';
297
+ }
298
+
299
+ function deactivateBoxSelect() {
300
+ map.boxSelect.disable();
301
+ delete boxButton.dataset.active;
302
+ }
303
+
304
+ boxButton.onclick = () => {
305
+ if (map.boxSelect.isEnabled()) deactivateBoxSelect();
306
+ else activateBoxSelect();
307
+ };
308
+
309
+ clearButton.onclick = () => map.clearSelection();
310
+
311
+ map.on('selectionchange', (e) => {
312
+ selectionCount.textContent = String(e.selected.length);
186
313
  });
187
314
  ```
188
315
 
189
- Vertex drags / feature translates within a session merge into one undo step. Dataset-replace ops (clip, dissolve, …) keep prior commands in the stack but flag them as `stale` for the UI to render appropriately — there's no destructive `clear`.
316
+ The handler owns pointer capture, hit testing, selection mode resolution, and
317
+ its overlay drawing. Your application owns toolbar state, mode switching, and
318
+ whether selection is allowed for the current workflow.
190
319
 
191
- ### Validation hooks
192
-
193
- Register validators that run after every committed edit. Failures fire `validationfailed` — the engine doesn't auto-undo, the app decides.
320
+ ## Feature Events and Highlighting
194
321
 
195
322
  ```ts
196
- import { topologyValidator } from '@zwishing/emap';
323
+ map.on('feature:click', { layers: ['district-fill'] }, (e) => {
324
+ console.log(e.ref, e.feature.properties);
325
+ map.select([e.ref]);
326
+ });
197
327
 
198
- const unregister = map.validators.register(topologyValidator({
199
- sources: ['china'],
200
- }));
328
+ map.on('feature:enter', { layers: ['district-fill'] }, (e) => {
329
+ map.setHighlightedFeatures([e.ref], { color: '#1677ff', width: 2, fill: true });
330
+ });
201
331
 
202
- map.on('validationfailed', (e) => {
203
- console.warn(e.results); // [{ validator, report }, ...]
204
- // Optionally: map.undo();
332
+ map.on('feature:leave', () => {
333
+ map.clearHighlightedFeatures();
334
+ });
335
+
336
+ map.on('mousemove', (e) => {
337
+ status.textContent = `${e.mapCoord[0].toFixed(4)}, ${e.mapCoord[1].toFixed(4)}`;
205
338
  });
206
339
  ```
207
340
 
208
- Custom validators implement `Validator { name, phase: 'after-commit', run(host, change) }` and return `{ ok, issues: [{ severity, message, ref? }] }`.
341
+ Feature and pointer events are delegated through the same pointer arbiter as the
342
+ handlers. The pointer sink is installed lazily, so there is no hit-test overhead
343
+ when no listener is registered.
209
344
 
210
- ### Feature accessor
345
+ ## Data Operations
211
346
 
212
- Read one feature (or iterate a layer) without indexing into raw `layer.shapes` / `layer.data.getRecords()`:
347
+ All operations return `Promise<OpResult<T>>`:
213
348
 
214
349
  ```ts
215
- const f = map.features.get({ source: 'us', layer: 'states', id: 12 });
216
- if (f) console.log(f.properties.NAME, f.geometry);
350
+ const result = await map.ops.bufferLayer({
351
+ source: 'roads',
352
+ target: 'roads',
353
+ radius: 50,
354
+ });
217
355
 
218
- for (const f of map.features.iter('us', 'states')) {
219
- /* … */
356
+ if (!result.ok) {
357
+ console.error(result.error.kind, result.error.message);
220
358
  }
359
+ ```
360
+
361
+ Common operation groups:
362
+
363
+ - Geometry operations: `clipLayer`, `eraseLayer`, `dissolveLayer`,
364
+ `bufferLayer`, `simplifyLayer`, `projectLayer`, `cleanLayer`, `snapLayer`.
365
+ - Conversion operations: `pointsLayer`, `linesLayer`, `polygonsLayer`,
366
+ `innerlinesLayer`, `explodeLayer`.
367
+ - Layer operations: `renameLayer`, `mergeLayers`, `splitLayer`, `dropLayer`.
368
+ - Attribute operations: `applyExpression`, `filterFeatures`, `joinTable`,
369
+ `sortFeatures`, `uniqueFeatures`, `filterFields`, `renameFields`,
370
+ `dataFill`.
371
+ - Inspection and repair: `checkGeometry`, `rebuildTopology`,
372
+ `intersectionPointsLayer`.
373
+ - Selection-driven operation: `mergeSelected`.
374
+
375
+ Expression-bearing APIs are disabled by default for safety:
221
376
 
222
- map.features.count('us', 'states'); // O(1)
377
+ ```ts
378
+ const map = new Emap({ container: 'map', expressionPolicy: 'trusted' });
223
379
  ```
224
380
 
225
- `f.properties` is `Object.freeze`d so accidental mutation can't leak back into the dataset.
381
+ Use `'trusted'` only for application-owned expressions and trusted data.
226
382
 
227
- ### Selection
383
+ ## Transactions, Undo, and Validation
228
384
 
229
385
  ```ts
230
- map.selection.add({ source: 'us', layer: 'counties', id: 42 });
231
- map.selection.toggle(ref);
232
- map.selection.clear();
386
+ const tx = map.beginTransaction();
387
+
388
+ const clipped = await map.ops.clipLayer({ source: 's', target: 'parcels', mask: 'clip' });
389
+ if (!clipped.ok) {
390
+ tx.rollback();
391
+ } else {
392
+ await tx.commit('Clip parcels');
393
+ }
394
+
395
+ map.undo();
396
+ map.redo();
233
397
 
234
398
  map.on('selectionchange', (e) => {
235
- console.log(e.added, e.removed, e.current);
399
+ console.log(e.selected, e.added, e.removed);
236
400
  });
237
401
  ```
238
402
 
239
- Attribute-only ops (each / join / rename-fields / sort) preserve the selection across the operation. Shape-changing or topology-rebuilding ops clear it.
240
-
241
- ### Worker offloading
403
+ Register validators for post-commit checks:
242
404
 
243
405
  ```ts
244
- const map = new Emap({
245
- container: 'map',
246
- workerUrl: '/emap-worker.js', // built alongside dist/emap.js
247
- useWorker: 'auto', // 'auto' | true | false
248
- workerThreshold: 200_000, // vertex count
249
- workerPoolSize: 2, // multiple workers (PR-22a)
406
+ import { topologyValidator } from '@zwishing/emap';
407
+
408
+ const unregister = map.validators.register(topologyValidator({
409
+ sources: ['editing'],
410
+ }));
411
+
412
+ map.on('validationfailed', (e) => {
413
+ console.warn(e.results);
250
414
  });
415
+ ```
416
+
417
+ The engine reports validation failures; it does not auto-undo. The application
418
+ decides whether to warn, block save, or call `map.undo()`.
419
+
420
+ ## Camera
251
421
 
252
- map.on('workerjobstart', (e) => console.log('start', e.label));
253
- map.on('workerjobend', (e) => console.log('end', e.durationMs));
422
+ ```ts
423
+ map.jumpTo({ center: [120, 30], zoom: 6 });
424
+ map.easeTo({ center: [120, 30], zoom: 8, duration: 600 });
425
+ map.flyTo({ center: [120, 30], zoom: 10, duration: 1500 });
426
+ map.fitBounds(source.getExtent(), { padding: 40, duration: 600 });
427
+ map.panBy([120, 0]);
428
+ map.stop();
254
429
  ```
255
430
 
256
- The router classifies each op as cheap / expensive and overrides the threshold accordingly. Override the decision globally with `MapOptions.workerRouting?: (info) => boolean`.
257
- `workerMode` is also accepted as a compatibility alias for `useWorker`; prefer `useWorker` in new code.
431
+ Camera methods are chainable and fire `movestart`, `move`, `moveend`,
432
+ `zoomstart`, `zoom`, and `zoomend` events.
258
433
 
259
- When worker offloading is enabled, serve both `emap-worker.js` and
260
- `mapshaper-vendor.js` from the same directory. The worker loads the vendor file
261
- with `importScripts('mapshaper-vendor.js')`.
434
+ ## Worker Offloading
262
435
 
263
436
  ```ts
264
437
  const map = new Emap({
265
438
  container: 'map',
266
439
  useWorker: 'auto',
440
+ workerThreshold: 200_000,
267
441
  workerUrl: '/vendor/emap/emap-worker.js',
442
+ workerPoolSize: 2,
268
443
  });
269
444
  ```
270
445
 
271
- For bundler projects, copy these package files to that served directory during
272
- your app build:
446
+ When workers are enabled, serve these files from the same directory:
273
447
 
274
448
  ```text
275
449
  node_modules/@zwishing/emap/dist/emap-worker.js
276
450
  node_modules/@zwishing/emap/dist/mapshaper-vendor.js
277
451
  ```
278
452
 
279
- ### Snapshots
453
+ `workerRouting` can override the built-in cheap/expensive operation routing.
454
+
455
+ Workers only cover dataset-replace style operations such as clip, dissolve,
456
+ union, simplify, project, and similar `map.ops.*` commands. Pointer
457
+ interactions, selection transforms, vertex edits, and drawing sessions stay on
458
+ the main thread because their per-frame state is UI-bound and usually cheaper
459
+ than worker round trips.
460
+
461
+ ## Theming
462
+
463
+ Import the default CSS once:
280
464
 
281
465
  ```ts
282
- const blob = await map.exportSnapshot(); // → Uint8Array
283
- await map.loadSnapshot(blob); // restore datasets + history
466
+ import '@zwishing/emap/style.css';
284
467
  ```
285
468
 
286
- The export is a mapshaper-pack with magic-byte validation on load.
469
+ Override CSS tokens at `:root` or an app-specific container:
470
+
471
+ ```css
472
+ :root {
473
+ --emap-accent: #1677ff;
474
+ --emap-accent-soft: rgba(22, 119, 255, 0.12);
475
+ --emap-surface-bg: #fff;
476
+ --emap-surface-border: #d9d9d9;
477
+ --emap-surface-shadow: 0 4px 12px rgba(0, 0, 0, 0.16);
478
+ --emap-radius: 4px;
479
+ --emap-hover-bg: #f5f5f5;
480
+ --emap-danger: #d32f2f;
481
+ --emap-font: 13px/1.5 system-ui, sans-serif;
482
+ }
483
+ ```
287
484
 
288
- ### Controls
485
+ ## Built-In Controls
289
486
 
290
487
  | Control | Purpose |
291
488
  |---|---|
292
- | `NavigationControl` | Zoom in/out + reset |
293
- | `StatusControl` | Cursor coordinates + EPSG |
489
+ | `NavigationControl` | Zoom in, zoom out, reset |
490
+ | `StatusControl` | Pointer coordinates and CRS |
294
491
  | `BasemapControl` | MapLibre raster basemap toggle |
295
- | `EditToolbar` | Mode switcher (vertex / feature / draw) |
296
- | `VertexEditControl` | Topology-aware vertex dragging |
297
- | `DrawFeatureControl` | Polygon / polyline / point drawing with edge snapping |
298
- | `HistoryControl` | Visual undo/redo stack with stale-entry markers |
299
- | `BoxSelectControl` | Drag-to-select bounding box |
300
- | `LassoSelectControl` | Free-form selection lasso |
492
+ | `HistoryControl` | Undo/redo buttons |
493
+ | `EditToolbar` | Combined edit toolbar |
494
+ | `VertexEditControl` | Button shell over `map.vertexEdit` |
495
+ | `DrawFeatureControl` | Button shell over `map.drawFeature` |
496
+ | `BoxSelectControl` | Thin shell over `map.boxSelect` |
497
+ | `LassoSelectControl` | Thin shell over `map.lassoSelect` |
498
+ | `SimplifyControl` | Simplification UI |
499
+
500
+ ## Known Limitations
501
+
502
+ - The project is AI-driven and still moving quickly; avoid depending on
503
+ undocumented internals in production applications.
504
+ - `fill` rendering is heavier than `line` rendering for large polygon datasets
505
+ because area fills require full polygon path materialization and fill rules.
506
+ For large-data browsing, keep polygon layers outline-first and enable fills
507
+ only at suitable scales or for filtered subsets.
508
+ - Rendering is Canvas 2D only. There is no WebGL renderer, symbol placement
509
+ engine, tiled vector source, or server-side tile pipeline.
510
+ - CRS support follows the bundled Mapshaper/projection path and common browser
511
+ data workflows. Validate project-specific CRS definitions before building
512
+ user-facing editing workflows around them.
513
+ - Expression-bearing operations are disabled by default. Set
514
+ `expressionPolicy: 'trusted'` only for first-party expressions and trusted
515
+ data.
516
+ - Worker offloading requires the host app to deploy worker/vendor assets
517
+ correctly and does not make every interaction asynchronous.
301
518
 
302
- All controls implement `onAdd(map)` / `onRemove()` and accept `'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'`.
303
-
304
- ## Architecture
519
+ ## Development
305
520
 
306
- ```
307
- src/
308
- ├── map/ # Emap orchestrator, ops facade, selection, history
309
- │ └── ops/ # one file per data op (clip, dissolve, buffer, …)
310
- ├── adapter/ # MapshaperAdapter sole bridge to mapshaper.internal.*
311
- ├── source/ # TopologySource, DisplayArcs (LOD)
312
- ├── core/ # EventDispatcher, MapshaperWorkerPool, drag/wheel
313
- ├── geo/ # Viewport, Projection, Bounds, AffineTransform, CRS resolver
314
- ├── renderer/ # CanvasPainter (HTML5 Canvas 2D)
315
- ├── edit/ # EditHistory + per-command memento classes
316
- ├── ui/ # Control implementations + controls.css
317
- ├── worker/ # off-thread mapshaper entry
318
- └── types/ # central mapshaper type definitions
521
+ ```bash
522
+ pnpm install
523
+ cmd /c npm run typecheck
524
+ cmd /c npm run test:run
525
+ cmd /c npm run build
319
526
  ```
320
527
 
321
- Design pillars:
528
+ Build outputs:
322
529
 
323
- - **One adapter, no leaks.** `MapshaperAdapter` is the only place `mapshaper.internal.*` is touched. Tests stub it; future mapshaper upgrades reroute through it.
324
- - **Structured commands, no string-build.** Ops emit `ParsedCommand[]` directly there is no CLI string between the op and mapshaper, on either thread.
325
- - **Memento commands.** Every edit captures enough state to round-trip do/undo without referencing live dataset internals.
326
- - **Effect-aware dataset replace.** Each op declares whether it changes shape / topology / attributes / CRS; selection is preserved or cleared accordingly.
327
- - **Affine coordinate math everywhere.** All viewport conversions pass through `AffineTransform`, never ad-hoc matrix math.
328
- - **Batch rendering.** `CanvasPainter` groups 25 paths per `stroke()` — measured optimization for large topology datasets.
530
+ - `dist/emap.js` - IIFE bundle.
531
+ - `dist/emap.mjs` - ES module.
532
+ - `dist/emap-worker.js` - worker bundle.
533
+ - `dist/mapshaper-vendor.js` and `dist/mapshaper-vendor.mjs` - Mapshaper vendor bundles.
534
+ - `dist/emap.css` - control styles.
535
+ - `dist/index.d.ts` - TypeScript declarations.
329
536
 
330
- ## Development
537
+ Manual examples live in `test/examples/*.html`.
538
+
539
+ ## Release Checklist
540
+
541
+ Before publishing a version:
331
542
 
332
543
  ```bash
333
- pnpm install
334
- npm run dev # watch + sourcemaps
335
- npm run dev-build # one-shot dev build
336
- npm run build # production (minified) + .d.ts
337
- npm test # vitest, ~760 unit tests
338
- npm run typecheck # strict tsc
544
+ cmd /c npm ci
545
+ cmd /c npm run typecheck
546
+ cmd /c npm run test:run
547
+ cmd /c npm run build
548
+ cmd /c npm pack --dry-run
339
549
  ```
340
550
 
341
- Build outputs in `dist/`:
342
-
343
- - `emap.js` — production IIFE bundle
344
- - `emap.mjs` — ES module
345
- - `emap-dev.js` — dev bundle with sourcemaps
346
- - `emap-worker.js` — worker entry
347
- - `mapshaper-vendor.js` — mapshaper vendor bundle (load before `emap.js` in IIFE mode)
348
- - `index.d.ts` — TypeScript declarations
551
+ Then check:
349
552
 
350
- Manual testing: open any `test/examples/*.html` after building.
553
+ - `package.json` version matches the release section in `CHANGELOG.md`.
554
+ - `README.md`, `FEATURES.md`, `SECURITY.md`, and `CHANGELOG.md` describe the
555
+ same public API.
556
+ - The dry-run package contains only current `dist` files, docs, license, and
557
+ package metadata.
558
+ - A fresh consumer project can import `@zwishing/emap`, import
559
+ `@zwishing/emap/style.css`, and resolve `dist/emap-worker.js` if worker
560
+ mode is documented for the release.
351
561
 
352
562
  ## License
353
563