@zwishing/emap 0.2.0 → 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 +212 -1
  2. package/FEATURES.md +455 -0
  3. package/README.md +415 -210
  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 +7 -2
  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,303 +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
-
121
- // Re-importing an existing source: setData is awaitable — it resolves
122
- // once the dataset is ready, so getLayers()/getExtent() are safe after it.
123
- // The `data` / `error` events still fire for event-driven consumers.
124
- await source.setData({ filename: 'next.geojson', content: bytes });
125
- source.getExtent();
126
- source.getLayers();
218
+ handler.enable();
219
+ handler.disable();
220
+ handler.isEnabled();
221
+ handler.setOptions({...});
222
+ handler.getOptions();
127
223
  ```
128
224
 
129
- ### Editing operations (`emap.ops`)
225
+ Available named handlers:
130
226
 
131
- 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 |
132
237
 
133
- ```ts
134
- // Whole-dataset CLI ops
135
- await map.ops.clipLayer({ source: 'roads', target: 'streets', mask: 'park' });
136
- await map.ops.dissolveLayer({ source: 'us', target: 'counties', field: 'STATE' });
137
- await map.ops.bufferLayer({ source: 'roads', target: 'streets', radius: 50 });
138
- await map.ops.simplifyLayer({ source: 'world', percentage: 10 });
139
- await map.ops.projectLayer({ source: 'world', crs: 'EPSG:3857' });
140
-
141
- // Layer management
142
- await map.ops.renameLayer({ source: 's', target: 'old', name: 'new' });
143
- await map.ops.mergeLayers({ source: 's', targets: ['a', 'b'], name: 'combined' });
144
- await map.ops.splitLayer({ source: 's', target: 'us', expression: 'STATE' });
145
- await map.ops.dropLayer({ source: 's', target: 'tmp' });
146
-
147
- // Attribute / data
148
- await map.ops.applyExpression({ source: 's', target: 'l', expression: 'this.area = $.area' });
149
- await map.ops.filterFeatures({ source: 's', target: 'l', expression: 'this.pop > 1e6' });
150
- await map.ops.joinTable({
151
- source: 's', target: 'states',
152
- data: { csv: csvBytes }, keys: ['STATE_FIPS', 'fips'],
153
- });
238
+ ## Building Your Own UI
154
239
 
155
- // Topology repair / inspection
156
- await map.ops.cleanLayer({ source: 's', target: 'l' });
157
- await map.ops.snapLayer({ source: 's', target: 'l', interval: 0.001 });
158
- const report = await map.ops.checkGeometry({ source: 's' });
159
- // → { 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:
160
242
 
161
- // Selection-driven
162
- await map.ops.mergeSelected();
163
- ```
243
+ ```ts
244
+ toolbar.querySelector('[data-tool="box"]').onclick = () => {
245
+ map.lassoSelect.disable();
246
+ map.boxSelect.enable();
247
+ };
164
248
 
165
- 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();
166
251
 
167
- ### Transactions
252
+ map.on('historychange', (e) => {
253
+ undoButton.disabled = !e.canUndo;
254
+ redoButton.disabled = !e.canRedo;
255
+ });
256
+ ```
168
257
 
169
- 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:
170
259
 
171
260
  ```ts
172
- const tx = map.beginTransaction();
173
- const r1 = await map.ops.clipLayer({ source, target, mask });
174
- if (!r1.ok) { tx.rollback(); return; }
175
- const r2 = await map.ops.dissolveLayer({ source, target, field: 'STATE' });
176
- if (!r2.ok) { tx.rollback(); return; }
177
- 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');
178
271
  ```
179
272
 
180
- Nesting is not supported — opening a transaction while one is active throws.
273
+ ### Custom Box Selection
181
274
 
182
- ### 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`:
183
278
 
184
279
  ```ts
185
- map.undo();
186
- map.redo();
187
- 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
+ });
188
289
 
189
- map.on('historychange', (e) => {
190
- 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);
191
313
  });
192
314
  ```
193
315
 
194
- 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.
195
319
 
196
- ### Validation hooks
197
-
198
- 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
199
321
 
200
322
  ```ts
201
- 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
+ });
202
327
 
203
- const unregister = map.validators.register(topologyValidator({
204
- sources: ['china'],
205
- }));
328
+ map.on('feature:enter', { layers: ['district-fill'] }, (e) => {
329
+ map.setHighlightedFeatures([e.ref], { color: '#1677ff', width: 2, fill: true });
330
+ });
206
331
 
207
- map.on('validationfailed', (e) => {
208
- console.warn(e.results); // [{ validator, report }, ...]
209
- // 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)}`;
210
338
  });
211
339
  ```
212
340
 
213
- 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.
214
344
 
215
- ### Feature accessor
345
+ ## Data Operations
216
346
 
217
- Read one feature (or iterate a layer) without indexing into raw `layer.shapes` / `layer.data.getRecords()`:
347
+ All operations return `Promise<OpResult<T>>`:
218
348
 
219
349
  ```ts
220
- const f = map.features.get({ source: 'us', layer: 'states', id: 12 });
221
- 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
+ });
222
355
 
223
- for (const f of map.features.iter('us', 'states')) {
224
- /* … */
356
+ if (!result.ok) {
357
+ console.error(result.error.kind, result.error.message);
225
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:
226
376
 
227
- map.features.count('us', 'states'); // O(1)
377
+ ```ts
378
+ const map = new Emap({ container: 'map', expressionPolicy: 'trusted' });
228
379
  ```
229
380
 
230
- `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.
231
382
 
232
- ### Selection
383
+ ## Transactions, Undo, and Validation
233
384
 
234
385
  ```ts
235
- map.selection.add({ source: 'us', layer: 'counties', id: 42 });
236
- map.selection.toggle(ref);
237
- 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();
238
397
 
239
398
  map.on('selectionchange', (e) => {
240
- console.log(e.added, e.removed, e.current);
399
+ console.log(e.selected, e.added, e.removed);
241
400
  });
242
401
  ```
243
402
 
244
- Attribute-only ops (each / join / rename-fields / sort) preserve the selection across the operation. Shape-changing or topology-rebuilding ops clear it.
245
-
246
- ### Worker offloading
403
+ Register validators for post-commit checks:
247
404
 
248
405
  ```ts
249
- const map = new Emap({
250
- container: 'map',
251
- workerUrl: '/emap-worker.js', // built alongside dist/emap.js
252
- useWorker: 'auto', // 'auto' | true | false
253
- workerThreshold: 200_000, // vertex count
254
- 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);
255
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
256
421
 
257
- map.on('workerjobstart', (e) => console.log('start', e.label));
258
- 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();
259
429
  ```
260
430
 
261
- The router classifies each op as cheap / expensive and overrides the threshold accordingly. Override the decision globally with `MapOptions.workerRouting?: (info) => boolean`.
262
- `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.
263
433
 
264
- When worker offloading is enabled, serve both `emap-worker.js` and
265
- `mapshaper-vendor.js` from the same directory. The worker loads the vendor file
266
- with `importScripts('mapshaper-vendor.js')`.
434
+ ## Worker Offloading
267
435
 
268
436
  ```ts
269
437
  const map = new Emap({
270
438
  container: 'map',
271
439
  useWorker: 'auto',
440
+ workerThreshold: 200_000,
272
441
  workerUrl: '/vendor/emap/emap-worker.js',
442
+ workerPoolSize: 2,
273
443
  });
274
444
  ```
275
445
 
276
- For bundler projects, copy these package files to that served directory during
277
- your app build:
446
+ When workers are enabled, serve these files from the same directory:
278
447
 
279
448
  ```text
280
449
  node_modules/@zwishing/emap/dist/emap-worker.js
281
450
  node_modules/@zwishing/emap/dist/mapshaper-vendor.js
282
451
  ```
283
452
 
284
- ### 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:
285
464
 
286
465
  ```ts
287
- const blob = await map.exportSnapshot(); // → Uint8Array
288
- await map.loadSnapshot(blob); // restore datasets + history
466
+ import '@zwishing/emap/style.css';
289
467
  ```
290
468
 
291
- 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
+ ```
292
484
 
293
- ### Controls
485
+ ## Built-In Controls
294
486
 
295
487
  | Control | Purpose |
296
488
  |---|---|
297
- | `NavigationControl` | Zoom in/out + reset |
298
- | `StatusControl` | Cursor coordinates + EPSG |
489
+ | `NavigationControl` | Zoom in, zoom out, reset |
490
+ | `StatusControl` | Pointer coordinates and CRS |
299
491
  | `BasemapControl` | MapLibre raster basemap toggle |
300
- | `EditToolbar` | Mode switcher (vertex / feature / draw) |
301
- | `VertexEditControl` | Topology-aware vertex dragging |
302
- | `DrawFeatureControl` | Polygon / polyline / point drawing with edge snapping |
303
- | `HistoryControl` | Visual undo/redo stack with stale-entry markers |
304
- | `BoxSelectControl` | Drag-to-select bounding box |
305
- | `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.
306
518
 
307
- All controls implement `onAdd(map)` / `onRemove()` and accept `'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'`.
308
-
309
- ## Architecture
519
+ ## Development
310
520
 
311
- ```
312
- src/
313
- ├── map/ # Emap orchestrator, ops facade, selection, history
314
- │ └── ops/ # one file per data op (clip, dissolve, buffer, …)
315
- ├── adapter/ # MapshaperAdapter sole bridge to mapshaper.internal.*
316
- ├── source/ # TopologySource, DisplayArcs (LOD)
317
- ├── core/ # EventDispatcher, MapshaperWorkerPool, drag/wheel
318
- ├── geo/ # Viewport, Projection, Bounds, AffineTransform, CRS resolver
319
- ├── renderer/ # CanvasPainter (HTML5 Canvas 2D)
320
- ├── edit/ # EditHistory + per-command memento classes
321
- ├── ui/ # Control implementations + controls.css
322
- ├── worker/ # off-thread mapshaper entry
323
- └── 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
324
526
  ```
325
527
 
326
- Design pillars:
528
+ Build outputs:
327
529
 
328
- - **One adapter, no leaks.** `MapshaperAdapter` is the only place `mapshaper.internal.*` is touched. Tests stub it; future mapshaper upgrades reroute through it.
329
- - **Structured commands, no string-build.** Ops emit `ParsedCommand[]` directly there is no CLI string between the op and mapshaper, on either thread.
330
- - **Memento commands.** Every edit captures enough state to round-trip do/undo without referencing live dataset internals.
331
- - **Effect-aware dataset replace.** Each op declares whether it changes shape / topology / attributes / CRS; selection is preserved or cleared accordingly.
332
- - **Affine coordinate math everywhere.** All viewport conversions pass through `AffineTransform`, never ad-hoc matrix math.
333
- - **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.
334
536
 
335
- ## Development
537
+ Manual examples live in `test/examples/*.html`.
538
+
539
+ ## Release Checklist
540
+
541
+ Before publishing a version:
336
542
 
337
543
  ```bash
338
- pnpm install
339
- npm run dev # watch + sourcemaps
340
- npm run dev-build # one-shot dev build
341
- npm run build # production (minified) + .d.ts
342
- npm test # vitest, ~760 unit tests
343
- 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
344
549
  ```
345
550
 
346
- Build outputs in `dist/`:
347
-
348
- - `emap.js` — production IIFE bundle
349
- - `emap.mjs` — ES module
350
- - `emap-dev.js` — dev bundle with sourcemaps
351
- - `emap-worker.js` — worker entry
352
- - `mapshaper-vendor.js` — mapshaper vendor bundle (load before `emap.js` in IIFE mode)
353
- - `index.d.ts` — TypeScript declarations
551
+ Then check:
354
552
 
355
- 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.
356
561
 
357
562
  ## License
358
563