diff --git a/assets/templates/draw-tools.njk b/assets/templates/draw-tools.njk index 2bb518f95..5e0ae407d 100644 --- a/assets/templates/draw-tools.njk +++ b/assets/templates/draw-tools.njk @@ -33,7 +33,7 @@ - + +``` + +```js +const drawPlugin = defra.drawPlugin() + +const interactiveMap = new defra.InteractiveMap('map', { + mapProvider: defra.maplibreProvider(), + plugins: [drawPlugin] +}) +``` + +> [!NOTE] +> **GOV.UK Prototype Kit** — skip the copy step. All files are served automatically. Use this path instead: +> ```html +> +> ``` + +## Options + +Options are passed to the factory function when creating the plugin. + +--- + +### `snapLayers` + +**Type:** `string[]` + +Vector tile source-layer names to snap new and edited vertices against. Can be overridden per call — see `newPolygon`, `newLine`, `editFeature`, and `split` below. + +The layer names available depend entirely on your basemap style — there's no universal default, so check your style's vector tile source(s) for the source-layer names to use. The example below (`'OS/TopographicArea_1/Agricultural Land'`) is specific to an Ordnance Survey basemap style. + +When set (globally or per call), a "Snap to feature" toggle appears in the draw menu, letting the user turn snapping on and off during a session. + +```js +createDrawPlugin({ + snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'] +}) +``` + +--- + +### `onGeometryChange` + +**Type:** `Function` + +Plugin-level validation callback, called throughout the draw/edit lifecycle so you can enforce your own rules (e.g. "shapes must stay inside a boundary") alongside the built-in ones. Can be overridden per call — see [Validation](#validation) below for the full contract, and `newPolygon`, `newLine`, `editFeature` for the per-call override. + +--- + +### `includeModes` + +**Type:** `string[]` + +When set, the plugin only initialises when the app is in one of the specified modes. + +--- + +### `excludeModes` + +**Type:** `string[]` + +When set, the plugin does not initialise when the app is in one of the specified modes. + +--- + +### Colour and size overrides + +> [!NOTE] +> Each colour accepts a plain colour string or a style-keyed object (e.g. `{ light: '#1d70b8', dark: '#ffffff' }`). + +| Property | Type | Description | +|----------|------|-------------| +| `shapeStroke` | `string \| Record` | Default stroke colour for an inactive (not being drawn/edited) shape, when the feature sets no `stroke` of its own | +| `shapeFill` | `string \| Record` | Default fill colour for an inactive shape, when the feature sets no `fill` of its own | +| `strokeWidth` | `number` | Default stroke width in pixels. **Default:** `2` | +| `editStroke` | `string \| Record` | Stroke colour of the shape currently being drawn or edited | +| `editFill` | `string \| Record` | Fill colour of the shape currently being drawn or edited | +| `editVertex` | `string \| Record` | Colour of placed, unselected vertex handles | +| `editMidpoint` | `string \| Record` | Colour of the midpoint handles used to insert a new vertex on an edge | +| `editActive` | `string \| Record` | Colour of the currently selected/active vertex handle | +| `editHalo` | `string \| Record` | Colour of the halo drawn behind vertex/midpoint handles for contrast | +| `invalidStroke` | `string \| Record` | Stroke colour of the dashed outline shown while the shape fails validation | +| `splitValid` | `string \| Record` | Colour of the split line while it would produce a valid split | +| `splitInvalid` | `string \| Record` | Colour of the split line while it would not produce a valid split | +| `snapVertex` | `string \| Record` | Colour of the snap indicator shown over a vertex | +| `snapEdge` | `string \| Record` | Colour of the snap indicator shown over an edge | +| `snapRadius` | `number` | Snap tolerance in pixels. **Default:** `12` | + +```js +createDrawPlugin({ + shapeStroke: { light: '#1d70b8', dark: '#5694ca' }, + editStroke: '#0b0c0c', + strokeWidth: 3, + snapRadius: 16 +}) +``` + +## Validation + +Every geometry change — placing a vertex, dragging one, finishing a shape — is checked against a set of built-in rules before it's accepted, plus your own `onGeometryChange` callback if you provide one. + +### Built-in rules + +| Rule | Applies to | Behaviour | +|------|-----------|-----------| +| Minimum vertices | Polygon (3), Line (2) | Gates the Done button — a shape below the minimum can't be finished | +| Self-intersection | Polygon | Gates the Done button — a self-crossing shape can't be finished | +| Self-intersection (placement) | Polygon | Rejects a vertex placement outright if it would make the drawn path cross itself — the vertex never appears | +| Non-zero area | Polygon | Gates the Done button — a collinear/degenerate ring can't be finished | + +Rule failures gate the Done button (the shape can pass through interim invalid states while being built or reshaped, shown with a dashed outline) except the placement-time self-intersection check, which is a hard veto — that specific vertex is rejected and never placed. + +### `onGeometryChange` callback + +Set at the plugin level (`createDrawPlugin({ onGeometryChange })`) or per call (`newPolygon`/`newLine`/`editFeature`'s `options.onGeometryChange`, which takes precedence when given). Not used by `split` (which validates that the line actually bisects the shape) or `merge`/`addFeature` (no validation). + +**Signature:** `onGeometryChange(event) => boolean | { valid: boolean, reason?: string } | undefined` + +| Return value | Meaning | +|---|---| +| `true` / `undefined` | Valid | +| `false` | Invalid, no reason shown | +| `{ valid, reason }` | Valid or invalid, with an optional reason surfaced as a hint toast (except for placement, which never toasts — see `phase` below) | + +**Event payload:** + +| Property | Type | Description | +|----------|------|-------------| +| `feature` | `GeoJSON.Feature` | The shape at this point — the in-progress feature during `'preview'`, the committed/candidate feature at every other phase | +| `phase` | `string` | See table below | +| `mode` | `'draw_polygon' \| 'draw_line' \| 'edit_vertex'` | The active draw mode | +| `vertexIndex` | `number` | Index of the vertex being placed/added/moved/inserted/deleted. Present on `'place'` and every `'commit-*'` phase | +| `numVertices` | `number` | Count of already-committed vertices, excluding any in-progress cursor point. Present on every `'preview'` call | + +**Phases:** + +| Phase | When | Can veto? | +|-------|------|-----------| +| `'preview'` | Live feedback on every rubber-band move while drawing/dragging, throttled to once per frame. Drives both the dashed-outline check and the Add-point button | No — display only | +| `'place'` | A real click/tap/Add-point press, evaluated once, synchronously | Yes — a hard rule or your callback returning invalid rejects the vertex outright; it never appears | +| `'create'` | A whole new feature just finished being drawn | Gates whether it's accepted as-is or reopened in edit mode | +| `'edit-start'` | An existing feature was just loaded into an edit session — the baseline check before anything has changed | Gates the initial Done state | +| `'commit-add'` | A vertex was added while drawing | Gates Done | +| `'commit-move'` | A vertex was dragged/nudged while editing | Gates Done | +| `'commit-insert'` | A vertex was inserted at a midpoint while editing | Gates Done | +| `'commit-delete'` | A vertex was removed while editing | Gates Done | + +```js +createDrawPlugin({ + onGeometryChange: (event) => ({ + valid: isEastOfWalesBorder(event.feature.geometry), + reason: 'Points must be placed east of the England/Wales border' + }) +}) +``` + +## Methods + +Methods are called on the plugin instance. `newPolygon`, `newLine`, `editFeature`, `addFeature`, `deleteFeature`, `split`, and `merge` all need `mapProvider.draw` to be ready — call them after [`draw:ready`](#drawready). + +--- + +### `newPolygon(featureId, options?)` + +Start drawing a new polygon. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureId` | `string` | **Required.** ID to assign the finished feature | +| `options.snapLayers` | `string[]` | Overrides the plugin-level `snapLayers` for this session | +| `options.onGeometryChange` | `Function` | Overrides the plugin-level `onGeometryChange` for this session — see [Validation](#validation) | +| `options.stroke` | `string \| Record` | Stroke colour for this shape | +| `options.fill` | `string \| Record` | Fill colour for this shape | +| `options.strokeWidth` | `number` | Stroke width in pixels for this shape | +| `options.properties` | `Object` | Custom GeoJSON properties to set on the finished feature | + +```js +drawPlugin.newPolygon(crypto.randomUUID(), { + stroke: '#e6c700', + fill: 'rgba(255, 221, 0, 0.1)' +}) +``` + +--- + +### `newLine(featureId, options?)` + +Start drawing a new line. Same options as `newPolygon`. + +```js +drawPlugin.newLine(crypto.randomUUID(), { + stroke: { outdoor: '#99704a', dark: '#ffffff' }, + strokeWidth: 6 +}) +``` + +--- + +### `editFeature(featureId, options?)` + +Open an existing feature in vertex-edit mode. Returns `false` (without doing anything) if the feature doesn't exist or the plugin isn't ready yet — check the return value before assuming the edit session started. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureId` | `string` | **Required.** ID of the feature to edit | +| `options.snapLayers` | `string[]` | Overrides the plugin-level `snapLayers` for this session | +| `options.onGeometryChange` | `Function` | Overrides the plugin-level `onGeometryChange` for this session | + +```js +const editSuccess = drawPlugin.editFeature(selectedFeatureId) +if (!editSuccess) { + return +} +``` + +--- + +### `addFeature(feature)` + +Add a feature directly to the map without a draw session — e.g. loading in existing shapes on `draw:ready`. + +| Argument | Type | Description | +|----------|------|-------------| +| `feature.id` | `string` | **Required.** Feature ID | +| `feature.geometry` | `GeoJSON.Geometry` | **Required.** `Polygon` or `LineString` geometry | +| `feature.stroke` | `string \| Record` | Stroke colour | +| `feature.fill` | `string \| Record` | Fill colour | +| `feature.strokeWidth` | `number` | Stroke width in pixels | +| `feature.properties` | `Object` | Custom GeoJSON properties | + +```js +interactiveMap.on('draw:ready', () => { + drawPlugin.addFeature({ + id: 'test1234', + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[-2.879, 54.709], [-2.877, 54.708], [-2.875, 54.708], [-2.879, 54.709]]] }, + stroke: 'rgba(0,112,60,1)', + fill: 'rgba(0,112,60,0.2)', + strokeWidth: 2 + }) +}) +``` + +--- + +### `deleteFeature(featureIds)` + +Remove one or more features from the map. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureIds` | `string[]` | IDs of the features to remove | + +```js +drawPlugin.deleteFeature(['test1234']) +``` + +--- + +### `split(featureId, options?)` + +Start drawing a line across a polygon to split it in two. Splitting is a pure computation — it does not remove the original feature or add the results itself. Listen for [`draw:split`](#drawsplit) and call `deleteFeature`/`addFeature` yourself. + +Always snaps to the polygon's own outline, in addition to any layers in `snapLayers`. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureId` | `string` | **Required.** ID of the polygon to split | +| `options.snapLayers` | `string[]` | Additional layers to snap against, on top of the polygon's own outline | + +```js +drawPlugin.split(selectedFeatureId) + +interactiveMap.on('draw:split', (e) => { + drawPlugin.deleteFeature([e.originalFeatureId]) + e.featureCollection.features.forEach((feature, index) => { + drawPlugin.addFeature({ + id: `${e.originalFeatureId}-${index === 0 ? 'a' : 'b'}`, + type: feature.type, + geometry: feature.geometry, + properties: feature.properties + }) + }) +}) +``` + +--- + +### `merge(featureIds)` + +Merge multiple contiguous polygons into one. Like `split`, this is a pure computation — it does not touch the map. Listen for the return value or [`draw:merge`](#drawmerge) and call `deleteFeature`/`addFeature` yourself. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureIds` | `string[]` | IDs of the polygons to merge | + +**Returns:** the merged GeoJSON feature, or `null` if the merge failed (e.g. the polygons aren't actually contiguous). + +```js +drawPlugin.merge(selectedFeatureIds) + +interactiveMap.on('draw:merge', (e) => { + drawPlugin.deleteFeature(e.originalFeatureIds) + drawPlugin.addFeature({ + id: e.originalFeatureIds[0], + type: e.feature.type, + geometry: e.feature.geometry, + properties: e.feature.properties + }) +}) +``` + +## Buttons and keyboard shortcuts + +The plugin registers its own toolbar buttons automatically — Cancel, Add point (touch only), Done, and a Draw actions menu (Undo, Snap to feature, Delete point) — which show and enable themselves based on the current draw/edit state. You don't need to render these yourself; augment them with your own trigger buttons (e.g. "Draw polygon", "Draw line") the way the [Draw tools example](../examples/draw-tools.mdx) does. + +| Shortcut | Action | +|----------|--------| +| Enter | Add point (draw) | +| Spacebar | Select nearest point (edit) | +| Alt + /// | Select adjacent point (edit) | +| /// | Move point (edit) | +| Shift + /// | Nudge point, fine step (edit) | +| Delete | Delete point (edit) | +| Command/Ctrl + Z | Undo | + +A selected edit vertex also claims the map's [`enableMoveControl`](../api.md#enablemovecontrol) D-pad, if enabled, so it can be nudged with the on-screen directional buttons as well as the keyboard. + +## Events + +Subscribe to events using `interactiveMap.on()`. + +--- + +### `draw:ready` + +Emitted once the draw plugin has initialised. Safe to call API methods from here. + +**Payload:** None + +--- + +### `draw:started` + +Emitted when `newPolygon` or `newLine` starts a new draw session. + +**Payload:** `{ mode: 'draw_polygon' | 'draw_line' }` + +--- + +### `draw:editstart` + +Emitted when `editFeature` opens an existing feature for editing. + +**Payload:** `{ mode: 'edit_polygon' | 'edit_line' }` + +--- + +### `draw:created` + +Emitted when a new shape finishes drawing and passes validation. Also fires for a shape that finished invalid, was automatically reopened in edit mode, and was then fixed and finished — from the caller's perspective it's still a creation, not an edit. + +**Payload:** the finished `GeoJSON.Feature` + +--- + +### `draw:edited` + +Emitted when an existing feature finishes an edit session (via `editFeature`). + +**Payload:** the edited `GeoJSON.Feature` + +--- + +### `draw:cancelled` + +Emitted when the Cancel button is pressed during a draw or edit session. + +**Payload:** the feature being drawn/edited at the time of cancellation + +--- + +### `draw:updated` + +Emitted after a committed vertex operation (add/move/insert/delete) while editing. + +**Payload:** the updated `GeoJSON.Feature` + +--- + +### `draw:vertexselection` + +Emitted when the selected vertex changes in edit mode. + +**Payload:** `{ index: number, numVertices: number }` — `index` is `-1` when nothing is selected + +--- + +### `draw:interfacetypechange` + +Emitted when the input device changes mid-session (e.g. switching from mouse to touch and panning via the Move control) — sync your own `interfaceType` state from this if you're tracking it independently. + +**Payload:** `{ interfaceType: 'mouse' | 'touch' | 'keyboard' }` + +--- + +### `draw:geometryinvalid` + +Emitted whenever a validation check — a built-in rule or your `onGeometryChange` callback — fails, alongside the same hint toast shown to the user (skipped only for an in-progress shape that simply hasn't reached its minimum vertex count yet). See [Validation](#validation). + +**Payload:** `{ feature, reason, phase, mode, vertexIndex? }` + +--- + +### `draw:add` + +Emitted after `addFeature` adds a feature to the map. + +**Payload:** the added `GeoJSON.Feature` + +--- + +### `draw:delete` + +Emitted after `deleteFeature` removes a feature. + +**Payload:** `{ featureId: string }` + +--- + +### `draw:split` + +Emitted when `split` computes a successful split. + +**Payload:** `{ originalFeatureId: string, featureCollection: GeoJSON.FeatureCollection }` — the two resulting polygons + +--- + +### `draw:merge` + +Emitted when `merge` computes a successful merge. + +**Payload:** `{ originalFeatureIds: string[], feature: GeoJSON.Feature }` — the single merged polygon + +```js +interactiveMap.on('draw:created', (feature) => { + console.log('New feature drawn:', feature.id) +}) + +interactiveMap.on('draw:geometryinvalid', ({ reason }) => { + console.log('Validation failed:', reason) +}) +``` diff --git a/govuk-prototype-kit.config.json b/govuk-prototype-kit.config.json index 196bed9d6..8fd3f7216 100644 --- a/govuk-prototype-kit.config.json +++ b/govuk-prototype-kit.config.json @@ -61,12 +61,13 @@ "/dist/css/", "/dist/umd/", "/providers/maplibre/dist", + "/plugins/interact/dist", + "/plugins/search/dist", + "/plugins/draw/dist", "/providers/beta/open-names/dist", "/plugins/datasets/dist", - "/plugins/interact/dist", "/plugins/beta/map-styles/dist", "/plugins/beta/scale-bar/dist", - "/plugins/search/dist", "/plugins/beta/use-location/dist", "/plugins/beta/draw-ml/dist", "/plugins/beta/frame/dist" diff --git a/jest.config.mjs b/jest.config.mjs index abc8bb503..bf7a14a36 100755 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -30,6 +30,7 @@ export default { '/coverage', '/demo', '/src/test-utils.js', + '__helpers__', '/plugins/datasets/', '/providers/beta/', '/plugins/beta/draw-es', diff --git a/package-lock.json b/package-lock.json index a1e748c04..4069e1fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,7 @@ "@turf/boolean-valid": "^7.2.0", "@turf/destination": "^7.3.3", "@turf/helpers": "^7.2.0", - "@turf/line-intersect": "^7.3.3", - "@turf/point-to-line-distance": "^7.3.3", - "@turf/polygon-to-line": "^7.3.3", + "@turf/union": "^7.3.5", "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", "maplibre-gl": "^5.23.0", @@ -9876,6 +9874,7 @@ }, "node_modules/@turf/clone": { "version": "7.3.3", + "dev": true, "license": "MIT", "dependencies": { "@turf/helpers": "7.3.3", @@ -10095,26 +10094,6 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/point-to-line-distance": { - "version": "7.3.3", - "license": "MIT", - "dependencies": { - "@turf/bearing": "7.3.3", - "@turf/distance": "7.3.3", - "@turf/helpers": "7.3.3", - "@turf/invariant": "7.3.3", - "@turf/meta": "7.3.3", - "@turf/nearest-point-on-line": "7.3.3", - "@turf/projection": "7.3.3", - "@turf/rhumb-bearing": "7.3.3", - "@turf/rhumb-distance": "7.3.3", - "@types/geojson": "^7946.0.10", - "tslib": "^2.8.1" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, "node_modules/@turf/polygon-to-line": { "version": "7.3.3", "license": "MIT", @@ -10130,6 +10109,7 @@ }, "node_modules/@turf/projection": { "version": "7.3.3", + "dev": true, "license": "MIT", "dependencies": { "@turf/clone": "7.3.3", @@ -10193,25 +10173,42 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/rhumb-bearing": { - "version": "7.3.3", + "node_modules/@turf/union": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/union/-/union-7.3.5.tgz", + "integrity": "sha512-/FSKhl+LX4+M7L/Trmiln0CDPWS8vCneGnQktt1o5XbCY/zIpH1JdxHEBFXhFZg4beAyXCz0uuxRyW9N/DH+KA==", "license": "MIT", "dependencies": { - "@turf/helpers": "7.3.3", - "@turf/invariant": "7.3.3", + "@turf/helpers": "7.3.5", + "@turf/meta": "7.3.5", "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", "tslib": "^2.8.1" }, "funding": { "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/rhumb-distance": { - "version": "7.3.3", + "node_modules/@turf/union/node_modules/@turf/helpers": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz", + "integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==", "license": "MIT", "dependencies": { - "@turf/helpers": "7.3.3", - "@turf/invariant": "7.3.3", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/union/node_modules/@turf/meta": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.5.tgz", + "integrity": "sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -12168,6 +12165,15 @@ "node": "*" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "dev": true, @@ -25460,6 +25466,16 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/polyclip-ts": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz", + "integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.1.0", + "splaytree-ts": "^1.0.2" + } + }, "node_modules/polygon-splitter": { "version": "0.0.11", "license": "MIT", @@ -30141,6 +30157,12 @@ "dev": true, "license": "MIT" }, + "node_modules/splaytree-ts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz", + "integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==", + "license": "BDS-3-Clause" + }, "node_modules/sprintf-js": { "version": "1.0.3", "dev": true, diff --git a/package.json b/package.json index 0f8e1f0c6..0e032d700 100755 --- a/package.json +++ b/package.json @@ -230,9 +230,7 @@ "@turf/boolean-valid": "^7.2.0", "@turf/destination": "^7.3.3", "@turf/helpers": "^7.2.0", - "@turf/line-intersect": "^7.3.3", - "@turf/point-to-line-distance": "^7.3.3", - "@turf/polygon-to-line": "^7.3.3", + "@turf/union": "^7.3.5", "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", "maplibre-gl": "^5.23.0", diff --git a/plugins/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx new file mode 100644 index 000000000..9ce2d4479 --- /dev/null +++ b/plugins/draw/src/DrawInit.jsx @@ -0,0 +1,89 @@ +import { useEffect } from 'react' +import { EVENTS } from '../../../src/config/events.js' +import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' +import { attachEvents } from './events.js' + +export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginState, services, mapProvider, buttonConfig }) => { + const { eventBus, hints } = services + const { crossHair } = mapState + const isTouchOrKeyboard = ['touch', 'keyboard'].includes(appState.interfaceType) + + useEffect(() => { + const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true + const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false + + if (!mapState.isMapReady || !inModeWhitelist || inExcludeModes) { + return undefined + } + + let isMounted = true + + loadDrawAdapter(mapProvider, { + mapStyle: mapState.mapStyle, + snapLayers: pluginConfig.snapLayers, + pluginConfig, + events: EVENTS, + eventBus + }).then(adapter => { + if (!isMounted) { return } + mapProvider.draw = adapter + pluginState.dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: pluginConfig.snapLayers?.length > 0 }) + eventBus.emit('draw:ready') + }) + + return () => { + isMounted = false + mapProvider.draw?.remove() + mapProvider.draw = null + // Release MoveControl's D-pad if this plugin instance still held it. + mapProvider.activeMoveTarget = null + } + }, [mapState.isMapReady, appState.mode]) + + useEffect(() => { + if (['draw_polygon', 'draw_line'].includes(pluginState.mode) && isTouchOrKeyboard) { + const wasAlreadyVisible = crossHair.isVisible + crossHair.fixAtCenter() + return () => { + // Only hide crosshair if it wasn't visible before drawing AND we're not currently + // in keyboard/touch mode (user might have switched input devices during drawing). + // This ensures crosshair stays visible if user switched to keyboard mid-drawing. + if (!wasAlreadyVisible && !['touch', 'keyboard'].includes(appState.interfaceType)) { + crossHair.hide() + } + } + } + return undefined + }, [pluginState.mode, appState.interfaceType]) + + // Keep the active draw/edit session in sync with the global interface type so + // the touch offset target shows/hides, and the rubber band keeps following the + // map, immediately when the input device changes mid-session (e.g. the user + // starts drawing with the mouse then switches to touch and pans via MoveControl). + useEffect(() => { + if (!['edit_vertex', 'draw_polygon', 'draw_line'].includes(pluginState.mode) || !mapProvider.draw) { + return undefined + } + mapProvider.draw.setInterfaceType(appState.interfaceType) + return undefined + }, [appState.interfaceType, pluginState.mode]) + + // Attach events when plugin state or map provider changes + useEffect(() => { + if (!mapProvider.draw) { + return undefined + } + + return attachEvents({ + appState, + appConfig, + mapState, + mapProvider, + buttonConfig, + pluginState, + events: EVENTS, + eventBus, + hints + }) + }, [mapProvider, appState, pluginState]) +} diff --git a/plugins/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx new file mode 100644 index 000000000..98040bcc3 --- /dev/null +++ b/plugins/draw/src/DrawInit.test.jsx @@ -0,0 +1,184 @@ +import { render, act } from '@testing-library/react' +import { EVENTS } from '../../../src/config/events.js' +import { DrawInit } from './DrawInit.jsx' +import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' +import { attachEvents } from './events.js' + +jest.mock('./adapters/loadDrawAdapter.js', () => ({ loadDrawAdapter: jest.fn() })) +jest.mock('./events.js', () => ({ attachEvents: jest.fn(() => jest.fn()) })) + +const makeProps = (overrides = {}) => { + const adapter = { remove: jest.fn(), setInterfaceType: jest.fn() } + loadDrawAdapter.mockResolvedValue(adapter) + + const props = { + appState: { interfaceType: 'mouse', mode: null }, + appConfig: { id: 'app' }, + mapState: { + isMapReady: true, + mapStyle: { id: 'outdoor' }, + crossHair: { isVisible: false, fixAtCenter: jest.fn(), hide: jest.fn() } + }, + pluginConfig: { snapLayers: ['a'] }, + pluginState: { dispatch: jest.fn(), mode: null }, + services: { eventBus: { emit: jest.fn() } }, + mapProvider: { draw: null }, + buttonConfig: {}, + ...overrides + } + return { props, adapter } +} + +const renderInit = async (props) => { + let result + await act(async () => { result = render() }) + return result +} + +beforeEach(() => jest.clearAllMocks()) + +describe('adapter lifecycle', () => { + test('loads the adapter and announces readiness when the map is ready', async () => { + const { props, adapter } = makeProps() + + await renderInit(props) + + expect(loadDrawAdapter).toHaveBeenCalledWith(props.mapProvider, expect.objectContaining({ + mapStyle: props.mapState.mapStyle, + snapLayers: props.pluginConfig.snapLayers, + events: EVENTS, + eventBus: props.services.eventBus + })) + expect(props.mapProvider.draw).toBe(adapter) + expect(props.pluginState.dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + expect(props.services.eventBus.emit).toHaveBeenCalledWith('draw:ready') + }) + + test('does not load when the map is not ready', async () => { + const { props } = makeProps({ + mapState: { isMapReady: false, mapStyle: {}, crossHair: { isVisible: false, fixAtCenter: jest.fn(), hide: jest.fn() } } + }) + await renderInit(props) + expect(loadDrawAdapter).not.toHaveBeenCalled() + }) + + test('does not load when the app mode is excluded', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'mouse', mode: 'measure' }, + pluginConfig: { snapLayers: [], excludeModes: ['measure'] } + }) + await renderInit(props) + expect(loadDrawAdapter).not.toHaveBeenCalled() + }) + + test('does not load when the app mode is outside the include list', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'mouse', mode: 'other' }, + pluginConfig: { snapLayers: [], includeModes: ['draw'] } + }) + await renderInit(props) + expect(loadDrawAdapter).not.toHaveBeenCalled() + }) + + test('removes the adapter, clears the reference, and releases activeMoveTarget on unmount', async () => { + const { props, adapter } = makeProps() + const result = await renderInit(props) + expect(props.mapProvider.draw).toBe(adapter) + props.mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + + await act(async () => { result.unmount() }) + + expect(adapter.remove).toHaveBeenCalled() + expect(props.mapProvider.draw).toBeNull() + expect(props.mapProvider.activeMoveTarget).toBeNull() + }) + + test('ignores a late-resolving adapter after unmount', async () => { + const { props, adapter } = makeProps() + let resolveAdapter + loadDrawAdapter.mockReturnValue(new Promise((resolve) => { resolveAdapter = resolve })) + + let result + await act(async () => { result = render() }) + await act(async () => { result.unmount() }) + await act(async () => { resolveAdapter(adapter); await Promise.resolve() }) + + expect(props.mapProvider.draw).toBeNull() + expect(props.services.eventBus.emit).not.toHaveBeenCalledWith('draw:ready') + }) +}) + +describe('crosshair', () => { + test('fixes the crosshair at centre while drawing on a touch interface', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'touch', mode: null }, + pluginState: { dispatch: jest.fn(), mode: 'draw_polygon' } + }) + await renderInit(props) + expect(props.mapState.crossHair.fixAtCenter).toHaveBeenCalled() + }) + + test('leaves the crosshair alone when not drawing', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'touch', mode: null }, + pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } + }) + await renderInit(props) + expect(props.mapState.crossHair.fixAtCenter).not.toHaveBeenCalled() + }) + + test('hides the crosshair on cleanup when it was hidden before and the interface has left touch/keyboard', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'touch', mode: null }, + pluginState: { dispatch: jest.fn(), mode: 'draw_polygon' } + }) + const result = await renderInit(props) + expect(props.mapState.crossHair.fixAtCenter).toHaveBeenCalled() + + // Switch away from touch/keyboard, then re-render so the crosshair effect's + // cleanup runs — it reads the (mutated) interface type and hides the crosshair. + props.appState.interfaceType = 'mouse' + await act(async () => { result.rerender() }) + + expect(props.mapState.crossHair.hide).toHaveBeenCalled() + }) +}) + +describe('interface type sync', () => { + test.each(['edit_vertex', 'draw_polygon', 'draw_line'])('pushes the interface type to the adapter in %s mode', async (mode) => { + const { props, adapter } = makeProps({ pluginState: { dispatch: jest.fn(), mode } }) + props.mapProvider.draw = adapter + await renderInit(props) + expect(adapter.setInterfaceType).toHaveBeenCalledWith('mouse') + }) + + test('does nothing outside draw/edit modes', async () => { + const { props, adapter } = makeProps({ pluginState: { dispatch: jest.fn(), mode: null } }) + props.mapProvider.draw = adapter + await renderInit(props) + expect(adapter.setInterfaceType).not.toHaveBeenCalled() + }) +}) + +describe('event attachment', () => { + test('attaches events when a draw adapter is present', async () => { + const { props, adapter } = makeProps() + props.mapProvider.draw = adapter + await renderInit(props) + expect(attachEvents).toHaveBeenCalledWith(expect.objectContaining({ + mapProvider: props.mapProvider, + buttonConfig: props.buttonConfig, + pluginState: props.pluginState, + eventBus: props.services.eventBus, + events: EVENTS + })) + }) + + test('does not attach events without a draw adapter', async () => { + const { props } = makeProps({ + mapState: { isMapReady: false, mapStyle: {}, crossHair: { isVisible: false, fixAtCenter: jest.fn(), hide: jest.fn() } } + }) + await renderInit(props) + expect(attachEvents).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/adapterEvents.js b/plugins/draw/src/adapterEvents.js new file mode 100644 index 000000000..aee336ddc --- /dev/null +++ b/plugins/draw/src/adapterEvents.js @@ -0,0 +1,127 @@ +/** + * What stage of the draw/edit lifecycle a geometry validation call is running at. + * Passed as `context.phase` through validateGeometry (validation/validateGeometry.js) + * to the default rules (validation/rules.js) and the user's `onGeometryChange` callback. + * + * @typedef {'preview' | 'place' | 'create' | 'edit-start' | + * 'commit-add' | 'commit-move' | 'commit-insert' | 'commit-delete'} GeometryChangePhase + * + * 'preview' - live, in-progress feedback (drag / rubber-band); nothing + * has committed yet, and nothing can be vetoed at this stage. + * While drawing, this single check also drives the + * Add-point button (see liveDrawChecks.js) — it isn't only + * a display concern, just never a commit/veto one. + * 'place' - a candidate vertex is genuinely about to be committed + * (a real click/tap/Add-point press), evaluated once, + * synchronously; a HARD_RULES failure vetoes it outright + * and it never appears. Never fires continuously — that's + * 'preview''s job (see above). + * 'create' - a whole new feature just finished being drawn. + * 'edit-start' - an existing feature was just loaded into an edit session; + * nothing has changed yet, this is the baseline check. + * 'commit-add' - a vertex was added while drawing. + * 'commit-move' - a vertex was dragged/nudged while editing. + * 'commit-insert' - a vertex was inserted at a midpoint while editing. + * 'commit-delete' - a vertex was removed while editing. + */ + +/** + * The single event object passed to a user's `onGeometryChange` callback + * (validateGeometry.js) — `onGeometryChange({ feature, ...context })`. Not every + * field is present on every phase; see each field's own doc below. + * + * While drawing, every rubber-band mouse-move invokes this callback ONCE, + * throttled to one call per animation frame, always with `phase: 'preview'` — + * even before any vertex is committed, since a location-based rule can be + * meaningful against a single candidate point. Its verdict is combined + * (internally, in liveDrawChecks.js) with two independently-floored built-in + * checks to drive BOTH the dashed stroke / Done gate AND the Add-point button — + * you don't need to do anything to support both; they share this one call. A + * separate, genuinely momentary call with `phase: 'place'` (see + * GeometryChangePhase) fires only for a real placement attempt, evaluated + * synchronously, never continuously. Earlier versions of this plugin called + * the callback twice per mouse-move (once per phase, for the same instant) — + * that's been unified into the single 'preview' call described here. + * + * @typedef {object} GeometryChangeEvent + * @property {object} feature - GeoJSON feature representing the shape at this + * point. Always present. During 'preview' this is the in-progress feature + * (placed vertices + rubber-band cursor); at every other phase it's the + * committed/candidate feature for that specific change. + * @property {GeometryChangePhase} phase - what stage of the draw/edit lifecycle + * this is. Always present. + * @property {'draw_polygon' | 'draw_line' | 'edit_vertex'} mode - the active draw + * mode. Always present. + * @property {number} [vertexIndex] - index of the vertex being placed, added, + * moved, inserted, or deleted. Present on 'place' and every 'commit-*' phase; + * absent on 'preview', 'create', and 'edit-start' (those have no single vertex + * to point at). + * @property {number} [numVertices] - count of already-committed vertices, + * excluding any in-progress rubber-band cursor point. Present on every + * 'preview' call, with no minimum — including 0, against the very first + * candidate point, before any vertex is committed. Note this is a lower bar + * than the built-in live rules, which stay skipped until the geometry type's + * minimum vertex count is reached (self-intersection/area are meaningless + * below that). + */ + +/** + * Shared adapter event contract. + * + * Both draw adapters (MaplibreDrawAdapter, OLDrawAdapter) expose an on/off bus + * that emits these events. events.js and the api entry points consume them, so + * the names and payload shapes below are the contract between the adapters and + * the framework-agnostic plugin layer. + * + * Engine-specific event names stay with their adapter — e.g. the map-level + * `draw.*` events mapbox-gl-draw fires live in adapters/maplibre/drawEvents.js, + * and the MapLibre adapter normalises them onto this contract. + * + * Payloads: + * CREATE GeoJSON feature that was drawn + * EDIT_FINISH GeoJSON feature after editing + * CANCEL (none) + * VERTEX_SELECTION { index, numVertices } + * VERTEX_CHANGE { numVertices } + * UNDO_CHANGE number — current undo stack length + * UPDATE GeoJSON feature after a vertex operation + * GEOMETRY_CHANGE Two payload shapes share this event: + * - Preview (draw/split live update): the in-progress + * feature itself (has `coordinates`, no `phase`). + * - Commit-level validation: `{ feature, phase, vertexIndex }` + * where phase ∈ 'commit-add' | 'commit-move' | + * 'commit-insert' | 'commit-delete' (see GeometryChangePhase). + * events.js validates these and reverts invalid ones. + * Listeners must guard for the shape they expect. + * INTERFACE_TYPE_CHANGE { interfaceType: 'mouse' | 'touch' | 'keyboard' } + * PLACEMENT_BLOCKED { feature, reason, phase: 'place', mode, vertexIndex } — + * a REAL placement attempt (click/tap/Add-point press) + * was rejected by validatePlacement (hard rule or user + * callback), evaluated once, synchronously; feature is + * the candidate geometry that was refused. Distinct from + * CAN_PLACE_CHANGE below, which is a continuous forecast + * of this same outcome, not an attempt. + * VALIDITY_CHANGE { valid, reason } — the live validity of the displayed + * shape flipped while editing (drag/nudge in progress). + * Drives the Done gate in edit mode, where the displayed + * shape is exactly what Done finishes. Fires on flips only. + * CAN_PLACE_CHANGE { canPlace, reason } — whether placing a vertex at the + * crosshair would be vetoed flipped while drawing. Drives + * the Add-point button. Derived from the same 'preview' + * check that drives the dashed stroke (liveDrawChecks.js), + * not a separate attempt — fires on flips only. + */ +export const ADAPTER_EVENTS = { + CREATE: 'create', + EDIT_FINISH: 'editfinish', + CANCEL: 'cancel', + VERTEX_SELECTION: 'vertexselection', + VERTEX_CHANGE: 'vertexchange', + UNDO_CHANGE: 'undochange', + UPDATE: 'update', + GEOMETRY_CHANGE: 'geometrychange', + INTERFACE_TYPE_CHANGE: 'interfacetypechange', + PLACEMENT_BLOCKED: 'placementblocked', + VALIDITY_CHANGE: 'validitychange', + CAN_PLACE_CHANGE: 'canplacechange' +} diff --git a/plugins/draw/src/adapterEvents.test.js b/plugins/draw/src/adapterEvents.test.js new file mode 100644 index 000000000..4246e33ec --- /dev/null +++ b/plugins/draw/src/adapterEvents.test.js @@ -0,0 +1,20 @@ +import { ADAPTER_EVENTS } from './adapterEvents.js' + +describe('adapter event contract', () => { + test('event names are stable — renaming a value breaks adapter consumers', () => { + expect(ADAPTER_EVENTS).toEqual({ + CREATE: 'create', + EDIT_FINISH: 'editfinish', + CANCEL: 'cancel', + VERTEX_SELECTION: 'vertexselection', + VERTEX_CHANGE: 'vertexchange', + UNDO_CHANGE: 'undochange', + UPDATE: 'update', + GEOMETRY_CHANGE: 'geometrychange', + INTERFACE_TYPE_CHANGE: 'interfacetypechange', + PLACEMENT_BLOCKED: 'placementblocked', + VALIDITY_CHANGE: 'validitychange', + CAN_PLACE_CHANGE: 'canplacechange' + }) + }) +}) diff --git a/plugins/draw/src/adapters/adapterContract.test.js b/plugins/draw/src/adapters/adapterContract.test.js new file mode 100644 index 000000000..3b4ca4f00 --- /dev/null +++ b/plugins/draw/src/adapters/adapterContract.test.js @@ -0,0 +1,48 @@ +import { MaplibreDrawAdapter } from './maplibre/MaplibreDrawAdapter.js' +import { OLDrawAdapter } from './openlayers/OLDrawAdapter.js' + +/** + * The shared adapter contract: events.js, DrawInit and the api entry points are + * written against this surface, so both adapters must implement all of it — even + * where an engine needs only a documented no-op (deleteVertex on MapLibre, + * setFeatureProperty on OpenLayers). A method added to one adapter but not the + * other fails here before it fails at runtime on the other engine. + */ +const CONTRACT_METHODS = [ + 'changeMode', + 'getMode', + 'setInterfaceType', + 'done', + 'cancel', + 'undo', + 'deleteVertex', + 'nudgeSelectedVertex', + 'get', + 'add', + 'delete', + 'deleteAll', + 'setSnapEnabled', + 'setSnapLayers', + 'isSnapEnabled', + 'setFeatureProperty', + 'setGeometryValid', + 'setInvalid', + 'on', + 'off', + 'remove' +] + +describe.each([ + ['MaplibreDrawAdapter', MaplibreDrawAdapter], + ['OLDrawAdapter', OLDrawAdapter] +])('%s adapter contract', (name, Adapter) => { + test.each(CONTRACT_METHODS)('implements %s()', (method) => { + expect(typeof Adapter.prototype[method]).toBe('function') + }) + + test('exposes the _geometryValidator accessor pair (user callback storage)', () => { + const descriptor = Object.getOwnPropertyDescriptor(Adapter.prototype, '_geometryValidator') + expect(typeof descriptor?.get).toBe('function') + expect(typeof descriptor?.set).toBe('function') + }) +}) diff --git a/plugins/draw/src/adapters/loadDrawAdapter.js b/plugins/draw/src/adapters/loadDrawAdapter.js new file mode 100644 index 000000000..cb03341e0 --- /dev/null +++ b/plugins/draw/src/adapters/loadDrawAdapter.js @@ -0,0 +1,14 @@ +export const loadDrawAdapter = async (mapProvider, options) => { + switch (mapProvider.name) { + case 'MapLibreProvider': { + const { MaplibreDrawAdapter } = await import(/* webpackChunkName: "im-draw-ml-adapter" */ './maplibre/MaplibreDrawAdapter.js') + return new MaplibreDrawAdapter(mapProvider, options) + } + case 'OpenLayersProvider': { + const { OLDrawAdapter } = await import(/* webpackChunkName: "im-draw-ol-adapter" */ './openlayers/OLDrawAdapter.js') + return new OLDrawAdapter(mapProvider, options) + } + default: + throw new Error(`No draw adapter available for map provider "${mapProvider.name}"`) + } +} diff --git a/plugins/draw/src/adapters/loadDrawAdapter.test.js b/plugins/draw/src/adapters/loadDrawAdapter.test.js new file mode 100644 index 000000000..3ca6117c1 --- /dev/null +++ b/plugins/draw/src/adapters/loadDrawAdapter.test.js @@ -0,0 +1,53 @@ +import { loadDrawAdapter } from './loadDrawAdapter.js' +import { MaplibreDrawAdapter } from './maplibre/MaplibreDrawAdapter.js' +import { OLDrawAdapter } from './openlayers/OLDrawAdapter.js' + +jest.mock('./maplibre/MaplibreDrawAdapter.js', () => ({ + MaplibreDrawAdapter: jest.fn(function (mapProvider, options) { + this.mapProvider = mapProvider + this.options = options + }) +})) + +jest.mock('./openlayers/OLDrawAdapter.js', () => ({ + OLDrawAdapter: jest.fn(function (mapProvider, options) { + this.mapProvider = mapProvider + this.options = options + }) +})) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('loadDrawAdapter', () => { + test('loads the MapLibre adapter for a MapLibreProvider', async () => { + const mapProvider = { name: 'MapLibreProvider' } + const options = { mapStyle: 'light' } + + const adapter = await loadDrawAdapter(mapProvider, options) + + expect(MaplibreDrawAdapter).toHaveBeenCalledWith(mapProvider, options) + expect(OLDrawAdapter).not.toHaveBeenCalled() + expect(adapter).toBeInstanceOf(MaplibreDrawAdapter) + }) + + test('loads the OpenLayers adapter for an OpenLayersProvider', async () => { + const mapProvider = { name: 'OpenLayersProvider' } + const options = { mapStyle: 'dark' } + + const adapter = await loadDrawAdapter(mapProvider, options) + + expect(OLDrawAdapter).toHaveBeenCalledWith(mapProvider, options) + expect(MaplibreDrawAdapter).not.toHaveBeenCalled() + expect(adapter).toBeInstanceOf(OLDrawAdapter) + }) + + test('throws for an unsupported map provider', async () => { + await expect(loadDrawAdapter({ name: 'SomethingElse' }, {})) + .rejects.toThrow('No draw adapter available for map provider "SomethingElse"') + + expect(MaplibreDrawAdapter).not.toHaveBeenCalled() + expect(OLDrawAdapter).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js new file mode 100644 index 000000000..8b65340b8 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -0,0 +1,367 @@ +import { createMapboxDraw } from './mapboxDraw.js' +import { getSnapInstance, clearSnapState, clearSnapIndicator } from './utils/snapHelpers.js' +import { createEventBus } from '../../utils/eventBus.js' +import { MAPBOX_DRAW_EVENTS, CUSTOM_DRAW_EVENTS, STYLE_DATA_EVENT } from './drawEvents.js' +import { ADAPTER_EVENTS } from '../../adapterEvents.js' +import { createLiveStroke } from '../../validation/liveStroke.js' +import { createLiveDrawChecks } from '../../validation/liveDrawChecks.js' + +const polygonFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates } }) +const lineFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } }) + +// The displayed feature + placed-vertex count for the live stroke check. MapLibre's +// fire() copies the payload onto an Event whose `type` is the event name, so the +// geometry type is clobbered — it comes from the draw mode, or (in edit, where the +// mode covers both shapes) from the coordinate nesting: a polygon's coordinates are +// rings (one level deeper than a line's). Draw-mode coordinates carry a trailing +// rubber-band point; edit-mode coordinates are all committed vertices. +export const displayedShape = (mode, coordinates) => { + if (mode === 'draw_polygon') { + return { feature: polygonFeature(coordinates), numVertices: (coordinates[0]?.length ?? 1) - 1 } + } + if (mode === 'draw_line') { + return { feature: lineFeature(coordinates), numVertices: (coordinates?.length ?? 1) - 1 } + } + if (mode === 'edit_vertex') { + return Array.isArray(coordinates[0]?.[0]) + ? { feature: polygonFeature(coordinates), numVertices: coordinates[0]?.length ?? 0 } + : { feature: lineFeature(coordinates), numVertices: coordinates?.length ?? 0 } + } + return null +} + +/** + * Draw adapter for MapLibre GL. + * + * Wraps the MapboxDraw instance and normalises its map-event-based API into the + * shared adapter interface consumed by events.js, DrawInit, and the api entry points. + * + * Adapter interface (also implemented by OLDrawAdapter): + * changeMode(name, options) + * getMode() + * setInterfaceType(type) + * done() / cancel() / undo() / deleteVertex() + * nudgeSelectedVertex(dx, dy, isLargeStep) + * get(id) / add(feature) / delete(id) / deleteAll() + * setSnapEnabled(bool) / setSnapLayers(layers) / isSnapEnabled() + * setFeatureProperty(id, property, value) / setDrawingPreviewProperty(property, value) + * on(event, handler) / off(event, handler) + * remove() + */ +export class MaplibreDrawAdapter { + constructor (mapProvider, options) { + this._mapProvider = mapProvider + this._map = mapProvider.map + this._bus = createEventBus() + this._editingFeatureId = null + + const { draw, remove } = createMapboxDraw({ + mapStyle: options.mapStyle, + mapProvider, + events: options.events, + eventBus: options.eventBus, + snapLayers: options.snapLayers, + pluginConfig: options.pluginConfig ?? {} + }) + + this._draw = draw + this._cleanupDraw = remove + + // Single owner of the dashed-stroke state: live rubber-band / drag moves feed + // update() (default rules sync, user callback throttled) and committed verdicts + // (events.js) land via setInvalid → set(), so the cached state always mirrors + // the rendered layers. onChange does the actual layer toggle; in edit mode the + // displayed shape is exactly what Done finishes, so validity flips also gate + // the Done button (events.js dispatches them). + this._liveStroke = createLiveStroke({ + onChange: (invalid, reason) => { + this._applyStrokeInvalid(invalid) + if (this._draw.getMode() === 'edit_vertex') { + this._bus.emit(ADAPTER_EVENTS.VALIDITY_CHANGE, { valid: !invalid, reason }) + } + } + }) + + // Draw-mode-only: computes both the stroke/Done verdict and the Add-point + // verdict from a SINGLE throttled call to the user's callback per rubber-band + // move (see liveDrawChecks.js for why), and is flip-guarded itself. The + // stroke verdict still routes through _liveStroke.set() — that instance is + // also driven independently by edit mode and must stay the single source of + // truth for the rendered stroke across mode switches. The Add-point verdict + // has no such cross-mode instance to stay in sync with, so it emits directly. + this._liveDrawChecks = createLiveDrawChecks({ + onStrokeChange: (invalid, reason) => this._liveStroke.set(invalid, reason), + onPlaceChange: (vetoed, reason) => this._bus.emit(ADAPTER_EVENTS.CAN_PLACE_CHANGE, { canPlace: !vetoed, reason }) + }) + + // Normalise ML map events → the shared adapter event contract (adapterEvents.js). + // The OL adapter emits the same contract directly from OLDrawManager. + this._mapHandlers = { + create: (e) => this._bus.emit(ADAPTER_EVENTS.CREATE, e.features[0]), + editfinish: (e) => this._bus.emit(ADAPTER_EVENTS.EDIT_FINISH, e.features[0]), + cancel: () => this._bus.emit(ADAPTER_EVENTS.CANCEL), + // Normalise typo: the ML modes fire numVertecies, the contract uses numVertices + vertexselection: (e) => this._bus.emit(ADAPTER_EVENTS.VERTEX_SELECTION, { ...e, numVertices: e.numVertecies }), + vertexchange: (e) => this._bus.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { ...e, numVertices: e.numVertecies }), + undochange: (e) => this._bus.emit(ADAPTER_EVENTS.UNDO_CHANGE, e.length), + update: (e) => this._bus.emit(ADAPTER_EVENTS.UPDATE, e.features[0]), + geometrychange: (e) => { + // Phase-less events are rubber-band moves carrying the displayed feature + // (placed vertices + cursor) — they drive the live invalid stroke, and are + // cached for setDrawingPreviewProperty (the in-progress feature has no + // stable id yet — only assigned once drawing actually finishes — so it + // can't be targeted via setFeatureProperty). + if (!e?.phase) { + this._updateLiveStroke(e) + this._currentDrawEvent = e + } + this._bus.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, e) + }, + placementblocked: (e) => this._bus.emit(ADAPTER_EVENTS.PLACEMENT_BLOCKED, e), + interfacetypechange: (e) => this._bus.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: e.interfaceType }), + modechange: (e) => this._handleModeChange(e), + styledata: () => this._handleStyleData() + } + + this._map.on(MAPBOX_DRAW_EVENTS.CREATE, this._mapHandlers.create) + this._map.on(CUSTOM_DRAW_EVENTS.EDIT_FINISH, this._mapHandlers.editfinish) + this._map.on(CUSTOM_DRAW_EVENTS.CANCEL, this._mapHandlers.cancel) + this._map.on(CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, this._mapHandlers.vertexselection) + this._map.on(CUSTOM_DRAW_EVENTS.VERTEX_CHANGE, this._mapHandlers.vertexchange) + this._map.on(CUSTOM_DRAW_EVENTS.UNDO_CHANGE, this._mapHandlers.undochange) + this._map.on(MAPBOX_DRAW_EVENTS.UPDATE, this._mapHandlers.update) + this._map.on(CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, this._mapHandlers.geometrychange) + this._map.on(CUSTOM_DRAW_EVENTS.PLACEMENT_BLOCKED, this._mapHandlers.placementblocked) + this._map.on(CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE, this._mapHandlers.interfacetypechange) + this._map.on(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) + this._map.on(STYLE_DATA_EVENT, this._mapHandlers.styledata) + } + + changeMode (name, options = {}) { + if (name === 'edit_vertex') { + this._editingFeatureId = options.featureId ?? null + } + // A fresh draw always starts with a solid stroke and a placeable crosshair; + // the live checks own both from here. + if (name === 'draw_polygon' || name === 'draw_line') { + this._liveStroke.set(false) + this._liveDrawChecks.reset() + } + this._draw.changeMode(name, options) + // The underlying mapbox-gl-draw control's public changeMode API is silent by + // default (it never fires 'draw.modechange'), so every mode change requested + // through this adapter must drive the same cleanup manually. + this._handleModeChange({ mode: name }) + } + + // Live invalid-stroke driver: called on every rubber-band move (draw) and vertex + // drag / nudge (edit) with the displayed feature. Edit mode has no Add-point + // gate, so it goes straight through the live-stroke controller as before. Draw + // mode routes through _liveDrawChecks instead, which computes both the stroke + // and Add-point verdicts from one throttled user-callback call (see + // liveDrawChecks.js). + _updateLiveStroke (e) { + if (!e?.coordinates) { return } + const mode = this._draw.getMode() + const shape = displayedShape(mode, e.coordinates) + if (!shape) { return } + if (mode === 'draw_polygon' || mode === 'draw_line') { + this._liveDrawChecks.update({ feature: shape.feature, numVertices: shape.numVertices, context: { mode }, onGeometryChange: this._geometryValidator }) + } else { + this._liveStroke.update({ ...shape, context: { mode }, onGeometryChange: this._geometryValidator }) + } + } + + getMode () { return this._draw.getMode() } + + setInterfaceType (type) { + this._map.fire(CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: type }) + } + + done () { + this._mapProvider.undoStack?.clear() + const mode = this._draw.getMode() + if (mode === 'edit_vertex' && this._editingFeatureId) { + // Leaving edit_vertex here — hide immediately rather than waiting on the + // async disable() the EDIT_FINISH handler fires later (see changeMode()). + this._handleModeChange({ mode: 'disabled' }) + this._map.fire(CUSTOM_DRAW_EVENTS.EDIT_FINISH, { features: [this._draw.get(this._editingFeatureId)] }) + return + } + if (mode === 'draw_polygon' || mode === 'draw_line') { + this._draw.changeMode('disabled') + this._handleModeChange({ mode: 'disabled' }) + } + } + + cancel () { + this._mapProvider.undoStack?.clear() + const mode = this._draw.getMode() + // trash() only belongs to an in-progress, never-committed draw — it deletes + // whatever's selected. For edit_vertex, events.js's handleCancel has already + // restored the original feature via draw.add() before this runs; trash() + // here would instead run mapbox-gl-draw's direct_select onTrash on the + // mode's own live state (removing the selected vertex, or — if the + // in-progress edit happened to leave the shape invalid — deleting the + // entire feature), silently discarding the just-restored original. + if (mode === 'draw_polygon' || mode === 'draw_line') { + this._draw.trash() + } + this._draw.changeMode('disabled') + this._handleModeChange({ mode: 'disabled' }) + } + + undo () { + this._map.fire(CUSTOM_DRAW_EVENTS.UNDO) + } + + // MoveControl's D-pad, routed here via mapProvider.activeMoveTarget (see + // events.js) once a vertex is selected. Bridged into the running edit mode the + // same way setInterfaceType is, since the adapter has no direct reference to the + // mode's live state. + nudgeSelectedVertex (dx, dy, isLargeStep) { + this._map.fire(CUSTOM_DRAW_EVENTS.NUDGE_VERTEX, { dx, dy, isLargeStep }) + } + + // Record the current geometry validity so the draw mode can block finish gestures + // (double-click / click-to-close) while the in-progress shape is invalid. + setGeometryValid (valid) { + this._map._drawGeometryValid = valid + } + + // The api entry points assign the active user validator to the adapter; store it + // on the map (like _drawGeometryValid) so modes can veto placements synchronously. + set _geometryValidator (fn) { this._map._drawGeometryValidator = fn } + get _geometryValidator () { return this._map._drawGeometryValidator } + + // Committed-verdict write (events.js, edit mode): routed through the live-stroke + // controller so its cached state stays in sync with the rendered layers. + setInvalid (invalid) { + this._liveStroke.set(invalid) + } + + // Toggle the active shape's stroke between solid (valid) and dashed (invalid) by + // swapping which of the two overlaid stroke layers is visible; the fill is hidden + // while invalid so the shape reads as an outline only. Only the live-stroke + // controller calls this — everything else goes through setInvalid. + _applyStrokeInvalid (invalid) { + this._setLayerVisibility('stroke-active', !invalid) + this._setLayerVisibility('stroke-active-invalid', invalid) + this._setLayerVisibility('fill-active', !invalid) + } + + _setLayerVisibility (id, visible) { + ['hot', 'cold'].forEach((suffix) => { + const layerId = `${id}.${suffix}` + if (this._map.getLayer(layerId)) { + this._map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none') + } + }) + } + + deleteVertex () { + // Intentionally a no-op on MapLibre. The shared events layer calls draw.deleteVertex() + // from the delete-point button, but the ML edit mode already handles deletion itself: + // via the keyboard (Backspace/Delete) and its own window-click listener matching + // deleteVertexButtonId (see editVertexMode.onButtonClick). Deleting here too would + // double-delete. The OL adapter implements this for real; here it only satisfies the + // shared adapter interface. + } + + get (id) { return this._draw.get(id) } + add (feature) { return this._draw.add(feature) } + delete (id) { this._draw.delete(id) } + deleteAll () { this._draw.deleteAll() } + + setSnapEnabled (bool) { + this._mapProvider.snapEnabled = bool + const snap = getSnapInstance(this._map) + if (snap?.setSnapStatus) { snap.setSnapStatus(bool) } + if (!bool && snap) { + clearSnapState(snap) + if (this._map.getLayer('snap-helper-circle')) { + this._map.setLayoutProperty('snap-helper-circle', 'visibility', 'none') + } + } + } + + setSnapLayers (layers) { + const snap = getSnapInstance(this._map) + if (snap?.setSnapLayers) { + snap.setSnapLayers(layers) + } else if (layers) { + this._map._pendingSnapLayers = layers + } else { + // No action + } + } + + isSnapEnabled () { + return this._mapProvider.snapEnabled === true + } + + setFeatureProperty (id, property, value) { + this._draw.setFeatureProperty(id, property, value) + } + + // Tag the feature currently being drawn (rubber-band, not yet created — so it + // has no stable id to target via setFeatureProperty) with a property, and + // re-render so the change is visible immediately. Used for live preview styling + // while drawing, e.g. split's valid/invalid line colour. A no-op once nothing + // has been drawn yet this session, or outside draw_polygon/draw_line. + setDrawingPreviewProperty (property, value) { + const e = this._currentDrawEvent + if (e?.properties) { + e.properties[property] = value + } + e?.ctx?.store?.render() + } + + on (type, handler) { + this._bus.on(type, handler) + } + + off (type, handler) { + this._bus.off(type, handler) + } + + _handleModeChange (e) { + const DRAW_MODES = new Set(['draw_polygon', 'draw_line', 'edit_vertex']) + if (!DRAW_MODES.has(e.mode)) { + clearSnapIndicator(getSnapInstance(this._map), this._map) + } + } + + // Keeps draw layers on top after MapLibre style reloads + _handleStyleData () { + // A style reload re-adds the draw layers with their spec-default visibility + // (solid stroke shown, dashed hidden) — re-assert the cached stroke state so + // an invalid shape stays dashed across the reload. + this._liveStroke.refresh() + const layers = this._map.getStyle().layers || [] + if (!layers.length || layers[layers.length - 1].source?.startsWith('mapbox-gl-draw')) { + return + } + layers + .filter(l => l.source?.startsWith('mapbox-gl-draw')) + .forEach(l => this._map.moveLayer(l.id)) + } + + remove () { + this._map.off(MAPBOX_DRAW_EVENTS.CREATE, this._mapHandlers.create) + this._map.off(CUSTOM_DRAW_EVENTS.EDIT_FINISH, this._mapHandlers.editfinish) + this._map.off(CUSTOM_DRAW_EVENTS.CANCEL, this._mapHandlers.cancel) + this._map.off(CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, this._mapHandlers.vertexselection) + this._map.off(CUSTOM_DRAW_EVENTS.VERTEX_CHANGE, this._mapHandlers.vertexchange) + this._map.off(CUSTOM_DRAW_EVENTS.UNDO_CHANGE, this._mapHandlers.undochange) + this._map.off(MAPBOX_DRAW_EVENTS.UPDATE, this._mapHandlers.update) + this._map.off(CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, this._mapHandlers.geometrychange) + this._map.off(CUSTOM_DRAW_EVENTS.PLACEMENT_BLOCKED, this._mapHandlers.placementblocked) + this._map.off(CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE, this._mapHandlers.interfacetypechange) + this._map.off(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) + this._map.off(STYLE_DATA_EVENT, this._mapHandlers.styledata) + this._liveStroke.destroy() + this._liveDrawChecks.destroy() + this._cleanupDraw() + } +} diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js new file mode 100644 index 000000000..f1c0b0d0a --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -0,0 +1,799 @@ +import { createMapboxDraw } from './mapboxDraw.js' +import { getSnapInstance, clearSnapState, clearSnapIndicator } from './utils/snapHelpers.js' +import { createEventBus } from '../../utils/eventBus.js' +import { MAPBOX_DRAW_EVENTS, CUSTOM_DRAW_EVENTS, STYLE_DATA_EVENT } from './drawEvents.js' +import { MaplibreDrawAdapter, displayedShape } from './MaplibreDrawAdapter.js' + +jest.mock('./mapboxDraw.js', () => ({ createMapboxDraw: jest.fn() })) +jest.mock('./utils/snapHelpers.js', () => ({ + getSnapInstance: jest.fn(), + clearSnapState: jest.fn(), + clearSnapIndicator: jest.fn() +})) +jest.mock('../../utils/eventBus.js', () => ({ createEventBus: jest.fn() })) + +const SNAP_LAYER = 'snap-helper-circle' + +const onHandler = (map, event) => map.on.mock.calls.find(([name]) => name === event)?.[1] + +const setup = () => { + const map = { + on: jest.fn(), + off: jest.fn(), + fire: jest.fn(), + getLayer: jest.fn(() => null), + setLayoutProperty: jest.fn(), + getStyle: jest.fn(() => ({ layers: [] })), + moveLayer: jest.fn() + } + const undoStack = { clear: jest.fn() } + const mapProvider = { map, undoStack, snapEnabled: false } + const draw = { + changeMode: jest.fn(), + getMode: jest.fn(() => 'disabled'), + get: jest.fn((id) => ({ id })), + add: jest.fn(), + delete: jest.fn(), + deleteAll: jest.fn(), + trash: jest.fn(), + setFeatureProperty: jest.fn() + } + const removeDraw = jest.fn() + createMapboxDraw.mockReturnValue({ draw, remove: removeDraw }) + + const bus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() } + createEventBus.mockReturnValue(bus) + + const options = { + mapStyle: 'light', + events: { MAP_SET_STYLE: 'mss' }, + eventBus: { on: jest.fn() }, + snapLayers: ['layer-a'] + } + const adapter = new MaplibreDrawAdapter(mapProvider, options) + + return { adapter, map, mapProvider, draw, removeDraw, bus, undoStack, options } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('construction', () => { + test('creates the MapboxDraw control with the provided options', () => { + const { options } = setup() + expect(createMapboxDraw).toHaveBeenCalledWith({ + mapStyle: 'light', + mapProvider: expect.any(Object), + events: options.events, + eventBus: options.eventBus, + snapLayers: ['layer-a'], + pluginConfig: {} + }) + }) + + test('forwards a provided pluginConfig through to createMapboxDraw', () => { + const map = { + on: jest.fn(), + off: jest.fn(), + fire: jest.fn(), + getLayer: jest.fn(() => null), + setLayoutProperty: jest.fn(), + getStyle: jest.fn(() => ({ layers: [] })), + moveLayer: jest.fn() + } + const mapProvider = { map, undoStack: { clear: jest.fn() }, snapEnabled: false } + createMapboxDraw.mockReturnValue({ draw: { changeMode: jest.fn(), getMode: jest.fn() }, remove: jest.fn() }) + createEventBus.mockReturnValue({ on: jest.fn(), off: jest.fn(), emit: jest.fn() }) + + const pluginConfig = { shapeStroke: '#custom' } + // eslint-disable-next-line no-new + new MaplibreDrawAdapter(mapProvider, { + mapStyle: 'light', + events: { MAP_SET_STYLE: 'mss' }, + eventBus: { on: jest.fn() }, + snapLayers: ['layer-a'], + pluginConfig + }) + + expect(createMapboxDraw).toHaveBeenCalledWith(expect.objectContaining({ pluginConfig })) + }) + + test('subscribes to every MapLibre draw event', () => { + const { map } = setup() + const subscribed = map.on.mock.calls.map(([name]) => name) + expect(subscribed).toEqual(expect.arrayContaining([ + MAPBOX_DRAW_EVENTS.CREATE, MAPBOX_DRAW_EVENTS.UPDATE, MAPBOX_DRAW_EVENTS.MODE_CHANGE, + CUSTOM_DRAW_EVENTS.EDIT_FINISH, CUSTOM_DRAW_EVENTS.CANCEL, CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, + CUSTOM_DRAW_EVENTS.VERTEX_CHANGE, CUSTOM_DRAW_EVENTS.UNDO_CHANGE, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, + STYLE_DATA_EVENT + ])) + }) +}) + +describe('map event normalisation', () => { + test('create/editfinish/update forward the first feature', () => { + const { map, bus } = setup() + const feature = { id: 'f1' } + + onHandler(map, MAPBOX_DRAW_EVENTS.CREATE)({ features: [feature] }) + onHandler(map, CUSTOM_DRAW_EVENTS.EDIT_FINISH)({ features: [feature] }) + onHandler(map, MAPBOX_DRAW_EVENTS.UPDATE)({ features: [feature] }) + + expect(bus.emit).toHaveBeenCalledWith('create', feature) + expect(bus.emit).toHaveBeenCalledWith('editfinish', feature) + expect(bus.emit).toHaveBeenCalledWith('update', feature) + }) + + test('cancel forwards with no payload', () => { + const { map, bus } = setup() + onHandler(map, CUSTOM_DRAW_EVENTS.CANCEL)() + expect(bus.emit).toHaveBeenCalledWith('cancel') + }) + + test('vertexselection/vertexchange normalise the numVertecies typo', () => { + const { map, bus } = setup() + + onHandler(map, CUSTOM_DRAW_EVENTS.VERTEX_SELECTION)({ numVertecies: 3, index: 1 }) + onHandler(map, CUSTOM_DRAW_EVENTS.VERTEX_CHANGE)({ numVertecies: 2 }) + + expect(bus.emit).toHaveBeenCalledWith('vertexselection', expect.objectContaining({ numVertices: 3, index: 1 })) + expect(bus.emit).toHaveBeenCalledWith('vertexchange', expect.objectContaining({ numVertices: 2 })) + }) + + test('undochange forwards the stack length', () => { + const { map, bus } = setup() + onHandler(map, CUSTOM_DRAW_EVENTS.UNDO_CHANGE)({ length: 4 }) + expect(bus.emit).toHaveBeenCalledWith('undochange', 4) + }) + + test('geometrychange forwards the raw event', () => { + const { map, bus } = setup() + const e = { type: 'Polygon' } + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(e) + expect(bus.emit).toHaveBeenCalledWith('geometrychange', e) + }) + + test('placementblocked forwards the raw event', () => { + const { map, bus } = setup() + const e = { phase: 'place', reason: 'outside region' } + onHandler(map, CUSTOM_DRAW_EVENTS.PLACEMENT_BLOCKED)(e) + expect(bus.emit).toHaveBeenCalledWith('placementblocked', e) + }) +}) + +describe('displayedShape helper', () => { + test('builds a polygon feature from draw_polygon mode', () => { + const result = displayedShape('draw_polygon', [[[0, 0], [10, 0], [10, 10], [0, 0]]]) + expect(result?.feature?.type).toBe('Feature') + expect(result?.feature?.geometry?.type).toBe('Polygon') + expect(result?.numVertices).toBe(3) + }) + + test('builds a line feature from draw_line mode', () => { + const result = displayedShape('draw_line', [[0, 0], [10, 0], [10, 10]]) + expect(result?.feature?.type).toBe('Feature') + expect(result?.feature?.geometry?.type).toBe('LineString') + expect(result?.numVertices).toBe(2) + }) + + test('detects polygon vs line in edit_vertex mode from coordinate nesting', () => { + const polygon = displayedShape('edit_vertex', [[[0, 0], [10, 0], [10, 10], [0, 0]]]) + expect(polygon?.feature?.geometry?.type).toBe('Polygon') + const line = displayedShape('edit_vertex', [[0, 0], [10, 0], [10, 10]]) + expect(line?.feature?.geometry?.type).toBe('LineString') + }) + + test('returns null for an unknown mode', () => { + expect(displayedShape('unknown_mode', [[0, 0], [10, 0]])).toBeNull() + }) +}) + +describe('live invalid stroke (draw mode)', () => { + // Displayed rings (placed vertices + cursor) as the listener really receives them: + // MapLibre's fire() wraps the gl-draw feature in an Event whose `type` is the + // event name — the geometry type is clobbered, only `coordinates` survives. + const bowtie = { type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 10], [10, 0], [0, 10]]] } + const square = { type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10]]] } + + const drawPolygonSetup = () => { + const fixture = setup() + fixture.draw.getMode.mockReturnValue('draw_polygon') + fixture.map.getLayer.mockReturnValue({}) + return fixture + } + + test('a self-intersecting displayed ring turns the stroke dashed; simple again restores it', () => { + const { map } = drawPolygonSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + + fire(bowtie) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + + map.setLayoutProperty.mockClear() + fire(square) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'none') + }) + + test('only restyles when the invalid state flips, not on every move', () => { + const { map } = drawPolygonSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + fire(bowtie) + const callsAfterFlip = map.setLayoutProperty.mock.calls.length + fire(bowtie) + fire(bowtie) + expect(map.setLayoutProperty.mock.calls.length).toBe(callsAfterFlip) + }) + + test('commit-level (has a phase) events do not drive the stroke; events.js owns those', () => { + const { map } = drawPolygonSetup() + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ feature: bowtie, phase: 'commit-add', vertexIndex: 3 }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('lines never go dashed from the live check', () => { + const { map, draw } = drawPolygonSetup() + draw.getMode.mockReturnValue('draw_line') + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ type: 'draw.geometrychange', coordinates: [[0, 0], [10, 10], [10, 0], [0, 10]] }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('the rubber band sitting on the just-placed vertex never reads as a crossing', () => { + const { map } = drawPolygonSetup() + // 3 placed + rubber band duplicating the last placed vertex. + const justPlaced = { type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [10, 10]]] } + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(justPlaced) + expect(map.setLayoutProperty).not.toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + }) + + test('a placement-vetoing path disables Add point; a legal one re-enables it', () => { + const { map, bus } = drawPolygonSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + // Open drawn path crosses itself → placing at the crosshair would be vetoed. + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [2, 2], [2, 0], [0, 2]]] }) + expect(bus.emit).toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: false, reason: expect.any(String) })) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [2, 0], [2, 2], [0, 2]]] }) + expect(bus.emit).toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: true })) + }) + + test('a red stroke via the closing edge alone keeps Add point enabled', () => { + const { map, bus } = drawPolygonSetup() + // Only the implicit closing edge crosses: stroke dashed, but the placement is legal. + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ type: 'draw.geometrychange', coordinates: [[[0, 0], [2, 0], [0, 2], [2, 2]]] }) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + expect(bus.emit).not.toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: false })) + }) + + test('the user callback runs ONCE per frame in draw mode, with phase preview, driving both the stroke and Add point from that single call', () => { + jest.useFakeTimers() + const { map, bus } = drawPolygonSetup() + map._drawGeometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + fire(square) + expect(map._drawGeometryValidator).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(map._drawGeometryValidator).toHaveBeenCalledTimes(1) // not once per gate + expect(map._drawGeometryValidator).toHaveBeenCalledWith(expect.objectContaining({ phase: 'preview' })) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + expect(bus.emit).toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: false, reason: 'outside region' })) + jest.useRealTimers() + }) + + test('the user callback runs even before any vertex is placed, gating both the stroke and Add point from the very first candidate point', () => { + jest.useFakeTimers() + const { map, bus } = drawPolygonSetup() + map._drawGeometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + // Zero committed vertices — coordinates is just the rubber-band cursor point. + fire({ type: 'draw.geometrychange', coordinates: [[[5, 5]]] }) + jest.runAllTimers() + expect(map._drawGeometryValidator).toHaveBeenCalledWith(expect.objectContaining({ numVertices: 0, phase: 'preview' })) + expect(bus.emit).toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: false, reason: 'outside region' })) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + jest.useRealTimers() + }) + + test('entering a draw mode resets the stroke to solid', () => { + const { adapter, map } = drawPolygonSetup() + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(bowtie) // dashed + map.setLayoutProperty.mockClear() + adapter.changeMode('draw_polygon') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'none') + // ...and the flip guard is reset with it, so the next crossing restyles again. + map.setLayoutProperty.mockClear() + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(bowtie) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + }) +}) + +describe('live invalid stroke (edit mode)', () => { + const editSetup = () => { + const fixture = setup() + fixture.draw.getMode.mockReturnValue('edit_vertex') + fixture.map.getLayer.mockReturnValue({}) + return fixture + } + + test('dragging a polygon vertex into a crossing turns the stroke dashed, and back', () => { + const { map } = editSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + // Edit payloads have the type clobbered too — polygon detected from ring nesting. + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 10], [10, 0], [0, 10]]] }) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + map.setLayoutProperty.mockClear() + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10]]] }) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'visible') + }) + + test('validity flips while editing also gate the Done button', () => { + const { map, bus } = editSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 10], [10, 0], [0, 10]]] }) + expect(bus.emit).toHaveBeenCalledWith('validitychange', expect.objectContaining({ valid: false, reason: expect.any(String) })) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10]]] }) + expect(bus.emit).toHaveBeenCalledWith('validitychange', expect.objectContaining({ valid: true })) + // Add point is a draw-mode concern — never driven from edit mode. + expect(bus.emit).not.toHaveBeenCalledWith('canplacechange', expect.anything()) + }) + + test('lines never go dashed from the default rules while editing', () => { + const { map } = editSetup() + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ type: 'draw.geometrychange', coordinates: [[0, 0], [10, 10], [10, 0], [0, 10]] }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('the user callback runs throttled during an edit drag', () => { + jest.useFakeTimers() + const { map } = editSetup() + map._drawGeometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10]]] }) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 11]]] }) + expect(map._drawGeometryValidator).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(map._drawGeometryValidator).toHaveBeenCalledTimes(1) // trailing edge only + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + jest.useRealTimers() + }) +}) + +describe('_geometryValidator accessor', () => { + test('stores the validator on the map for modes to read, and reads it back', () => { + const { adapter, map } = setup() + const validator = () => true + adapter._geometryValidator = validator + expect(map._drawGeometryValidator).toBe(validator) + expect(adapter._geometryValidator).toBe(validator) + }) +}) + +describe('changeMode', () => { + test('records the editing feature id when entering edit_vertex', () => { + const { adapter, draw } = setup() + adapter.changeMode('edit_vertex', { featureId: 'f9' }) + expect(draw.changeMode).toHaveBeenCalledWith('edit_vertex', { featureId: 'f9' }) + }) + + test('defaults the editing feature id to null when omitted', () => { + const { adapter, draw } = setup() + adapter.changeMode('edit_vertex', {}) + draw.getMode.mockReturnValue('edit_vertex') + adapter.done() + // no editing feature id → no editfinish fired + expect(draw.changeMode).toHaveBeenCalledWith('edit_vertex', {}) + }) + + test('passes through non-edit modes with default options', () => { + const { adapter, draw } = setup() + adapter.changeMode('draw_polygon') + expect(draw.changeMode).toHaveBeenCalledWith('draw_polygon', {}) + }) +}) + +describe('setGeometryValid', () => { + test('records validity on the map for the draw mode to read', () => { + const { adapter, map } = setup() + adapter.setGeometryValid(false) + expect(map._drawGeometryValid).toBe(false) + adapter.setGeometryValid(true) + expect(map._drawGeometryValid).toBe(true) + }) +}) + +describe('setInvalid', () => { + test('shows the dashed stroke and hides the solid stroke and fill when invalid', () => { + const { adapter, map } = setup() + map.getLayer.mockReturnValue({}) // every layer exists + adapter.setInvalid(true) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.cold', 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.cold', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('fill-active.hot', 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith('fill-active.cold', 'visibility', 'none') + }) + + test('restores the solid stroke and fill when valid again', () => { + const { adapter, map } = setup() + map.getLayer.mockReturnValue({}) + adapter.setInvalid(true) + map.setLayoutProperty.mockClear() + adapter.setInvalid(false) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith('fill-active.hot', 'visibility', 'visible') + }) + + test('writes are flip-guarded: repeating the same state does not touch the layers', () => { + const { adapter, map } = setup() + map.getLayer.mockReturnValue({}) + adapter.setInvalid(false) // already solid — no-op + expect(map.setLayoutProperty).not.toHaveBeenCalled() + adapter.setInvalid(true) + map.setLayoutProperty.mockClear() + adapter.setInvalid(true) // already dashed — no-op + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('skips layers that are not present on the map', () => { + const { adapter, map } = setup() + map.getLayer.mockReturnValue(null) + adapter.setInvalid(true) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) +}) + +describe('simple delegations', () => { + test('getMode delegates to the draw control', () => { + const { adapter, draw } = setup() + draw.getMode.mockReturnValue('draw_line') + expect(adapter.getMode()).toBe('draw_line') + }) + + test('setInterfaceType fires the interface-type-change event', () => { + const { adapter, map } = setup() + adapter.setInterfaceType('keyboard') + expect(map.fire).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: 'keyboard' }) + }) + + test('undo fires the undo event', () => { + const { adapter, map } = setup() + adapter.undo() + expect(map.fire).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.UNDO) + }) + + test('nudgeSelectedVertex fires the nudge-vertex event with the given delta and step size', () => { + const { adapter, map } = setup() + adapter.nudgeSelectedVertex(1, 0, true) + expect(map.fire).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.NUDGE_VERTEX, { dx: 1, dy: 0, isLargeStep: true }) + }) + + test('deleteVertex is a no-op', () => { + const { adapter, draw, map } = setup() + expect(() => adapter.deleteVertex()).not.toThrow() + expect(draw.changeMode).not.toHaveBeenCalled() + expect(map.fire).not.toHaveBeenCalled() + }) + + test('feature store methods delegate to the draw control', () => { + const { adapter, draw } = setup() + adapter.get('a') + adapter.add({ id: 'b' }) + adapter.delete('c') + adapter.deleteAll() + adapter.setFeatureProperty('d', 'p', 1) + + expect(draw.get).toHaveBeenCalledWith('a') + expect(draw.add).toHaveBeenCalledWith({ id: 'b' }) + expect(draw.delete).toHaveBeenCalledWith('c') + expect(draw.deleteAll).toHaveBeenCalled() + expect(draw.setFeatureProperty).toHaveBeenCalledWith('d', 'p', 1) + }) + + test('setDrawingPreviewProperty tags the in-progress feature and re-renders', () => { + const { adapter, map } = setup() + const render = jest.fn() + const drawEvent = { coordinates: [[0, 0], [1, 1]], properties: {}, ctx: { store: { render } } } + // Phase-less events are rubber-band moves — cached so setDrawingPreviewProperty + // has something to tag (the in-progress feature has no id yet to look up). + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(drawEvent) + + adapter.setDrawingPreviewProperty('splitter', 'valid') + + expect(drawEvent.properties.splitter).toBe('valid') + expect(render).toHaveBeenCalled() + }) + + test('setDrawingPreviewProperty tolerates nothing having been drawn yet', () => { + const { adapter } = setup() + expect(() => adapter.setDrawingPreviewProperty('splitter', 'valid')).not.toThrow() + }) + + test('setDrawingPreviewProperty ignores commit-level (has a phase) events', () => { + const { adapter, map } = setup() + const render = jest.fn() + // A commit event (has a phase) must not become the cached preview target — it + // carries a `feature`, not the live `properties` object rubber-band moves do. + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ feature: {}, phase: 'commit-add', ctx: { store: { render } } }) + + expect(() => adapter.setDrawingPreviewProperty('splitter', 'valid')).not.toThrow() + expect(render).not.toHaveBeenCalled() + }) + + test('on/off delegate to the internal event bus', () => { + const { adapter, bus } = setup() + const handler = jest.fn() + adapter.on('create', handler) + adapter.off('create', handler) + expect(bus.on).toHaveBeenCalledWith('create', handler) + expect(bus.off).toHaveBeenCalledWith('create', handler) + }) + + test('isSnapEnabled reflects the provider flag', () => { + const { adapter, mapProvider } = setup() + expect(adapter.isSnapEnabled()).toBe(false) + mapProvider.snapEnabled = true + expect(adapter.isSnapEnabled()).toBe(true) + }) +}) + +describe('done', () => { + test('clears the undo stack and fires editfinish when editing a vertex', () => { + const { adapter, map, draw, undoStack } = setup() + adapter.changeMode('edit_vertex', { featureId: 'f1' }) + draw.getMode.mockReturnValue('edit_vertex') + + adapter.done() + + expect(undoStack.clear).toHaveBeenCalled() + expect(map.fire).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.EDIT_FINISH, { features: [{ id: 'f1' }] }) + expect(draw.changeMode).not.toHaveBeenCalledWith('disabled') + }) + + test('disables the control when finishing a draw mode', () => { + const { adapter, draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + adapter.done() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + + draw.getMode.mockReturnValue('draw_line') + adapter.done() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) + + test('does nothing further for edit_vertex without an editing feature id', () => { + const { adapter, draw, map } = setup() + draw.getMode.mockReturnValue('edit_vertex') + adapter.done() + expect(map.fire).not.toHaveBeenCalled() + expect(draw.changeMode).not.toHaveBeenCalled() + }) +}) + +describe('cancel', () => { + test('cancelling an in-progress draw clears the undo stack, trashes the sketch and disables the control', () => { + const { adapter, draw, undoStack } = setup() + draw.getMode.mockReturnValue('draw_polygon') + adapter.cancel() + expect(undoStack.clear).toHaveBeenCalled() + expect(draw.trash).toHaveBeenCalled() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) + + test('cancelling a draw_line session also trashes the sketch', () => { + const { adapter, draw } = setup() + draw.getMode.mockReturnValue('draw_line') + adapter.cancel() + expect(draw.trash).toHaveBeenCalled() + }) + + // Regression: events.js's handleCancel already restores the original feature + // via draw.add() before calling this. trash() runs mapbox-gl-draw's + // direct_select onTrash handler, which operates on the mode's own live + // state — removing the selected vertex or (if the in-progress edit left the + // shape invalid) deleting the whole feature — silently discarding the + // just-restored original. See MaplibreDrawAdapter.js's cancel() comment. + test('cancelling an edit session does NOT trash — the restored feature must survive', () => { + const { adapter, draw, undoStack } = setup() + draw.getMode.mockReturnValue('edit_vertex') + adapter.cancel() + expect(undoStack.clear).toHaveBeenCalled() + expect(draw.trash).not.toHaveBeenCalled() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) + + test('cancelling with no active session (disabled mode) does not trash', () => { + const { adapter, draw } = setup() + adapter.cancel() + expect(draw.trash).not.toHaveBeenCalled() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) +}) + +describe('setSnapEnabled', () => { + test('enables snapping via the snap instance', () => { + const { adapter, mapProvider } = setup() + const snap = { setSnapStatus: jest.fn() } + getSnapInstance.mockReturnValue(snap) + + adapter.setSnapEnabled(true) + + expect(mapProvider.snapEnabled).toBe(true) + expect(snap.setSnapStatus).toHaveBeenCalledWith(true) + expect(clearSnapState).not.toHaveBeenCalled() + }) + + test('disabling clears snap state and hides the indicator when present', () => { + const { adapter, map } = setup() + const snap = { setSnapStatus: jest.fn() } + getSnapInstance.mockReturnValue(snap) + map.getLayer.mockReturnValue({ id: SNAP_LAYER }) + + adapter.setSnapEnabled(false) + + expect(snap.setSnapStatus).toHaveBeenCalledWith(false) + expect(clearSnapState).toHaveBeenCalledWith(snap) + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'none') + }) + + test('disabling without the indicator layer skips the layout update', () => { + const { adapter, map } = setup() + const snap = { setSnapStatus: jest.fn() } + getSnapInstance.mockReturnValue(snap) + map.getLayer.mockReturnValue(null) + + adapter.setSnapEnabled(false) + + expect(clearSnapState).toHaveBeenCalledWith(snap) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('tolerates a missing snap instance', () => { + const { adapter, mapProvider } = setup() + getSnapInstance.mockReturnValue(null) + + adapter.setSnapEnabled(false) + + expect(mapProvider.snapEnabled).toBe(false) + expect(clearSnapState).not.toHaveBeenCalled() + }) + + test('tolerates a snap instance without setSnapStatus', () => { + const { adapter } = setup() + getSnapInstance.mockReturnValue({}) + expect(() => adapter.setSnapEnabled(true)).not.toThrow() + }) +}) + +describe('setSnapLayers', () => { + test('forwards layers to the snap instance when available', () => { + const { adapter } = setup() + const snap = { setSnapLayers: jest.fn() } + getSnapInstance.mockReturnValue(snap) + + adapter.setSnapLayers(['a', 'b']) + expect(snap.setSnapLayers).toHaveBeenCalledWith(['a', 'b']) + }) + + test('stashes pending layers when the instance is not ready', () => { + const { adapter, map } = setup() + getSnapInstance.mockReturnValue(null) + + adapter.setSnapLayers(['a']) + expect(map._pendingSnapLayers).toEqual(['a']) + }) + + test('does nothing when there is no instance and no layers', () => { + const { adapter, map } = setup() + getSnapInstance.mockReturnValue(null) + + adapter.setSnapLayers(null) + expect(map._pendingSnapLayers).toBeUndefined() + }) +}) + +describe('_handleModeChange', () => { + test('clears the snap indicator when leaving to a non-draw mode', () => { + const { map } = setup() + const snap = { id: 'snap' } + getSnapInstance.mockReturnValue(snap) + + onHandler(map, MAPBOX_DRAW_EVENTS.MODE_CHANGE)({ mode: 'simple_select' }) + + expect(clearSnapIndicator).toHaveBeenCalledWith(snap, map) + }) + + test('keeps the snap indicator while in a draw mode', () => { + const { map } = setup() + onHandler(map, MAPBOX_DRAW_EVENTS.MODE_CHANGE)({ mode: 'draw_polygon' }) + expect(clearSnapIndicator).not.toHaveBeenCalled() + }) + + test('keeps the snap indicator while in edit_vertex mode', () => { + const { map } = setup() + onHandler(map, MAPBOX_DRAW_EVENTS.MODE_CHANGE)({ mode: 'edit_vertex' }) + expect(clearSnapIndicator).not.toHaveBeenCalled() + }) +}) + +describe('_handleStyleData', () => { + test('does nothing when there are no layers', () => { + const { map } = setup() + map.getStyle.mockReturnValue({ layers: undefined }) + onHandler(map, STYLE_DATA_EVENT)() + expect(map.moveLayer).not.toHaveBeenCalled() + }) + + test('does nothing when a draw layer is already on top', () => { + const { map } = setup() + map.getStyle.mockReturnValue({ layers: [{ id: 'a', source: 'bg' }, { id: 'd', source: 'mapbox-gl-draw-hot' }] }) + onHandler(map, STYLE_DATA_EVENT)() + expect(map.moveLayer).not.toHaveBeenCalled() + }) + + test('moves draw layers back to the top when covered', () => { + const { map } = setup() + map.getStyle.mockReturnValue({ + layers: [ + { id: 'd1', source: 'mapbox-gl-draw-hot' }, + { id: 'd2', source: 'mapbox-gl-draw-cold' }, + { id: 'top', source: 'other' } + ] + }) + + onHandler(map, STYLE_DATA_EVENT)() + + expect(map.moveLayer).toHaveBeenCalledWith('d1') + expect(map.moveLayer).toHaveBeenCalledWith('d2') + expect(map.moveLayer).not.toHaveBeenCalledWith('top') + }) + + test('tolerates layers without a source', () => { + const { map } = setup() + map.getStyle.mockReturnValue({ layers: [{ id: 'd', source: 'mapbox-gl-draw-hot' }, { id: 'nosrc' }] }) + onHandler(map, STYLE_DATA_EVENT)() + expect(map.moveLayer).toHaveBeenCalledWith('d') + }) + + test('re-asserts a dashed stroke after a style reload resets layer visibility', () => { + const { adapter, map, draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + map.getLayer.mockReturnValue({}) + // Live check flags a crossing → dashed. + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 10], [10, 0], [0, 10]]] }) + expect(adapter).toBeDefined() + // A style reload re-adds the layers with spec defaults (solid visible)… + map.setLayoutProperty.mockClear() + onHandler(map, STYLE_DATA_EVENT)() + // …and the handler re-applies the cached dashed state. + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'none') + }) +}) + +describe('interface-type normalisation', () => { + test('draw.interfacetypechange is forwarded onto the adapter bus', () => { + const { map, bus } = setup() + onHandler(map, CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE)({ interfaceType: 'keyboard' }) + expect(bus.emit).toHaveBeenCalledWith('interfacetypechange', { interfaceType: 'keyboard' }) + }) +}) + +describe('remove', () => { + test('unsubscribes from every event and cleans up the draw control', () => { + const { adapter, map, removeDraw } = setup() + + adapter.remove() + + const unsubscribed = map.off.mock.calls.map(([name]) => name) + expect(unsubscribed).toEqual(expect.arrayContaining([ + MAPBOX_DRAW_EVENTS.CREATE, MAPBOX_DRAW_EVENTS.UPDATE, MAPBOX_DRAW_EVENTS.MODE_CHANGE, + CUSTOM_DRAW_EVENTS.EDIT_FINISH, CUSTOM_DRAW_EVENTS.CANCEL, CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, + CUSTOM_DRAW_EVENTS.VERTEX_CHANGE, CUSTOM_DRAW_EVENTS.UNDO_CHANGE, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, + STYLE_DATA_EVENT + ])) + expect(removeDraw).toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/defaults.js b/plugins/draw/src/adapters/maplibre/defaults.js new file mode 100644 index 000000000..c92e6ee24 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/defaults.js @@ -0,0 +1 @@ +export { COLORS, SIZES, TOLERANCES, KEYBOARD, MAP_SIZE_SCALES } from '../../defaults.js' diff --git a/plugins/draw/src/adapters/maplibre/drawEvents.js b/plugins/draw/src/adapters/maplibre/drawEvents.js new file mode 100644 index 000000000..6b158037a --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/drawEvents.js @@ -0,0 +1,35 @@ +/** + * Event names used by the MapLibre draw adapter, grouped by origin. + * + * - MAPBOX_DRAW_EVENTS: stock events fired by @mapbox/mapbox-gl-draw itself. + * - CUSTOM_DRAW_EVENTS: events this codebase's custom modes / adapter dispatch on the map + * (also under the `draw.*` namespace). + * - STYLE_DATA_EVENT: the one native MapLibre map event the adapter listens to (to keep draw + * layers on top after a style reload). + * + * The adapter normalises all of these onto the shared, framework-agnostic event bus. + */ + +// Stock @mapbox/mapbox-gl-draw events, fired by the library itself. +export const MAPBOX_DRAW_EVENTS = { + CREATE: 'draw.create', + UPDATE: 'draw.update', + MODE_CHANGE: 'draw.modechange' +} + +// Custom draw events dispatched by this codebase's modes / adapter. +export const CUSTOM_DRAW_EVENTS = { + EDIT_FINISH: 'draw.editfinish', + CANCEL: 'draw.cancel', + VERTEX_SELECTION: 'draw.vertexselection', + VERTEX_CHANGE: 'draw.vertexchange', + UNDO_CHANGE: 'draw.undochange', + UNDO: 'draw.undo', + GEOMETRY_CHANGE: 'draw.geometrychange', + INTERFACE_TYPE_CHANGE: 'draw.interfacetypechange', + PLACEMENT_BLOCKED: 'draw.placementblocked', + NUDGE_VERTEX: 'draw.nudgevertex' +} + +// Native MapLibre map event (not a draw event) — fires whenever the map style data changes. +export const STYLE_DATA_EVENT = 'styledata' diff --git a/plugins/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/draw/src/adapters/maplibre/mapboxDraw.js new file mode 100755 index 000000000..e80decfd6 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/mapboxDraw.js @@ -0,0 +1,126 @@ +import MapboxDraw from '@mapbox/mapbox-gl-draw' +import { DisabledMode } from './modes/disabledMode.js' +import { EditVertexMode } from './modes/editVertexMode.js' +import { DrawPolygonMode } from './modes/drawPolygonMode.js' +import { DrawLineMode } from './modes/drawLineMode.js' +import { createDrawStyles, updateDrawStyles } from './styles.js' +import { initMapLibreSnap } from './mapboxSnap.js' +import { createUndoStack } from '../../utils/undoStack.js' +import { setupTouchClickWorkaround } from './utils/touchClickWorkaround.js' +import { applyTouchVertexColors } from './modes/editVertexMode/touchHandlers.js' +import { resolveColors } from '../../utils/resolveColors.js' +import { TOLERANCES, MAP_SIZE_SCALES } from './defaults.js' + +/** + * Creates and manages a MapLibre/Mapbox Draw control instance configured for polygon editing. + * Returns an object with a `.remove()` cleanup function that removes all listeners + * and safely disposes of the Draw control. + * + * Features: + * - Custom modes for editing and drawing vertices + * - Dynamic runtime style updates on `events.MAP_SET_STYLE` event + * - Safe reapplication of styles if map.setStyle is called + * + * @param {string} options.mapStyle - Map style object + * @param {Object} options.mapProvider - Object containing the map instance + * @param {Object} options.eventBus - Event bus for app-level events + * @param {Object} [options.pluginConfig] - Plugin-level colour/size overrides — see resolveColors() + * @returns {{ draw: MapboxDraw, remove: Function }} draw instance and cleanup function + */ +export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snapLayers, pluginConfig = {} }) => { + const { map } = mapProvider + + // --- Configure MapLibre GL Draw CSS classes --- + MapboxDraw.constants.classes.CONTROL_BASE = 'maplibregl-ctrl' + MapboxDraw.constants.classes.CONTROL_PREFIX = 'maplibregl-ctrl-' + MapboxDraw.constants.classes.CONTROL_GROUP = 'maplibregl-ctrl-group' + + // --- Register custom modes --- + const modes = { + ...MapboxDraw.modes, + disabled: DisabledMode, + edit_vertex: EditVertexMode, + draw_polygon: DrawPolygonMode, + draw_line: DrawLineMode + } + + // --- Create or reuse MapLibre Draw instance --- + let draw = mapProvider._mapboxDrawInstance + if (draw) { + // Update modes on existing draw instance when adapter is recreated + Object.assign(draw.modes, modes) + } else { + draw = new MapboxDraw({ + modes, + styles: createDrawStyles(mapStyle, pluginConfig), + displayControlsDefault: false, + userProperties: true, + defaultMode: 'disabled' + }) + map.addControl(draw) + mapProvider._mapboxDrawInstance = draw + } + + // mapbox-gl-draw swallows tap clicks in disabled mode — synthesize them + const touchClickWorkaround = setupTouchClickWorkaround(map, draw) + + // We need a reference to this + mapProvider.draw = draw + map._drawCurrentMapStyle = mapStyle + // Stashed on the map (alongside _drawCurrentMapStyle) so mode code that only + // has `this.map` — e.g. touchHandlers.js's addTouchVertexTarget — can still + // resolve colour overrides without pluginConfig being threaded through every + // mode's option object. + map._drawPluginConfig = pluginConfig + // Initialize snap as disabled (matches initialState.snap = false) + mapProvider.snapEnabled = false + // Initialize undo stack (reuse if already exists) + let undoStack = mapProvider.undoStack + if (!undoStack) { + undoStack = createUndoStack((length) => map.fire('draw.undochange', { length })) + mapProvider.undoStack = undoStack + } + map._undoStack = undoStack + + // --- Initialize MapboxSnap using external module --- + // Start with status: false to match initial snap disabled state + const snapColors = resolveColors(mapStyle, pluginConfig) + initMapLibreSnap(map, draw, { + layers: snapLayers, + radius: pluginConfig.snapRadius ?? TOLERANCES.snapRadius, + rules: ['vertex', 'edge'], + colors: { vertex: snapColors.snapVertex, edge: snapColors.snapEdge } + }) + + // --- Update colour scheme --- + const handleSetMapStyle = (e) => { + map._drawCurrentMapStyle = e + map.once('idle', () => { + updateDrawStyles(map, e, pluginConfig) + const svg = map._drawEditContainer?.querySelector('[data-im-draw-touch-target]') + applyTouchVertexColors(svg, e, pluginConfig) + }) + } + eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle) + + // --- Update map scale --- + const handleSetMapSize = (e) => { + map.fire('draw.scalechange', { scale: MAP_SIZE_SCALES[e] }) + } + eventBus.on(events.MAP_SET_SIZE, handleSetMapSize) + + // --- Return instance and cleanup function --- + return { + draw, + remove () { + touchClickWorkaround.remove() + // Remove event listeners + eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle) + eventBus.off(events.MAP_SET_SIZE, handleSetMapSize) + // Disable draw mode but keep control on map for reuse + draw.changeMode('disabled') + // Clear adapter reference (but not _mapboxDrawInstance so it persists) + mapProvider.draw = null + } + } +} diff --git a/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js b/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js new file mode 100644 index 000000000..3c8ec52f3 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js @@ -0,0 +1,268 @@ +import MapboxDraw from '@mapbox/mapbox-gl-draw' +import { createDrawStyles, updateDrawStyles } from './styles.js' +import { initMapLibreSnap } from './mapboxSnap.js' +import { createUndoStack } from '../../utils/undoStack.js' +import { setupTouchClickWorkaround } from './utils/touchClickWorkaround.js' +import { applyTouchVertexColors } from './modes/editVertexMode/touchHandlers.js' +import { resolveColors } from '../../utils/resolveColors.js' +import { TOLERANCES, MAP_SIZE_SCALES } from './defaults.js' +import { createMapboxDraw } from './mapboxDraw.js' + +jest.mock('@mapbox/mapbox-gl-draw', () => { + const MockDraw = jest.fn(function () { + this.modes = {} + this.changeMode = jest.fn() + }) + MockDraw.constants = { classes: {} } + MockDraw.modes = { existing_mode: { id: 'existing' } } + return { __esModule: true, default: MockDraw } +}) + +jest.mock('./modes/disabledMode.js', () => ({ DisabledMode: { id: 'disabled' } })) +jest.mock('./modes/editVertexMode.js', () => ({ EditVertexMode: { id: 'edit_vertex' } })) +jest.mock('./modes/drawPolygonMode.js', () => ({ DrawPolygonMode: { id: 'draw_polygon' } })) +jest.mock('./modes/drawLineMode.js', () => ({ DrawLineMode: { id: 'draw_line' } })) +jest.mock('./styles.js', () => ({ + createDrawStyles: jest.fn(() => ['style']), + updateDrawStyles: jest.fn() +})) +jest.mock('./mapboxSnap.js', () => ({ initMapLibreSnap: jest.fn() })) +jest.mock('../../utils/undoStack.js', () => ({ createUndoStack: jest.fn(() => ({ id: 'undo-stack' })) })) +jest.mock('./utils/touchClickWorkaround.js', () => ({ setupTouchClickWorkaround: jest.fn() })) +jest.mock('./modes/editVertexMode/touchHandlers.js', () => ({ applyTouchVertexColors: jest.fn() })) +jest.mock('../../utils/resolveColors.js', () => ({ + resolveColors: jest.fn(() => ({ snapVertex: 'resolved-vertex', snapEdge: 'resolved-edge' })) +})) +jest.mock('./defaults.js', () => ({ + TOLERANCES: { snapRadius: 12 }, + MAP_SIZE_SCALES: { medium: 1.5 } +})) + +const EVENTS = { MAP_SET_STYLE: 'map:setstyle', MAP_SET_SIZE: 'map:setsize' } + +const handlerFor = (mockFn, eventName) => + mockFn.mock.calls.find(([name]) => name === eventName)?.[1] + +const createMap = () => ({ + addControl: jest.fn(), + once: jest.fn(), + on: jest.fn(), + off: jest.fn(), + fire: jest.fn() +}) + +const setup = ({ existingDraw, existingUndoStack, pluginConfig } = {}) => { + const map = createMap() + const mapProvider = { + map, + _mapboxDrawInstance: existingDraw, + undoStack: existingUndoStack + } + const eventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() } + const removeWorkaround = jest.fn() + setupTouchClickWorkaround.mockReturnValue({ remove: removeWorkaround }) + + const result = createMapboxDraw({ + mapStyle: 'light', + mapProvider, + events: EVENTS, + eventBus, + snapLayers: ['layer-a'], + ...(pluginConfig !== undefined ? { pluginConfig } : {}) + }) + + return { map, mapProvider, eventBus, removeWorkaround, result } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('createMapboxDraw – instance creation', () => { + test('configures the MapLibre control CSS class constants', () => { + setup() + expect(MapboxDraw.constants.classes).toMatchObject({ + CONTROL_BASE: 'maplibregl-ctrl', + CONTROL_PREFIX: 'maplibregl-ctrl-', + CONTROL_GROUP: 'maplibregl-ctrl-group' + }) + }) + + test('creates a new draw instance with the custom modes and adds it to the map', () => { + const { map, mapProvider, result } = setup() + + expect(MapboxDraw).toHaveBeenCalledTimes(1) + const options = MapboxDraw.mock.calls[0][0] + expect(options).toMatchObject({ + styles: ['style'], + displayControlsDefault: false, + userProperties: true, + defaultMode: 'disabled' + }) + expect(options.modes).toMatchObject({ + existing_mode: { id: 'existing' }, + disabled: { id: 'disabled' }, + edit_vertex: { id: 'edit_vertex' }, + draw_polygon: { id: 'draw_polygon' }, + draw_line: { id: 'draw_line' } + }) + expect(createDrawStyles).toHaveBeenCalledWith('light', {}) + + const draw = MapboxDraw.mock.instances[0] + expect(map.addControl).toHaveBeenCalledWith(draw) + expect(mapProvider._mapboxDrawInstance).toBe(draw) + expect(mapProvider.draw).toBe(draw) + expect(result.draw).toBe(draw) + }) + + test('reuses an existing draw instance and merges modes instead of creating a new one', () => { + const existingDraw = { modes: { old: {} }, changeMode: jest.fn() } + const { map, mapProvider } = setup({ existingDraw }) + + expect(MapboxDraw).not.toHaveBeenCalled() + expect(map.addControl).not.toHaveBeenCalled() + expect(existingDraw.modes).toMatchObject({ + old: {}, + disabled: { id: 'disabled' }, + edit_vertex: { id: 'edit_vertex' } + }) + expect(mapProvider.draw).toBe(existingDraw) + }) +}) + +describe('createMapboxDraw – setup side effects', () => { + test('sets up the touch-click workaround and records provider state', () => { + const { map, mapProvider } = setup() + + expect(setupTouchClickWorkaround).toHaveBeenCalledWith(map, mapProvider.draw) + expect(map._drawCurrentMapStyle).toBe('light') + expect(mapProvider.snapEnabled).toBe(false) + }) + + test('stashes pluginConfig on the map for mode code that only has `this.map`', () => { + const pluginConfig = { shapeStroke: '#custom' } + const { map } = setup({ pluginConfig }) + + expect(map._drawPluginConfig).toBe(pluginConfig) + }) + + test('defaults pluginConfig to {} when not provided', () => { + const { map } = setup() + + expect(map._drawPluginConfig).toEqual({}) + }) + + test('creates an undo stack when none exists and wires it to the map', () => { + const { map, mapProvider } = setup() + + expect(createUndoStack).toHaveBeenCalledTimes(1) + expect(mapProvider.undoStack).toEqual({ id: 'undo-stack' }) + expect(map._undoStack).toBe(mapProvider.undoStack) + + // The callback passed to createUndoStack fires a draw.undochange event + const undoCallback = createUndoStack.mock.calls[0][0] + undoCallback(3) + expect(map.fire).toHaveBeenCalledWith('draw.undochange', { length: 3 }) + }) + + test('reuses an existing undo stack', () => { + const existingUndoStack = { id: 'existing-stack' } + const { map } = setup({ existingUndoStack }) + + expect(createUndoStack).not.toHaveBeenCalled() + expect(map._undoStack).toBe(existingUndoStack) + }) + + test('initializes snapping with the default radius, rules, and resolved colours', () => { + const { map, mapProvider } = setup() + + expect(initMapLibreSnap).toHaveBeenCalledWith(map, mapProvider.draw, { + layers: ['layer-a'], + radius: TOLERANCES.snapRadius, + rules: ['vertex', 'edge'], + colors: { vertex: 'resolved-vertex', edge: 'resolved-edge' } + }) + }) + + test('a pluginConfig.snapRadius overrides the default snap radius', () => { + const { map, mapProvider } = setup({ pluginConfig: { snapRadius: 20 } }) + + expect(initMapLibreSnap).toHaveBeenCalledWith(map, mapProvider.draw, expect.objectContaining({ + radius: 20 + })) + }) + + test('resolves snap colours from mapStyle + pluginConfig', () => { + const pluginConfig = { snapVertex: '#custom' } + setup({ pluginConfig }) + + expect(resolveColors).toHaveBeenCalledWith('light', pluginConfig) + }) +}) + +describe('createMapboxDraw – event handlers', () => { + test('MAP_SET_STYLE updates the current style and restyles on idle', () => { + const { map, eventBus } = setup() + + const styleHandler = handlerFor(eventBus.on, EVENTS.MAP_SET_STYLE) + map._drawEditContainer = { querySelector: jest.fn(() => 'svg-el') } + styleHandler('dark') + + expect(map._drawCurrentMapStyle).toBe('dark') + + const idleCallback = handlerFor(map.once, 'idle') + idleCallback() + expect(updateDrawStyles).toHaveBeenCalledWith(map, 'dark', {}) + expect(map._drawEditContainer.querySelector).toHaveBeenCalledWith('[data-im-draw-touch-target]') + expect(applyTouchVertexColors).toHaveBeenCalledWith('svg-el', 'dark', {}) + }) + + test('MAP_SET_STYLE idle handler tolerates a missing edit container', () => { + const { map, eventBus } = setup() + + handlerFor(eventBus.on, EVENTS.MAP_SET_STYLE)('dark') + handlerFor(map.once, 'idle')() + + expect(applyTouchVertexColors).toHaveBeenCalledWith(undefined, 'dark', {}) + }) + + test('MAP_SET_STYLE passes pluginConfig through to restyling and touch colours', () => { + const pluginConfig = { editStroke: '#custom' } + const { map, eventBus } = setup({ pluginConfig }) + + handlerFor(eventBus.on, EVENTS.MAP_SET_STYLE)('dark') + handlerFor(map.once, 'idle')() + + expect(updateDrawStyles).toHaveBeenCalledWith(map, 'dark', pluginConfig) + expect(applyTouchVertexColors).toHaveBeenCalledWith(undefined, 'dark', pluginConfig) + }) + + test('draw.interfacetypechange is not handled here — the adapter normalises it onto the bus', () => { + const { map } = setup() + expect(map.on).not.toHaveBeenCalledWith('draw.interfacetypechange', expect.any(Function)) + }) + + test('MAP_SET_SIZE fires a scale change using the size lookup', () => { + const { map, eventBus } = setup() + + handlerFor(eventBus.on, EVENTS.MAP_SET_SIZE)('medium') + + expect(map.fire).toHaveBeenCalledWith('draw.scalechange', { scale: MAP_SIZE_SCALES.medium }) + }) +}) + +describe('createMapboxDraw – cleanup', () => { + test('remove() detaches listeners, disables draw and clears the adapter reference', () => { + const { mapProvider, eventBus, removeWorkaround, result } = setup() + const draw = mapProvider.draw + + result.remove() + + expect(removeWorkaround).toHaveBeenCalledTimes(1) + expect(eventBus.off).toHaveBeenCalledWith(EVENTS.MAP_SET_STYLE, expect.any(Function)) + expect(eventBus.off).toHaveBeenCalledWith(EVENTS.MAP_SET_SIZE, expect.any(Function)) + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + expect(mapProvider.draw).toBeNull() + expect(mapProvider._mapboxDrawInstance).toBe(draw) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/mapboxSnap.js b/plugins/draw/src/adapters/maplibre/mapboxSnap.js new file mode 100644 index 000000000..ead9ea98c --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/mapboxSnap.js @@ -0,0 +1,39 @@ +import { COLORS, TOLERANCES } from './defaults.js' +import { applyMapboxSnapPatches } from './snap/prototypePatches.js' +import { pollUntil } from './snap/sourceData.js' +import { createSnapInstance } from './snap/snapInstance.js' +import { registerStyleLoadHandler, registerZoomHandlers } from './snap/mapHandlers.js' +import { DRAW_HOT_SOURCE } from './snap/constants.js' + +/** Initialize MapboxSnap with MapLibre + MapboxDraw */ +export function initMapLibreSnap (map, draw, snapOptions = {}) { + // Prevent multiple initializations (causes event listener duplication) + if (map._snapInitialized) { + return map._snapInstance + } + map._snapInitialized = true + + const { + layers = [], + radius = TOLERANCES.snapRadius, + rules = ['vertex', 'midpoint', 'edge'], + status = false, + onSnapped = () => {}, + colors = {} + } = snapOptions + const config = { layers, radius, rules, status, onSnapped } + + // Apply global patches to the MapboxSnap prototype + applyMapboxSnapPatches({ vertex: COLORS.snapVertex, midpoint: COLORS.snapMidpoint, edge: COLORS.snapEdge, ...colors }) + + registerStyleLoadHandler(map, draw, config) + registerZoomHandlers(map) + + // Initial setup - poll until the draw source exists + pollUntil( + () => map._removed ? null : map.getSource(DRAW_HOT_SOURCE), + (source) => createSnapInstance(map, draw, source, config) + ) + + return map._snapInstance +} diff --git a/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js b/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js new file mode 100644 index 000000000..0ae9bb66f --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js @@ -0,0 +1,91 @@ +import { applyMapboxSnapPatches } from './snap/prototypePatches.js' +import { registerStyleLoadHandler, registerZoomHandlers } from './snap/mapHandlers.js' +import { createSnapInstance } from './snap/snapInstance.js' +import { initMapLibreSnap } from './mapboxSnap.js' + +const DRAW_SOURCE = 'mapbox-gl-draw-hot' + +jest.mock('./snap/prototypePatches.js', () => ({ applyMapboxSnapPatches: jest.fn() })) +jest.mock('./snap/mapHandlers.js', () => ({ + registerStyleLoadHandler: jest.fn(), + registerZoomHandlers: jest.fn() +})) +jest.mock('./snap/snapInstance.js', () => ({ + createSnapInstance: jest.fn((map) => { + map._snapInstance = { id: 'snap' } + return map._snapInstance + }) +})) +jest.mock('./snap/sourceData.js', () => ({ + pollUntil: jest.fn((checkFn, onSuccess) => { + const result = checkFn() + if (result) { + onSuccess(result) + } + }) +})) +jest.mock('./defaults.js', () => ({ + COLORS: { snapVertex: 'v', snapMidpoint: 'm', snapEdge: 'e' }, + TOLERANCES: { snapRadius: 12 } +})) + +const makeMap = (overrides = {}) => ({ + getSource: jest.fn(() => ({ id: 'hot' })), + ...overrides +}) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('initMapLibreSnap', () => { + test('returns the existing instance and skips setup when already initialized', () => { + const map = { _snapInitialized: true, _snapInstance: { id: 'existing' } } + + expect(initMapLibreSnap(map, {}, {})).toEqual({ id: 'existing' }) + expect(applyMapboxSnapPatches).not.toHaveBeenCalled() + expect(registerStyleLoadHandler).not.toHaveBeenCalled() + }) + + test('applies patches, registers handlers and creates the instance', () => { + const map = makeMap() + const draw = { id: 'draw' } + + const result = initMapLibreSnap(map, draw, { layers: ['a'] }) + + expect(applyMapboxSnapPatches).toHaveBeenCalledWith({ vertex: 'v', midpoint: 'm', edge: 'e' }) + expect(registerStyleLoadHandler).toHaveBeenCalledWith(map, draw, expect.objectContaining({ layers: ['a'], radius: 12 })) + expect(registerZoomHandlers).toHaveBeenCalledWith(map) + expect(createSnapInstance).toHaveBeenCalledWith(map, draw, { id: 'hot' }, expect.any(Object)) + expect(map.getSource).toHaveBeenCalledWith(DRAW_SOURCE) + expect(result).toEqual({ id: 'snap' }) + }) + + test('merges custom snap colours over the defaults', () => { + initMapLibreSnap(makeMap(), {}, { colors: { vertex: 'custom' } }) + expect(applyMapboxSnapPatches).toHaveBeenCalledWith({ vertex: 'custom', midpoint: 'm', edge: 'e' }) + }) + + test('uses the configured default radius when none is supplied', () => { + initMapLibreSnap(makeMap(), {}, {}) + expect(registerStyleLoadHandler.mock.calls[0][2].radius).toBe(12) + }) + + test('defaults snapOptions to an empty object when omitted', () => { + const map = makeMap() + expect(() => initMapLibreSnap(map, {})).not.toThrow() + expect(createSnapInstance).toHaveBeenCalled() + }) + + test('provides a no-op default onSnapped handler', () => { + initMapLibreSnap(makeMap(), {}, {}) + const passedConfig = registerStyleLoadHandler.mock.calls[0][2] + expect(passedConfig.onSnapped()).toBeUndefined() + }) + + test('does not create an instance when the map has already been removed', () => { + const map = makeMap({ _removed: true }) + initMapLibreSnap(map, {}, {}) + expect(createSnapInstance).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js new file mode 100644 index 000000000..f597b6151 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js @@ -0,0 +1,58 @@ +import { createLifecycle } from './drawMode/lifecycle.js' +import { createClickHandlers } from './drawMode/clickHandlers.js' +import { createUndoHandlers } from './drawMode/undoHandlers.js' +import { createKeyboardHandlers } from './drawMode/keyboardHandlers.js' +import { createPointerHandlers } from './drawMode/pointerHandlers.js' +import { createRenderHelpers } from './drawMode/renderHelpers.js' + +/** + * Factory function to create a draw mode for either polygons or lines. + * Shared behaviour (event handling, snap detection, undo, rubber band, keyboard + * drawing) is split across ./drawMode/* handler modules and composed here. + * + * @param {Object} ParentMode - DrawPolygon or DrawLineString from mapbox-gl-draw + * @param {Object} config - Configuration for the mode + * @param {string} config.featureProp - Property name on state ('polygon' or 'line') + * @param {string} config.geometryType - 'Polygon' or 'LineString' + * @param {Function} config.getCoords - Function to get coordinates from feature + * @param {Function} config.validateClick - Validation function for clicks + * @param {Function} config.getPlacedCoords - Function to get placed vertex coordinates from a display geojson + */ +export const createDrawMode = (ParentMode, config) => { + const { + featureProp, + geometryType, + getCoords, + validateClick, + getPlacedCoords, + excludeFeatureIdFromSetup = false, + finishOnInvalidClick = false // For lines: finish when clicking same spot (like double-click) + } = config + + const deps = { + ParentMode, + featureProp, + geometryType, + getCoords, + validateClick, + getPlacedCoords, + excludeFeatureIdFromSetup, + finishOnInvalidClick, + getFeature: (state) => state[featureProp], + // ring is [...placed, last_placed, rubber_band]; splice(-2,1) removes last_placed + RUBBER_BAND_OFFSET: 2, + // Only these keys signal a switch to keyboard drawing (matches the OL adapter's + // drawInput) — modifier keys and shortcut chords like cmd+z must not show the crosshair + INTERFACE_KEYS: new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter']) + } + + return { + ...ParentMode, + ...createLifecycle(deps), + ...createClickHandlers(deps), + ...createUndoHandlers(deps), + ...createKeyboardHandlers(deps), + ...createPointerHandlers(deps), + ...createRenderHelpers(deps) + } +} diff --git a/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js b/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js new file mode 100644 index 000000000..319630322 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js @@ -0,0 +1,53 @@ +import { createDrawMode } from './createDrawMode.js' + +/** + * Tests for the factory's own job: composing ParentMode with every handler group into a + * single mode object. The behaviour of each handler group is covered in its colocated + * test file under drawMode/ (lifecycle, click, keyboard, undo, pointer, render). + */ + +const POLYGON_CONFIG = { + featureProp: 'polygon', + geometryType: 'Polygon', + getCoords: (f) => f.coordinates[0], + validateClick: () => true, + getPlacedCoords: () => [] +} + +const build = (parent = {}, config = POLYGON_CONFIG) => createDrawMode(parent, config) + +describe('createDrawMode composition', () => { + test('assembles a mode object with methods from every handler group', () => { + const mode = build() + const contributed = [ + 'onSetup', 'onStop', // lifecycle + 'onClick', 'onTap', 'doClick', 'onVertexButtonClick', 'onCreate', // click + 'pushDrawUndo', 'undoVertex', 'onUndo', // undo + 'onKeydown', 'onKeyup', 'onKeyUp', // keyboard + 'onMove', 'onMouseMove', 'onTouchStart', 'onBlur', // pointer + 'toDisplayFeatures', '_showCrossHair', '_simulateMouse' // render + ] + for (const method of contributed) { + expect(typeof mode[method]).toBe('function') + } + }) + + test('preserves non-overridden ParentMode members and overrides shared ones', () => { + const parent = { parentOnly () {}, onClick: 'parent-onclick' } + const mode = build(parent) + expect(mode.parentOnly).toBe(parent.parentOnly) // passed through + expect(typeof mode.onClick).toBe('function') // overridden by the click handlers + expect(mode.onClick).not.toBe(parent.onClick) + }) + + test('passes config through so the getFeature accessor reads the configured feature prop', () => { + const polygonMode = build() + const lineMode = build({}, { ...POLYGON_CONFIG, featureProp: 'line' }) + // dispatchVertexChange is config-agnostic, but pushDrawUndo reads getFeature(state).id + const map = { _undoStack: { push: jest.fn() }, _undoInProgress: false } + polygonMode.pushDrawUndo.call({ map }, { polygon: { id: 'p1' } }) + lineMode.pushDrawUndo.call({ map }, { line: { id: 'l1' } }) + expect(map._undoStack.push).toHaveBeenNthCalledWith(1, expect.objectContaining({ featureId: 'p1' })) + expect(map._undoStack.push).toHaveBeenNthCalledWith(2, expect.objectContaining({ featureId: 'l1' })) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/disabledMode.js b/plugins/draw/src/adapters/maplibre/modes/disabledMode.js new file mode 100755 index 000000000..1a7cbddc8 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/disabledMode.js @@ -0,0 +1,23 @@ +export const DisabledMode = { + onSetup () { + return {} // Return empty state + }, + + onClick () { + // Prevent feature selection + return false + }, + + onKeyUp () { + return false + }, + + onDrag () { + return false + }, + + toDisplayFeatures (_state, geojson, display) { + geojson.properties.active = 'false' + display(geojson) + } +} diff --git a/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js b/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js new file mode 100644 index 000000000..76a4e779e --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js @@ -0,0 +1,21 @@ +import { DisabledMode } from './disabledMode.js' + +describe('DisabledMode', () => { + test('onSetup returns an empty state', () => { + expect(DisabledMode.onSetup()).toEqual({}) + }) + + test('interaction handlers are disabled (return false to block selection/drag/keys)', () => { + expect(DisabledMode.onClick()).toBe(false) + expect(DisabledMode.onKeyUp()).toBe(false) + expect(DisabledMode.onDrag()).toBe(false) + }) + + test('toDisplayFeatures marks the feature inactive and still displays it', () => { + const display = jest.fn() + const geojson = { properties: {} } + DisabledMode.toDisplayFeatures({}, geojson, display) + expect(geojson.properties.active).toBe('false') + expect(display).toHaveBeenCalledWith(geojson) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js b/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js new file mode 100644 index 000000000..f7eae94ac --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js @@ -0,0 +1,15 @@ +import MapboxDraw from '@mapbox/mapbox-gl-draw' +import { isValidLineClick } from '../../../utils/spatial.js' +import { createDrawMode } from './createDrawMode.js' + +// Extend the built-in mode via the package's public API (MapboxDraw.modes) rather than a deep internal import +export const DrawLineMode = createDrawMode(MapboxDraw.modes.draw_line_string, { + featureProp: 'line', + geometryType: 'LineString', + getCoords: (feature) => feature.coordinates, + validateClick: (feature) => isValidLineClick(feature.coordinates), + excludeFeatureIdFromSetup: true, // DrawLineString interprets featureId as "continue existing" + finishOnInvalidClick: true, // Clicking same spot (like double-click) finishes the line + // Display coords during drawing: [v0...vN, rubber_band] + getPlacedCoords: (geojson) => geojson.geometry.coordinates.slice(0, -1) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js b/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js new file mode 100644 index 000000000..5e3eebacf --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js @@ -0,0 +1,37 @@ +import { setup, firedWith, DrawLineMode } from './drawMode/__helpers__/harness.js' + +const drawVertexMarkers = (ctx, state, geometry) => { + const display = jest.fn() + ctx.toDisplayFeatures(state, { type: 'Feature', properties: { id: state.line.id }, geometry }, display) + return display.mock.calls.map(([f]) => f) + .filter((f) => f.properties.meta === 'draw-vertex') + .map((m) => m.geometry.coordinates) +} + +describe('DrawLineMode config', () => { + test('excludeFeatureIdFromSetup starts a fresh line feature even when a featureId is supplied', () => { + const { state } = setup(DrawLineMode) + expect(state.line).toBeDefined() + expect(state.featureId).toBe('shape-1') + }) + + test('getCoords reads the line coordinates directly (not a ring)', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + expect(state.line.coordinates[0]).toEqual([5, 5]) // first placed vertex, at map centre + }) + + test('getPlacedCoords marks every placed vertex, excluding the rubber band', () => { + const { ctx, state } = setup(DrawLineMode) + // Display coords: [v0..vN, rubber_band] → placed = slice(0, -1) + expect(drawVertexMarkers(ctx, state, { type: 'LineString', coordinates: [[5, 5], [8, 8]] })).toEqual([[5, 5]]) + }) + + test('finishOnInvalidClick finishes the line when the same spot is clicked twice', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.doClick(state) // same spot → finish (like a double-click) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(1) + expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', { featureIds: [state.line.id] }) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js new file mode 100644 index 000000000..5c21e1ae8 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js @@ -0,0 +1,115 @@ +import { DrawPolygonMode } from '../../drawPolygonMode.js' +import { DrawLineMode } from '../../drawLineMode.js' +import { createUndoStack } from '../../../../../utils/undoStack.js' +import PolygonFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' +import LineStringFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' + +/** + * Shared test harness for the createDrawMode handler modules. Exercises the real + * DrawPolygonMode / DrawLineMode (real mapbox-gl-draw parent modes and feature + * classes) over test doubles for the map, store and DOM. Excluded from coverage. + */ + +export { DrawPolygonMode, DrawLineMode } +export const CENTER = { lng: 5, lat: 5 } + +const createMap = () => { + const canvas = document.createElement('canvas') + const listeners = {} + return { + _undoInProgress: false, + _undoStack: null, + _snapInstance: null, + doubleClickZoom: { disable: jest.fn(), enable: jest.fn() }, + getCanvas: () => canvas, + getCenter: () => ({ ...CENTER }), + project: jest.fn(() => ({ x: 50, y: 50 })), + unproject: jest.fn(() => ({ ...CENTER })), + fire: jest.fn(function (type, e) { (listeners[type] ?? []).forEach((h) => h(e)) }), + on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }), + off: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter((x) => x !== h) }) + } +} + +// The `this` context mapbox-gl-draw gives a mode: map, ctx accessors and the mode's own methods +const createModeContext = (mode) => { + const map = createMap() + const features = new Map() + const store = { + add: jest.fn((f) => features.set(f.id, f)), + delete: jest.fn((ids) => ids.forEach((id) => features.delete(id))), + render: jest.fn(), + featureChanged: jest.fn(), + getInitialConfigValue: jest.fn(() => true) + } + const ctx = { + map, + _ctx: { store, api: { changeMode: jest.fn(), add: jest.fn(), delete: jest.fn() }, options: {} }, + addFeature: (f) => store.add(f), + getFeature: (id) => features.get(id), + deleteFeature: jest.fn((ids) => ids.forEach((id) => features.delete(id))), + clearSelectedFeatures: jest.fn(), + updateUIClasses: jest.fn(), + activateUIButton: jest.fn(), + setActionableState: jest.fn(), + changeMode: jest.fn() + } + ctx.newFeature = (geojson) => geojson.geometry.type === 'Polygon' + ? new PolygonFeature(ctx._ctx, geojson) + : new LineStringFeature(ctx._ctx, geojson) + Object.assign(ctx, mode) + contexts.push(ctx) + return ctx +} + +// Remove window/container/map listeners registered by onSetup so tests don't leak into each other +const contexts = [] +const removeListeners = (ctx) => ctx._listeners?.forEach(([t, e, h]) => + t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h)) + +const createContainer = () => { + const container = document.createElement('div') + container.tabIndex = 0 + const marker = document.createElement('div') + marker.id = 'vertex-marker' + container.appendChild(marker) + const button = document.createElement('button') + button.id = 'add-vertex' + container.appendChild(button) + document.body.appendChild(container) + return { container, marker, button } +} + +export const setup = (mode, options = {}) => { + const ctx = createModeContext(mode) + const dom = createContainer() + ctx.map._undoStack = createUndoStack(() => {}) + const state = ctx.onSetup({ + container: dom.container, + vertexMarkerId: 'vertex-marker', + addVertexButtonId: 'add-vertex', + interfaceType: 'mouse', + featureId: 'shape-1', + properties: { label: 'field' }, + ...options + }) + return { ctx, state, ...dom } +} + +export const clickEvent = (ctx, lng, lat, overrides = {}) => ({ + lngLat: { lng, lat }, + point: { x: lng, y: lat }, + originalEvent: { button: 0, target: ctx.map.getCanvas(), ...overrides } +}) + +export const clickAt = (ctx, state, lng, lat) => ctx.onClick(state, clickEvent(ctx, lng, lat)) + +export const firedWith = (map, type) => map.fire.mock.calls.filter(([t]) => t === type).map(([, e]) => e) + +export const activeSnap = () => ({ status: true, snapStatus: true, snapCoords: [9, 9], snapToClosestPoint: jest.fn() }) + +afterEach(() => { + contexts.splice(0).forEach(removeListeners) + document.body.innerHTML = '' + jest.useRealTimers() +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js new file mode 100644 index 000000000..df5e55255 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js @@ -0,0 +1,193 @@ +import { + getSnapInstance, isSnapActive, isSnapEnabled, createSnappedEvent, createSnappedClickEvent +} from '../../utils/snapHelpers.js' +import { attemptPlacement } from '../../../../validation/validateGeometry.js' + +// The commit-level geometrychange payload for a vertex commit: the placed-only +// geometry (trailing rubber-band point dropped so validation tests the committed shape). +const placedDrawGeometryChange = (feature, getCoords, phase) => { + const placed = getCoords(feature).slice(0, -1) + const type = feature.toGeoJSON().geometry.type + const geometry = type === 'Polygon' + ? { type: 'Polygon', coordinates: [placed] } + : { type: 'LineString', coordinates: placed } + return { feature: { type: 'Feature', geometry, properties: {} }, phase, vertexIndex: Math.max(0, placed.length - 1) } +} + +// Fire a commit-level geometrychange for validation, deferred a tick so it runs after +// the current click settles. +const scheduleDrawValidation = (map, getFeature, getCoords, state, phase) => { + setTimeout(() => { + const feature = getFeature(state) + if (!feature) { return } + map.fire('draw.geometrychange', placedDrawGeometryChange(feature, getCoords, phase)) + }, 0) +} + +// Re-id a freshly created feature to the caller's requested id. +const reidCreatedFeature = (api, feature, featureId) => { + api.delete(feature.id) + feature.id = featureId + api.add(feature, { userProperties: true }) +} + +// Guards, notifications, and small wiring handlers shared by the click paths. +const createClickHelpers = ({ geometryType, getFeature, getCoords }) => ({ + // Non-drawing clicks: secondary buttons, clicks during an undo, or off-canvas. + _isIgnorableClick (e) { + return e.originalEvent.button > 0 || this.map._undoInProgress || e.originalEvent.target !== this.map.getCanvas() + }, + + // Attempt a placement through the shared hard rules + user callback. On a veto + // the vertex never appears and a draw.placementblocked event carries the + // reason. The trailing rubber-band coord is dropped so the candidate is placed + // vertices + the point about to be placed. + _canPlaceVertex (state, point) { + const feature = getFeature(state) + if (!feature || !point) { return true } + const result = attemptPlacement({ + placed: getCoords(feature).slice(0, -1), + point, + geometryType, + onGeometryChange: this.map._drawGeometryValidator + }) + if (!result.valid) { + this.map.fire('draw.placementblocked', result.blocked) + } + return result.valid + }, + + dispatchVertexChange (coords) { + // Both polygon ring and LineString store [v0...vN, rubber_band] during drawing — subtract 1 to get placed vertex count + this.map.fire('draw.vertexchange', { + numVertecies: Math.max(0, coords.length - 1) + }) + }, + + // Emit a commit-level geometrychange after a vertex commit (placement or undo) + // so the validation layer can gate the Done button. + emitDrawValidation (state, phase = 'commit-add') { + scheduleDrawValidation(this.map, getFeature, getCoords, state, phase) + }, + + onTap () { + + }, + + onVertexButtonClick (state, e) { + // Only trigger for the specific add vertex button, and skip during undo + if (state.addVertexButtonId && !this.map._undoInProgress && e.target.closest(`#${state.addVertexButtonId}`)) { + this.doClick(state) + } + }, + + onCreate (state, e) { + reidCreatedFeature(this._ctx.api, e.features[0], state.featureId) + } +}) + +// The click paths themselves: mouse clicks and the simulated crosshair click +// (touch / keyboard / add-vertex button). +const createClickActions = ({ ParentMode, getFeature, getCoords, validateClick, finishOnInvalidClick }) => { + // Snap is inactive: sync the trailing rubber-band point to the click, then guard + // against a duplicate-coordinate click reaching ParentMode, which would trigger a + // changeMode chain and cause a runtime error on coords.length access (polygon). + // Returns false when the click should be dropped without placing a vertex. + const prepareUnsnappedClick = (state, e) => { + const coords = getCoords(getFeature(state)) + if (coords.length > 0) { + coords[coords.length - 1] = [e.lngLat.lng, e.lngLat.lat] + } + return finishOnInvalidClick || validateClick(getFeature(state)) + } + + return { + onClick (state, e) { + // Skip non-primary clicks, undo operations, or clicks outside canvas + if (this._isIgnorableClick(e)) { + return + } + // Block a finish/close gesture (clicking a placed vertex) while the shape is invalid. + if (this.map._drawGeometryValid === false && e.featureTarget?.properties?.meta === 'vertex') { + return + } + const snap = getSnapInstance(this.map) + if (isSnapEnabled(state) && isSnapActive(snap)) { + e = createSnappedEvent(e, snap) + } else if (!prepareUnsnappedClick(state, e)) { + return + } else { + // No action + } + // Hard gate: a placement the rules or the user callback veto never appears, so + // an unrecoverable state (e.g. a self-crossing path) can't be drawn forward. + if (!this._canPlaceVertex(state, [e.lngLat.lng, e.lngLat.lat])) { + return + } + const coordsBefore = getCoords(getFeature(state)).length + ParentMode.onClick.call(this, state, e) + // Push undo and update count if a vertex was added + if (getCoords(getFeature(state)).length > coordsBefore) { + this.pushDrawUndo(state) + this.dispatchVertexChange(getCoords(getFeature(state))) + this.emitDrawValidation(state) + } + }, + + doClick (state) { + // Skip during undo operation + if (this.map._undoInProgress) { + return + } + + const feature = getFeature(state) + const coords = getCoords(feature) + this.dispatchVertexChange(coords) + + if (!validateClick(feature)) { + // For lines: clicking same spot (like double-click) should finish the line — but + // only when the geometry is valid, so an invalid line can't be completed. + // isValidLineClick only returns false with 2+ coords, so coords.length is always > 1 here. + if (finishOnInvalidClick && this.map._drawGeometryValid !== false) { + coords.pop() + this.map.fire('draw.create', { features: [feature.toGeoJSON()] }) + this.changeMode('simple_select', { featureIds: [feature.id] }) + } + return + } + + const snap = getSnapInstance(this.map) + const snappedEvent = isSnapEnabled(state) && createSnappedClickEvent(this.map, snap) + + // Hard-gate parity with onClick: touch/keyboard/add-button placements are vetoed + // at the point that would actually be committed (snapped or map centre). + const target = snappedEvent ? snappedEvent.lngLat : this.map.getCenter() + if (!this._canPlaceVertex(state, [target.lng, target.lat])) { + return + } + + if (snappedEvent) { + ParentMode.onClick.call(this, state, snappedEvent) + this._ctx.store.render() + } else { + this._simulateMouse('click', ParentMode.onClick, state) + } + + // Push undo and update count if a vertex was added. A validated click always + // adds one vertex via the parent mode, so this runs on every successful doClick. + const newCoords = getCoords(getFeature(state)) + this.pushDrawUndo(state) + this.dispatchVertexChange(newCoords) + this.emitDrawValidation(state) + } + } +} + +/** + * Click / vertex-placement handling for the shared draw mode: mouse clicks, the + * add-vertex button, and the draw.create re-id step. Part of createDrawMode. + */ +export const createClickHandlers = (deps) => ({ + ...createClickHelpers(deps), + ...createClickActions(deps) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js new file mode 100644 index 000000000..986d0dbab --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js @@ -0,0 +1,241 @@ +import { setup, clickAt, clickEvent, firedWith, activeSnap, DrawPolygonMode, DrawLineMode, CENTER } from './__helpers__/harness.js' + +describe('mouse clicks (polygon)', () => { + test('each click at a new position places a vertex, fires vertexchange and pushes an undo op', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + + // Ring is [v0, v1, rubber_band] + expect(state.polygon.coordinates[0]).toHaveLength(3) + expect(state.polygon.coordinates[0][0]).toEqual([0, 0]) + expect(state.polygon.coordinates[0][1]).toEqual([10, 0]) + expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 2 }) + expect(ctx.map._undoStack.length).toBe(2) + }) + + test('clicking the same position again is rejected', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0]).toHaveLength(2) + expect(ctx.map._undoStack.length).toBe(1) + }) + + test('rejects a click that would make the drawn path cross itself', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 10) + clickAt(ctx, state, 10, 0) + const lenBefore = state.polygon.coordinates[0].length + clickAt(ctx, state, 0, 10) // the new edge would cross the (0,0)-(10,10) edge + expect(state.polygon.coordinates[0].length).toBe(lenBefore) // vertex never placed + }) + + test('a rejected placement fires draw.placementblocked with the reason', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 10) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 0, 10) + expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual(expect.objectContaining({ + phase: 'place', + mode: 'draw_polygon', + vertexIndex: 3, + reason: expect.any(String), + feature: expect.objectContaining({ type: 'Feature' }) + })) + }) + + test('the user callback can veto a mouse placement (and receives phase "place")', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.map._drawGeometryValidator = jest.fn((event) => + event.phase === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0]).toHaveLength(0) // first vertex vetoed + expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual( + expect.objectContaining({ reason: 'outside region', vertexIndex: 0 })) + }) + + test('non-primary buttons, undo-in-progress and off-canvas clicks are ignored', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.onClick(state, clickEvent(ctx, 0, 0, { button: 2 })) + ctx.map._undoInProgress = true + clickAt(ctx, state, 0, 0) + ctx.map._undoInProgress = false + ctx.onClick(state, clickEvent(ctx, 0, 0, { target: document.body })) + expect(state.polygon.coordinates[0]).toHaveLength(0) + }) + + test('with snap active the vertex is placed at the snapped position', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + ctx.map._snapInstance = activeSnap() + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0][0]).toEqual([9, 9]) + }) + + test('onTap is disabled', () => { + const { ctx, state } = setup(DrawPolygonMode) + expect(ctx.onTap(state, clickEvent(ctx, 0, 0))).toBeUndefined() + expect(state.polygon.coordinates[0]).toHaveLength(0) + }) + + test('placing a vertex emits a deferred commit-level geometrychange for validation', () => { + jest.useFakeTimers() + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + jest.runAllTimers() + const geomChange = firedWith(ctx.map, 'draw.geometrychange').pop() + expect(geomChange).toEqual(expect.objectContaining({ phase: 'commit-add', feature: expect.any(Object) })) + jest.useRealTimers() + }) + + test('emitDrawValidation does not fire without a feature', () => { + jest.useFakeTimers() + const { ctx } = setup(DrawPolygonMode) + ctx.emitDrawValidation({}) // no polygon on the state + jest.runAllTimers() + expect(firedWith(ctx.map, 'draw.geometrychange')).toHaveLength(0) + jest.useRealTimers() + }) + + test('a line placement emits a LineString geometrychange', () => { + jest.useFakeTimers() + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + jest.runAllTimers() + const geom = firedWith(ctx.map, 'draw.geometrychange').pop() + expect(geom.feature.geometry.type).toBe('LineString') + jest.useRealTimers() + }) + + test('blocks a close gesture (vertex click) while the geometry is invalid', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 5, 10) + ctx.map._drawGeometryValid = false + const lenBefore = state.polygon.coordinates[0].length + ctx.onClick(state, { ...clickEvent(ctx, 0, 0), featureTarget: { properties: { meta: 'vertex' } } }) + expect(state.polygon.coordinates[0].length).toBe(lenBefore) // finish gesture ignored + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + }) +}) + +describe('add-vertex button and doClick', () => { + test('clicking the add-vertex button places a vertex at map center', () => { + const { ctx, state, button } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.vertexButtonClickHandler({ target: button }) + expect(state.polygon.coordinates[0][0]).toEqual([CENTER.lng, CENTER.lat]) + expect(ctx.map._undoStack.length).toBe(1) + }) + + test('does not finish a line via doClick while the geometry is invalid', () => { + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + ctx.map._drawGeometryValid = false + // Rubber-band duplicates the last placed vertex → a finish gesture. + state.line.setCoordinates([[0, 0], [10, 0], [10, 0]]) + ctx.doClick(state) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + }) + + test('clicks elsewhere, without a button id, or during undo do nothing', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.vertexButtonClickHandler({ target: document.body }) + ctx.map._undoInProgress = true + ctx.doClick(state) + ctx.map._undoInProgress = false + const noButton = setup(DrawPolygonMode, { addVertexButtonId: null }) + noButton.ctx.vertexButtonClickHandler({ target: document.body }) + expect(state.polygon.coordinates[0]).toHaveLength(0) + expect(noButton.state.polygon.coordinates[0]).toHaveLength(0) + }) + + test('doClick rejects a placement that would make the drawn path cross itself', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, -10) + const lenBefore = state.polygon.coordinates[0].length + ctx.doClick(state) // map centre is (5,5): the new edge would cross (0,0)-(10,0) + expect(state.polygon.coordinates[0].length).toBe(lenBefore) + expect(firedWith(ctx.map, 'draw.placementblocked').length).toBeGreaterThan(0) + }) + + test('the user callback can veto a doClick placement', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.map._drawGeometryValidator = () => ({ valid: false, reason: 'outside region' }) + ctx.doClick(state) + expect(state.polygon.coordinates[0]).toHaveLength(0) + expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual( + expect.objectContaining({ reason: 'outside region', phase: 'place' })) + }) + + test('doClick places at the snapped position when snapping', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + ctx.map._snapInstance = activeSnap() + ctx.doClick(state) + expect(state.polygon.coordinates[0][0]).toEqual([9, 9]) + expect(ctx._ctx.store.render).toHaveBeenCalled() + }) + + test('line: activating add-vertex at the same spot twice finishes the line', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.doClick(state) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(1) + expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', { featureIds: [state.line.id] }) + }) + + test('polygon: a repeated add-vertex at the same spot is rejected without finishing', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.doClick(state) + ctx.doClick(state) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + expect(ctx.changeMode).not.toHaveBeenCalled() + }) +}) + +describe('line mode mouse interactions', () => { + test('mouse clicks place line vertices; moving then undoing keeps the rubber band on the last vertex', () => { + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + expect(state.line.coordinates.map(([x]) => x)).toEqual([0, 10, 10, 10]) // 3 placed + rubber band + + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(state.line.coordinates.at(-1)).toEqual([3, 3]) + expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) + + ctx.map._undoStack.push({ type: 'draw_vertex' }) + ctx.undoVertex(state) + const coords = state.line.coordinates + expect(coords[coords.length - 1]).toEqual(coords[coords.length - 2]) + }) + + test('a repeated mouse click at the last vertex adds no new vertex and does not finish the line', () => { + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + const before = state.line.coordinates.length + clickAt(ctx, state, 10, 0) + expect(state.line.coordinates.length).toBe(before) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + }) +}) + +describe('onCreate (draw.create re-id)', () => { + test('re-ids the created feature to the requested featureId', () => { + const { ctx } = setup(DrawPolygonMode) + const feature = { id: 'temp-id' } + ctx.map.fire('draw.create', { features: [feature] }) + expect(ctx._ctx.api.delete).toHaveBeenCalledWith('temp-id') + expect(feature.id).toBe('shape-1') + expect(ctx._ctx.api.add).toHaveBeenCalledWith(feature, { userProperties: true }) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js new file mode 100644 index 000000000..9446fa802 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js @@ -0,0 +1,78 @@ +/** + * Keyboard handling for the shared draw mode: cmd/ctrl+z undo, arrow/Enter keyboard + * drawing, and Escape cancel/reinitialise. Part of createDrawMode. + * + * Note the two distinct handlers: onKeyup (lowercase) is a window listener; onKeyUp + * (capital U) is mapbox-gl-draw's own, registered on the container. + */ +export const createKeyboardHandlers = ({ ParentMode, getFeature, INTERFACE_KEYS }) => ({ + onKeydown (state, e) { + if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + this._handleUndoKeydown(state, e) + return + } + if (document.activeElement !== state.container) { + return + } + if (e.key === 'Escape') { + e.preventDefault() + return + } + if (e.key === 'Enter') { + state.isActive = true + } + if (!INTERFACE_KEYS.has(e.key)) { + return + } + this._setInterface(state, 'keyboard') + this.onMove(state, e) + }, + + onKeyup (state, e) { + if (e.key === 'Escape') { + if (state.interfaceType !== 'keyboard') { + // Mouse/touch: cancel drawing — onKeyUp (capital U) won't fire since container isn't focused + this.map.fire('draw.cancel') + } + // Keyboard: onKeyUp (capital U) handles reinitialize (container is focused, event reaches it) + return + } + if (document.activeElement !== state.container) { + return + } + if (!INTERFACE_KEYS.has(e.key)) { + return + } + this._setInterface(state, 'keyboard') + this.onMove(state, e) + if (e.key === 'Enter' && state.isActive) { + this.doClick(state) + } + }, + + // Called by mapbox-gl-draw's event system (capital U — distinct from onKeyup above). + // Registered on ctx.container, so only fires when the viewport has focus (keyboard drawing). + // 1. A UI element inside the viewport has focus (e.g. popup menu) → ignore, let React handle + // 2. Keyboard drawing (container focused, interfaceType === 'keyboard') → Escape restarts + // 3. Non-keyboard with container focused → skip (already handled by window onKeyup via draw.cancel) + onKeyUp (state, e) { + const activeEl = document.activeElement + if (activeEl && activeEl !== state.container && state.container.contains(activeEl)) { + return + } + if (e.key === 'Escape') { + if (state.interfaceType === 'keyboard') { + const undoStack = this.map._undoStack + if (undoStack) { + undoStack.clear() + } + this._reinitializeFeature(state, getFeature(state)) + } + // Non-keyboard already handled by onKeyup (window) via draw.cancel + return + } + if (activeEl !== state.container) { + ParentMode.onKeyUp.call(this, state, e) + } + } +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js new file mode 100644 index 000000000..fc092b34f --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js @@ -0,0 +1,75 @@ +import { setup, clickAt, firedWith, DrawPolygonMode } from './__helpers__/harness.js' + +describe('keyboard interface', () => { + test('arrow keys switch to keyboard interface, show the crosshair and move the rubber band to center', () => { + const { ctx, state, marker, container } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + container.focus() + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + expect(state.interfaceType).toBe('keyboard') + expect(marker.style.display).toBe('block') + expect(state.polygon.coordinates[0].at(-1)).toEqual([5, 5]) + expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) + }) + + test('other keys, unfocused container and Escape do not switch interface', () => { + const { ctx, state, marker, container } = setup(DrawPolygonMode) + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'ArrowRight' })) // container not focused + container.focus() + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'a' })) + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'Escape' })) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'a' })) + expect(state.interfaceType).toBe('mouse') + expect(marker.style.display).toBe('none') + }) + + test('keyup without focus is ignored', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'ArrowRight' })) + expect(state.interfaceType).toBe('mouse') + }) + + test('Enter down then up places a vertex at center', () => { + const { ctx, state, container } = setup(DrawPolygonMode) + container.focus() + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'Enter' })) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Enter' })) + expect(state.isActive).toBe(true) + expect(state.polygon.coordinates[0][0]).toEqual([5, 5]) + }) + + test('Escape keyup cancels drawing for mouse/touch but not keyboard', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Escape' })) + expect(firedWith(ctx.map, 'draw.cancel')).toHaveLength(1) + + state.interfaceType = 'keyboard' + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Escape' })) + expect(firedWith(ctx.map, 'draw.cancel')).toHaveLength(1) + }) + + test('keyup of a non-Enter interface key while focused switches interface without committing a vertex', () => { + const { ctx, state, container } = setup(DrawPolygonMode) + container.focus() + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'ArrowRight' })) + expect(state.interfaceType).toBe('keyboard') + expect(ctx.map._undoStack.length).toBe(0) + }) + + test('container keyUp with the container itself focused and a non-Escape key does not defer to the parent', () => { + const { ctx, state, container } = setup(DrawPolygonMode) + container.focus() + ctx.onKeyUp(state, { key: 'a' }) + expect(ctx.changeMode).not.toHaveBeenCalled() + }) + + test('Escape during keyboard drawing with no undo stack still reinitialises the feature', () => { + const { ctx, state, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + container.focus() + const id = state.polygon.id + ctx.map._undoStack = null + ctx.onKeyUp(state, { key: 'Escape' }) + expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id]) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js new file mode 100644 index 000000000..cdebad34b --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js @@ -0,0 +1,78 @@ +/** + * Setup / teardown for the shared draw mode: binds the window/container/map event + * handlers on entry and removes them on exit. Part of createDrawMode. + */ +export const createLifecycle = ({ ParentMode, featureProp, excludeFeatureIdFromSetup }) => ({ + onSetup (options) { + const { map } = this + + // Some parent modes (DrawLineString) interpret featureId as "continue existing" + // rather than "use this ID for new feature" + const parentOptions = excludeFeatureIdFromSetup + ? { ...options, featureId: null } + : options + + const state = { + ...ParentMode.onSetup.call(this, parentOptions), + ...options + } + + // Add initial props + state[featureProp].properties = options.properties + + const { container, vertexMarkerId, getInterfaceType } = state + const currentInterfaceType = getInterfaceType ? getInterfaceType() : state.interfaceType + state.interfaceType = currentInterfaceType + const vertexMarker = container.querySelector(`#${vertexMarkerId}`) + state.vertexMarker = vertexMarker + if (['touch', 'keyboard'].includes(currentInterfaceType)) { + this._showCrossHair(state) + } else { + this._hideCrossHair(state) + } + + // Bind all handlers once + const bind = (name, fn) => (this[name] = fn.bind(this, state)) + const handlers = { + keydownHandler: this.onKeydown, + keyupHandler: this.onKeyup, + blurHandler: this.onBlur, + createHandler: this.onCreate, + moveHandler: this.onMove, + pointerdownHandler: this.onPointerdown, + pointermoveHandler: this.onPointermove, + pointerupHandler: this.onPointerup, + vertexButtonClickHandler: this.onVertexButtonClick, + undoHandler: this.onUndo, + interfaceTypeChangeHandler: this.onInterfaceTypeChange + } + Object.entries(handlers).forEach(([k, fn]) => bind(k, fn)) + + // Register events + this._listeners = [ + [window, 'keydown', this.keydownHandler], + [window, 'keyup', this.keyupHandler], + [window, 'click', this.vertexButtonClickHandler], + [container, 'blur', this.blurHandler], + [container, 'pointermove', this.pointermoveHandler], + [container, 'pointerup', this.pointerupHandler], + [map, 'pointerdown', this.pointerdownHandler], + [map, 'draw.create', this.createHandler], + [map, 'move', this.moveHandler], + [map, 'draw.undo', this.undoHandler], + [map, 'draw.interfacetypechange', this.interfaceTypeChangeHandler] + ] + this._listeners.forEach(([t, e, h]) => t.addEventListener ? t.addEventListener(e, h) : t.on(e, h)) + + return state + }, + + onStop (state) { + ParentMode.onStop.call(this, state) + this._listeners.forEach(([t, e, h]) => t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h)) + this._hideCrossHair(state) + // Sync the final interfaceType from draw mode back to app state so crosshair + // visibility is correct when exiting draw mode (e.g., if user switched from mouse to keyboard) + this.map.fire('draw.interfacetypechange', { interfaceType: state.interfaceType }) + } +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js new file mode 100644 index 000000000..d4c7043e7 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js @@ -0,0 +1,48 @@ +import { setup, firedWith, DrawPolygonMode, DrawLineMode } from './__helpers__/harness.js' + +describe('setup and crosshair', () => { + test('mouse interface hides the vertex marker, keyboard shows it', () => { + const mouse = setup(DrawPolygonMode) + expect(mouse.marker.style.display).toBe('none') + + const keyboard = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + expect(keyboard.marker.style.display).toBe('block') + }) + + test('getInterfaceType() takes precedence over the static interfaceType option', () => { + const { state, marker } = setup(DrawPolygonMode, { interfaceType: 'mouse', getInterfaceType: () => 'touch' }) + expect(state.interfaceType).toBe('touch') + expect(marker.style.display).toBe('block') + }) + + test('a crossHair object is used instead of the vertex marker when provided', () => { + const crossHair = { show: jest.fn(), hide: jest.fn() } + const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard', crossHair }) + expect(crossHair.show).toHaveBeenCalled() + ctx.pointermoveHandler({ pointerType: 'mouse' }) + expect(crossHair.hide).toHaveBeenCalled() + ctx.onBlur(state, { target: document.body }) + expect(crossHair.hide).toHaveBeenCalledTimes(2) + }) + + test('line mode creates a fresh feature even when featureId is passed', () => { + const { state } = setup(DrawLineMode) + expect(state.line).toBeDefined() + expect(state.featureId).toBe('shape-1') + }) +}) + +describe('onStop', () => { + test('removes listeners, hides the crosshair and reports the final interface type', () => { + const { ctx, state, marker, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.onStop(state) + expect(marker.style.display).toBe('none') + expect(firedWith(ctx.map, 'draw.interfacetypechange')).toEqual([{ interfaceType: 'keyboard' }]) + + // Window/container/map listeners are gone: keydown no longer shows the crosshair + container.focus() + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + expect(marker.style.display).toBe('none') + expect(ctx.map.off).toHaveBeenCalledWith('draw.create', ctx.createHandler) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js new file mode 100644 index 000000000..fd7137ac1 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js @@ -0,0 +1,103 @@ +import { + getSnapInstance, isSnapEnabled, getSnapLngLat, triggerSnapAtPoint, triggerSnapAtCenter +} from '../../utils/snapHelpers.js' + +/** + * Pointer / touch handling for the shared draw mode: touch and mouse interface + * switching, rubber-band movement (with snapping) and blur. Part of createDrawMode. + */ +export const createPointerHandlers = ({ ParentMode, getFeature, getCoords }) => ({ + onTouchStart (state, e) { + this._setInterface(state, 'touch') + this.onMove(state, e) + }, + + onTouchEnd (state, e) { + this._setInterface(state, 'touch') + this.onMove(state, e) + }, + + // The global interface type (e.g. switching to touch and panning via MoveControl) + // can change mid-session without any touch/pointer/key event ever landing on the + // map container, so this can't rely on onTouchStart/onPointerdown alone — refresh + // the rubber band immediately rather than waiting for the next incidental 'move'. + // Also fires once on every mode entry (DrawInit.jsx syncs setInterfaceType whenever + // draw_polygon/draw_line starts), so this must only show the crosshair for + // touch/keyboard — same as onSetup/onMove — not unconditionally like touch's own + // onTouchStart/onTouchEnd, or a mouse-driven session would flash it on every entry. + onInterfaceTypeChange (state, e) { + this._setInterface(state, e.interfaceType, ['touch', 'keyboard'].includes(e.interfaceType)) + this.onMove(state) + }, + + onBlur (state, e) { + if (e.target !== state.container) { + this._hideCrossHair(state) + } + }, + + onMouseMove (state, e) { + if (isSnapEnabled(state)) { + const snap = getSnapInstance(this.map) + triggerSnapAtPoint(snap, this.map, e.point) + + const snappedLngLat = getSnapLngLat(snap) + if (snappedLngLat) { + e = { ...e, lngLat: snappedLngLat } + } + } + + ParentMode.onMouseMove.call(this, state, e) + + // Fired after the parent updates the rubber band so the payload (and the live + // invalid-stroke check driven by it) reflects the current cursor position. + this.map.fire('draw.geometrychange', state.polygon || state.line) + }, + + onMove (state) { + if (['touch', 'keyboard'].includes(state.interfaceType)) { + if (isSnapEnabled(state)) { + triggerSnapAtCenter(getSnapInstance(this.map), this.map) + } + + const snap = getSnapInstance(this.map) + const snappedLngLat = isSnapEnabled(state) && getSnapLngLat(snap) + + if (snappedLngLat) { + const point = this.map.project([snappedLngLat.lng, snappedLngLat.lat]) + ParentMode.onMouseMove.call(this, state, { + lngLat: snappedLngLat, + point, + originalEvent: new MouseEvent('mousemove', { + clientX: point.x, + clientY: point.y, + bubbles: true, + cancelable: true + }) + }) + this._ctx.store.render() + // Parity with _simulateMouse: report the rubber-band move so the live + // invalid-stroke check sees snapped touch/keyboard moves too. + this.map.fire('draw.geometrychange', state.polygon || state.line) + } else { + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + } + } + }, + + onPointerdown (state, e) { + if (e.pointerType !== 'touch') { + this._setInterface(state, 'mouse', false) + } + }, + + onPointermove (state, e) { + if (e.pointerType !== 'touch') { + this._hideCrossHair(state) + } + }, + + onPointerup (state) { + this.dispatchVertexChange(getCoords(getFeature(state))) + } +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js new file mode 100644 index 000000000..226bfe132 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js @@ -0,0 +1,127 @@ +import { setup, clickAt, firedWith, activeSnap, DrawPolygonMode, DrawLineMode, CENTER } from './__helpers__/harness.js' + +describe('touch and pointer interface', () => { + test('touch start/end switch to touch interface and show the crosshair', () => { + const { ctx, state, marker } = setup(DrawPolygonMode) + ctx.onTouchStart(state, {}) + expect(state.interfaceType).toBe('touch') + expect(marker.style.display).toBe('block') + ctx.onTouchEnd(state, {}) + expect(state.interfaceType).toBe('touch') + }) + + test('non-touch pointerdown switches back to mouse without showing the crosshair; touch does not', () => { + const { ctx, state, marker } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.map.fire('pointerdown', { pointerType: 'mouse' }) + expect(state.interfaceType).toBe('mouse') + expect(marker.style.display).toBe('block') // unchanged, not re-shown or hidden + + ctx.map.fire('pointerdown', { pointerType: 'touch' }) + expect(state.interfaceType).toBe('mouse') + }) + + test('mouse pointermove hides the crosshair; touch pointermove does not', () => { + const { ctx, marker } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.pointermoveHandler({ pointerType: 'touch' }) + expect(marker.style.display).toBe('block') + ctx.pointermoveHandler({ pointerType: 'mouse' }) + expect(marker.style.display).toBe('none') + }) + + test('pointerup reports the current vertex count', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + ctx.map.fire.mockClear() + ctx.pointerupHandler({}) + expect(firedWith(ctx.map, 'draw.vertexchange')).toEqual([{ numVertecies: 1 }]) + }) + + test('blur away from the container hides the crosshair; blur on the container does not', () => { + const { ctx, marker, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.blurHandler({ target: container }) + expect(marker.style.display).toBe('block') + ctx.blurHandler({ target: document.body }) + expect(marker.style.display).toBe('none') + }) + + test('draw.interfacetypechange (e.g. switching to touch and panning via MoveControl mid-session) updates the interface and refreshes the rubber band immediately', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + ctx.map.fire('draw.interfacetypechange', { interfaceType: 'touch' }) + expect(state.interfaceType).toBe('touch') + expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) + }) + + test('draw.interfacetypechange to touch/keyboard shows the crosshair; to mouse it does not', () => { + const { ctx, marker } = setup(DrawPolygonMode) + ctx.map.fire('draw.interfacetypechange', { interfaceType: 'touch' }) + expect(marker.style.display).toBe('block') + + marker.style.display = 'none' // reset, isolate the next assertion + ctx.map.fire('draw.interfacetypechange', { interfaceType: 'keyboard' }) + expect(marker.style.display).toBe('block') + }) + + test('draw.interfacetypechange to mouse never shows the crosshair — fires on every mode entry regardless of device', () => { + const { ctx, marker } = setup(DrawPolygonMode) + ctx.map.fire('draw.interfacetypechange', { interfaceType: 'mouse' }) + expect(marker.style.display).toBe('none') + }) +}) + +describe('rubber band and snapping while moving', () => { + test('onMouseMove moves the rubber band, preferring the snapped position', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + clickAt(ctx, state, 0, 0) + ctx.map._snapInstance = activeSnap() + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(ctx.map._snapInstance.snapToClosestPoint).toHaveBeenCalled() + expect(state.polygon.coordinates[0].at(-1)).toEqual([9, 9]) + expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) + }) + + test('onMouseMove without snap uses the pointer position', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(state.polygon.coordinates[0].at(-1)).toEqual([3, 3]) + }) + + test('onMouseMove with snapping enabled but no snap point falls back to the pointer position', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + clickAt(ctx, state, 0, 0) + ctx.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(state.polygon.coordinates[0].at(-1)).toEqual([3, 3]) + }) + + test('map move in keyboard interface keeps the rubber band at center, or at the snap point when snapping', () => { + const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + ctx.map.fire('move') + expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) + + state.getSnapEnabled = () => true + ctx.map._snapInstance = activeSnap() + ctx.map.fire('move') + expect(ctx.map._snapInstance.snapToClosestPoint).toHaveBeenCalled() + expect(state.polygon.coordinates[0].at(-1)).toEqual([9, 9]) + }) + + test('map move in mouse interface leaves the rubber band alone', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + const before = [...state.polygon.coordinates[0].map((c) => [...c])] + ctx.map.fire('move') + expect(state.polygon.coordinates[0]).toEqual(before) + }) + + test('map move in keyboard line mode with snap fires geometrychange with state.line', () => { + const { ctx, state } = setup(DrawLineMode, { interfaceType: 'keyboard', getSnapEnabled: () => true }) + clickAt(ctx, state, 0, 0) + ctx.map._snapInstance = activeSnap() + ctx.map.fire.mockClear() + ctx.map.fire('move') + expect(firedWith(ctx.map, 'draw.geometrychange').pop()).toBe(state.line) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js new file mode 100644 index 000000000..c9b0726ee --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js @@ -0,0 +1,59 @@ +/** + * Rendering helpers for the shared draw mode: crosshair/vertex-marker visibility, + * interface switching, simulated mouse events at map centre, and the display-feature + * pass that adds a marker on every placed vertex. Part of createDrawMode. + */ +export const createRenderHelpers = ({ ParentMode, geometryType, getFeature, getPlacedCoords }) => ({ + _simulateMouse (type, fn, state) { + const { map } = this + const center = map.getCenter() + const point = map.project(center) + fn.call(this, state, { + lngLat: center, + point, + originalEvent: new MouseEvent(type, { + clientX: point.x, + clientY: point.y, + bubbles: true, + cancelable: true + }) + }) + this._ctx.store.render() + + this.map.fire('draw.geometrychange', state.polygon || state.line) + }, + + _showCrossHair (state) { + if (state.crossHair) { state.crossHair.show() } else { state.vertexMarker.style.display = 'block' } + }, + + _hideCrossHair (state) { + if (state.crossHair) { state.crossHair.hide() } else { state.vertexMarker.style.display = 'none' } + }, + + _setInterface (state, type, show = true) { + state.interfaceType = type + if (show) { + this._showCrossHair(state) + } + }, + + toDisplayFeatures (state, geojson, display) { + ParentMode.toDisplayFeatures.call(this, state, geojson, display) + + // Display features carry the id in properties.id (no top-level id) + const feature = getFeature(state) + if (geojson.geometry.type === geometryType && geojson.properties.id === feature.id) { + // Parent modes render only some placed vertices (which ones varies by + // mapbox-gl-draw version) — add a marker on every placed vertex. The + // 'draw-vertex' meta is display-only: it's not in mapbox-gl-draw's + // META_TYPES, so featuresAt ignores these markers and the parent's own + // vertex click targets (click first/last to finish) keep working. + getPlacedCoords(geojson).forEach((coordinates) => display({ + type: 'Feature', + properties: { meta: 'draw-vertex', parent: feature.id, active: 'false' }, + geometry: { type: 'Point', coordinates } + })) + } + } +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js new file mode 100644 index 000000000..516f01e13 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js @@ -0,0 +1,34 @@ +import { setup, clickAt, DrawPolygonMode, DrawLineMode } from './__helpers__/harness.js' + +describe('vertex marker display', () => { + const displayed = (ctx, state, geometry, id) => { + const display = jest.fn() + ctx.toDisplayFeatures(state, { type: 'Feature', properties: { id }, geometry }, display) + return display.mock.calls.map(([f]) => f).filter((f) => f.properties.meta === 'draw-vertex') + } + + test('every placed polygon vertex gets a display-only draw-vertex marker', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + const ring = [[0, 0], [10, 0], [10, 10], [5, 5], [0, 0]] // placed + rubber + closing + const markers = displayed(ctx, state, { type: 'Polygon', coordinates: [ring] }, state.polygon.id) + expect(markers.map((m) => m.geometry.coordinates)).toEqual([[0, 0], [10, 0], [10, 10]]) + expect(markers[0].properties).toEqual({ meta: 'draw-vertex', parent: state.polygon.id, active: 'false' }) + }) + + test('every placed line vertex gets a marker', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + const coords = [[5, 5], [8, 8]] // placed + rubber + const markers = displayed(ctx, state, { type: 'LineString', coordinates: coords }, state.line.id) + expect(markers.map((m) => m.geometry.coordinates)).toEqual([[5, 5]]) + }) + + test('other features get no markers', () => { + const { ctx, state } = setup(DrawPolygonMode) + const ring = [[0, 0], [10, 0], [10, 10], [0, 0]] + expect(displayed(ctx, state, { type: 'Polygon', coordinates: [ring] }, 'other-feature')).toEqual([]) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js new file mode 100644 index 000000000..5f62f1022 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js @@ -0,0 +1,193 @@ +/** + * Undo handling for the shared draw mode: pushing draw_vertex operations, undoing the + * last placed vertex, reinitialising a feature, and the rubber-band update that follows. + * Part of createDrawMode. + */ + +// Undo-stack and event wiring: pushing operations and reacting to undo triggers. +const createUndoStackHandlers = ({ geometryType, getFeature }) => ({ + /** + * Push an undo operation for the last added vertex + */ + pushDrawUndo (state) { + const undoStack = this.map._undoStack + // Don't push during undo operations + if (!undoStack || this.map._undoInProgress) { + return + } + undoStack.push({ + type: 'draw_vertex', + geometryType, + featureId: getFeature(state).id + }) + }, + + /** + * Handle the draw.undo event (fired by the undo button via the adapter). Pops the + * undo stack itself so it works regardless of whether the caller passes an operation. + */ + onUndo (state) { + const undoStack = this.map._undoStack + if (!undoStack || undoStack.length === 0) { + return + } + const operation = undoStack.pop() + if (operation?.type === 'draw_vertex') { + // Prevent click interference during undo + this.map._undoInProgress = true + setTimeout(() => { this.map._undoInProgress = false }, 100) + this.undoVertex(state) + // An undo commits a vertex removal, so it must re-validate like any other + // commit — otherwise the Done gate goes stale. + this.emitDrawValidation(state, 'commit-delete') + } + }, + + _handleUndoKeydown (state, e) { + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') { + return + } + e.preventDefault() + e.stopPropagation() + this.onUndo(state) + } +}) + +// Vertex-level undo: removing the last vertex and keeping the rubber band in sync. +const createVertexUndoHandlers = ({ ParentMode, geometryType, getCoords, getFeature, RUBBER_BAND_OFFSET }) => ({ + /** + * Undo the last added vertex during drawing + */ + undoVertex (state) { + const feature = getFeature(state) + const coords = getCoords(feature) + + if (coords.length < 2) { + return false + } + + // Undoing last vertex requires reinitializing the feature + if (coords.length === 2) { + return this._reinitializeFeature(state, feature) + } + + this._removeLastVertex(state, feature, coords) + return true + }, + + /** + * Remove the last committed vertex and update rubber band + */ + _removeLastVertex (state, feature, coords) { + // Structure during drawing: [v1, v2, ..., vN, rubber_band] + const ring = geometryType === 'Polygon' ? feature.coordinates[0] : coords + ring.splice(-RUBBER_BAND_OFFSET, 1) + + // Snap rubber band to new last vertex position. undoVertex only calls here with + // 3+ coords, so after the splice the ring always has a preceding vertex. + ring[ring.length - 1] = [...ring[ring.length - 2]] + + // Keep parent mode's vertex counter in sync (min 1 for rubber band) + state.currentVertexPosition = Math.max(1, state.currentVertexPosition - 1) + + this._ctx.store.render() + this._updateRubberBand(state, getCoords(feature)) + }, + + /** + * Update rubber band position based on interface type + */ + _updateRubberBand (state, coords) { + if (['touch', 'keyboard'].includes(state.interfaceType)) { + // Touch/keyboard: move to map center for add point to work + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + this._ctx.store.render() + } else { + // Mouse: keep rubber band at current position. After a vertex removal the + // rubber band index always resolves to a real coordinate. + const rubberBandIndex = geometryType === 'Polygon' ? coords.length - 2 : coords.length - 1 + const rubberBandPos = coords[rubberBandIndex] + const lngLat = { lng: rubberBandPos[0], lat: rubberBandPos[1] } + const point = this.map.project(lngLat) + ParentMode.onMouseMove.call(this, state, { + lngLat, + point, + originalEvent: new MouseEvent('mousemove', { clientX: point.x, clientY: point.y }) + }) + this._ctx.store.render() + // Parity with _simulateMouse: an undo can add/remove a crossing, so the live + // invalid-stroke check must see the new displayed geometry immediately. + this.map.fire('draw.geometrychange', state.polygon || state.line) + } + this.dispatchVertexChange(coords) + } +}) + +// Reinitialising back to zero vertices: reinitialize Polygon in place, restart LineString. +const createFeatureReinitHandlers = ({ ParentMode, featureProp, geometryType }) => ({ + /** + * Reinitialize feature when undoing to 0 vertices + */ + _reinitializeFeature (state, feature) { + const featureId = feature.id + this._ctx.store.delete([featureId]) + + // LineString: restart the draw mode with fresh state but same feature ID + if (geometryType === 'LineString') { + return this._restartLineStringDraw(state, featureId) + } + + // Polygon: reinitialize in place + const center = this.map.getCenter() + const initialCoords = [[center.lng, center.lat], [center.lng, center.lat]] + const newFeature = this.newFeature({ + type: 'Feature', + properties: state.properties || {}, + geometry: { + type: geometryType, + coordinates: [initialCoords] + } + }) + newFeature.id = featureId + this._ctx.store.add(newFeature) + + state[featureProp] = newFeature + state.currentVertexPosition = 0 + + this._ctx.store.render() + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + this._ctx.store.render() + + this.dispatchVertexChange(initialCoords) + return true + }, + + /** + * Restart the LineString draw mode with fresh state but the same feature ID + */ + _restartLineStringDraw (state, featureId) { + const undoStack = this.map._undoStack + if (undoStack) { + undoStack.clear() + } + // Restart draw with same options (excludeFeatureIdFromSetup prevents "continue" mode) + this._ctx.api.changeMode('draw_line', { + featureId, + container: state.container, + interfaceType: state.interfaceType, + crossHair: state.crossHair, + vertexMarkerId: state.vertexMarkerId, + addVertexButtonId: state.addVertexButtonId, + getSnapEnabled: state.getSnapEnabled, + properties: state.properties + }) + return true + } +}) + +export const createUndoHandlers = (deps) => ({ + ...createUndoStackHandlers(deps), + ...createVertexUndoHandlers(deps), + ...createFeatureReinitHandlers(deps) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js new file mode 100644 index 000000000..fb1c0c6cf --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js @@ -0,0 +1,195 @@ +import { setup, clickAt, firedWith, DrawPolygonMode, DrawLineMode, CENTER } from './__helpers__/harness.js' + +describe('cmd/ctrl+z undo', () => { + const undoKey = (overrides = {}) => { + const e = new KeyboardEvent('keydown', { key: 'z', metaKey: true, ...overrides }) + e.preventDefault = jest.fn() + e.stopPropagation = jest.fn() + return e + } + + test('removes the last placed vertex without switching to keyboard interface', () => { + jest.useFakeTimers() + const { ctx, state, marker } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + + ctx.keydownHandler(undoKey()) + expect(state.polygon.coordinates[0].map(([x]) => x)).toEqual([0, 10, 10]) + expect(state.polygon.coordinates[0]).toHaveLength(3) + expect(state.interfaceType).toBe('mouse') + expect(marker.style.display).toBe('none') + expect(ctx.map._undoInProgress).toBe(true) + jest.advanceTimersByTime(100) + expect(ctx.map._undoInProgress).toBe(false) + }) + + test('mouse interface keeps the rubber band on the remaining last vertex', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.keydownHandler(undoKey()) + const ring = state.polygon.coordinates[0] + expect(ring[ring.length - 1]).toEqual(ring[ring.length - 2]) + }) + + test('is ignored when typing in an input, when the stack is empty, or for other operations', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + ctx.keydownHandler(undoKey()) + expect(state.polygon.coordinates[0]).toHaveLength(3) + input.blur() + + ctx.map._undoStack.clear() + ctx.keydownHandler(undoKey()) + ctx.map._undoStack.push({ type: 'edit_vertex' }) + ctx.keydownHandler(undoKey()) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('cmd+shift+z is not treated as undo', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + ctx.keydownHandler(undoKey({ shiftKey: true })) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('ctrl+z (Windows/Linux) also undoes the last vertex', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.keydownHandler(undoKey({ metaKey: false, ctrlKey: true })) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) +}) + +describe('undo via draw.undo event and reinitialisation', () => { + test('draw.undo pops the stack and removes the last vertex; non-draw ops are ignored', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + // A non-draw op on top of the stack is popped but not undone. + ctx.map._undoStack.push({ type: 'edit_vertex' }) + ctx.map.fire('draw.undo') + expect(state.polygon.coordinates[0]).toHaveLength(4) + // The next op is a draw_vertex → the last vertex is removed. + ctx.map.fire('draw.undo') + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('undo re-validates the committed shape (deferred, phase commit-delete)', () => { + jest.useFakeTimers() + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + jest.runAllTimers() + ctx.map.fire.mockClear() + ctx.onUndo(state) + jest.runAllTimers() + expect(firedWith(ctx.map, 'draw.geometrychange').pop()).toEqual(expect.objectContaining({ + phase: 'commit-delete', + feature: expect.any(Object) + })) + jest.useRealTimers() + }) + + test('keyboard interface moves the rubber band to center after undo', () => { + const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.undoVertex(state) + expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) + }) + + test('undoVertex returns false with nothing placed', () => { + const { ctx, state } = setup(DrawPolygonMode) + expect(ctx.undoVertex(state)).toBe(false) + }) + + test('undoing the only polygon vertex reinitialises the feature in place with the same id', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + const id = state.polygon.id + expect(ctx.undoVertex(state)).toBe(true) + expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id]) + expect(state.polygon.id).toBe(id) + expect(state.currentVertexPosition).toBe(0) + expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 1 }) + }) + + test('undoing the only line vertex restarts draw_line with the same feature id', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.map._undoStack.push({ type: 'draw_vertex' }) + expect(ctx.undoVertex(state)).toBe(true) + expect(ctx.map._undoStack.length).toBe(0) + expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + featureId: state.line.id, + container: state.container, + properties: state.properties + })) + }) + + test('Escape (container keyUp) during keyboard drawing clears the undo stack and reinitialises', () => { + const { ctx, state, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + container.focus() + const id = state.polygon.id + ctx.onKeyUp(state, { key: 'Escape' }) + expect(ctx.map._undoStack.length).toBe(0) + expect(state.polygon.id).toBe(id) + }) + + test('container keyUp defers to the parent mode when focus is elsewhere, and ignores focus inside the container', () => { + const { ctx, state, button, container } = setup(DrawPolygonMode) + ctx.onKeyUp(state, { key: 'Enter' }) // focus not on container → parent finishes on Enter + expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', expect.anything()) + + button.focus() + ctx.changeMode.mockClear() + ctx.onKeyUp(state, { key: 'Enter' }) // focus on a child element → ignored + expect(ctx.changeMode).not.toHaveBeenCalled() + + container.focus() + ctx.onKeyUp(state, { key: 'Escape' }) // non-keyboard Escape → handled by window keyup instead + expect(ctx._ctx.store.delete).not.toHaveBeenCalled() + }) +}) + +describe('undo-stack and reinitialisation edge cases', () => { + test('placing a vertex with no undo stack does not throw and still places the vertex', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.map._undoStack = null + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0][0]).toEqual([0, 0]) + }) + + test('reinitialising a polygon without properties uses an empty properties object', () => { + const { ctx, state } = setup(DrawPolygonMode, { properties: undefined }) + clickAt(ctx, state, 0, 0) + expect(ctx.undoVertex(state)).toBe(true) + expect(state.polygon.properties).toEqual({}) + }) + + test('restarting a line with no undo stack still changes mode', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.map._undoStack = null + expect(ctx.undoVertex(state)).toBe(true) + expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + featureId: state.line.id + })) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js b/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js new file mode 100755 index 000000000..e3048a695 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js @@ -0,0 +1,16 @@ +import MapboxDraw from '@mapbox/mapbox-gl-draw' +import { isValidClick } from '../../../utils/spatial.js' +import { createDrawMode } from './createDrawMode.js' + +// During drawing the ring is [v0...vN, rubber_band, v0_closing]; the last two are not placed vertices +const RUBBER_BAND_AND_CLOSING = 2 + +// Extend the built-in mode via the package's public API (MapboxDraw.modes) rather than a deep internal import +export const DrawPolygonMode = createDrawMode(MapboxDraw.modes.draw_polygon, { + featureProp: 'polygon', + geometryType: 'Polygon', + getCoords: (feature) => feature.coordinates[0], + validateClick: (feature) => isValidClick(feature.coordinates), + // Display ring during drawing: [v0...vN, rubber_band, v0_closing] + getPlacedCoords: (geojson) => geojson.geometry.coordinates[0].slice(0, -RUBBER_BAND_AND_CLOSING) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js b/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js new file mode 100644 index 000000000..bbc57151b --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js @@ -0,0 +1,27 @@ +import { setup, clickAt, DrawPolygonMode } from './drawMode/__helpers__/harness.js' + +const drawVertexMarkers = (ctx, state, geometry) => { + const display = jest.fn() + ctx.toDisplayFeatures(state, { type: 'Feature', properties: { id: state.polygon.id }, geometry }, display) + return display.mock.calls.map(([f]) => f) + .filter((f) => f.properties.meta === 'draw-vertex') + .map((m) => m.geometry.coordinates) +} + +describe('DrawPolygonMode config', () => { + test('getCoords reads the polygon ring; validateClick (isValidClick) rejects a duplicate click', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + expect(state.polygon.coordinates[0].map(([x]) => x)).toEqual([0, 10, 10]) // v0, v1, rubber band + clickAt(ctx, state, 10, 0) // same spot → rejected by isValidClick + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('getPlacedCoords marks every placed vertex, excluding the rubber band and closing point', () => { + const { ctx, state } = setup(DrawPolygonMode) + // Display ring: [v0..vN, rubber_band, v0_closing] → placed = slice(0, -2) + const ring = [[0, 0], [10, 0], [10, 10], [5, 5], [0, 0]] + expect(drawVertexMarkers(ctx, state, { type: 'Polygon', coordinates: [ring] })).toEqual([[0, 0], [10, 0], [10, 10]]) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js new file mode 100755 index 000000000..4a392754a --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js @@ -0,0 +1,249 @@ +import MapboxDraw from '@mapbox/mapbox-gl-draw' +import { getSnapInstance, clearSnapIndicator } from '../utils/snapHelpers.js' +import { getCoords } from './editVertexMode/geometryHelpers.js' +import { scalePoint } from './editVertexMode/helpers.js' +import { undoHandlers } from './editVertexMode/undoHandlers.js' +import { touchHandlers } from './editVertexMode/touchHandlers.js' +import { vertexOperations } from './editVertexMode/vertexOperations.js' +import { vertexQueries } from './editVertexMode/vertexQueries.js' +import { keyboardHandlers } from './editVertexMode/keyboardHandlers.js' +import { pointerHandlers } from './editVertexMode/pointerHandlers.js' + +const EVENT_VERTEX_SELECTION = 'draw.vertexselection' +const EVENT_NUDGE_VERTEX = 'draw.nudgevertex' + +export const EditVertexMode = { + ...MapboxDraw.modes.direct_select, + ...undoHandlers, + ...touchHandlers, + ...vertexOperations, + ...vertexQueries, + ...keyboardHandlers, + ...pointerHandlers, + + onSetup (options) { + const state = MapboxDraw.modes.direct_select.onSetup.call(this, options) + Object.assign(state, { + container: options.container, + interfaceType: options.interfaceType, + deleteVertexButtonId: options.deleteVertexButtonId, + undoButtonId: options.undoButtonId, + isPanEnabled: options.isPanEnabled, + getSnapEnabled: options.getSnapEnabled, + featureId: state.featureId, + selectedVertexIndex: options.selectedVertexIndex ?? -1, + selectedVertexType: options.selectedVertexType, + coordPath: options.coordPath, + scale: options.scale ?? 1 + }) + + // Clear undo stack only on initial entry to edit mode for a feature + // Only clear if we're starting a new editing session (not already editing) + if (this.map._lastEditFeatureId !== state.featureId) { + this.map._undoStack?.clear() + this.map._lastEditFeatureId = state.featureId + } + + // Get feature type for later reference + const feature = this.getFeature(state.featureId) + state.featureType = feature?.type + + state.vertecies = this.getVerticies(state.featureId) + state.midpoints = this.getMidpoints(state.featureId) + this.setupEventListeners(state) + + this.applyVertexSelection(state, options) + this.map._drawEditContainer = options.container + this.addTouchVertexTarget(state) + + // Clear any snap indicator when entering edit mode + const snap = getSnapInstance(this.map) + if (snap) { + clearSnapIndicator(snap, this.map) + } + + // Show touch target if entering with a selected vertex on touch interface + if (state.interfaceType === 'touch' && state.selectedVertexIndex >= 0 && state.selectedVertexType === 'vertex') { + const vertex = state.vertecies[state.selectedVertexIndex] + if (vertex) { + setTimeout(() => { + this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) + }, 0) + } + } + + // Ignore pointermove deselection briefly after setup to let Safari settle + state._ignorePointermoveDeselect = true + setTimeout(() => { state._ignorePointermoveDeselect = false }, 100) + + return state + }, + + setupEventListeners (state) { + const bind = (fn) => (e) => fn.call(this, state, e) + const h = this.handlers = { + keydown: bind(this.onKeydown), + keyup: bind(this.onKeyup), + pointerdown: bind(this.onPointerevent), + pointermove: bind(this.onPointerevent), + pointerup: bind(this.onPointerevent), + click: bind(this.onButtonClick), + touchstart: bind(this.onTouchstart), + touchmove: bind(this.onTouchmove), + touchend: bind(this.onTouchend), + selectionchange: bind(this.onSelectionChange), + scalechange: bind(this.onScaleChange), + update: bind(this.onUpdate), + move: bind(this.onMove), + interfacetypechange: bind(this.onInterfaceTypeChange), + nudgevertex: bind(this.onNudgeVertex) + } + + window.addEventListener('keydown', h.keydown, { capture: true }) + window.addEventListener('keyup', h.keyup, { capture: true }) + window.addEventListener('click', h.click) + state.container.addEventListener('pointerdown', h.pointerdown) + state.container.addEventListener('pointermove', h.pointermove) + state.container.addEventListener('pointerup', h.pointerup) + state.container.addEventListener('touchstart', h.touchstart, { passive: false }) + state.container.addEventListener('touchmove', h.touchmove, { passive: false }) + state.container.addEventListener('touchend', h.touchend, { passive: false }) + this.map.on('draw.selectionchange', h.selectionchange) + this.map.on('draw.scalechange', h.scalechange) + this.map.on('draw.update', h.update) + this.map.on('move', h.move) + this.map.on('draw.interfacetypechange', h.interfacetypechange) + this.map.on(EVENT_NUDGE_VERTEX, h.nudgevertex) + }, + + applyVertexSelection (state, options) { + if (options.selectedVertexType === 'midpoint') { + state.selectedCoordPaths = [] + this.clearSelectedCoordinates() + state.feature.changed() + this._ctx.store.render() + this.updateMidpoint(state.midpoints[options.selectedVertexIndex - state.vertecies.length]) + return + } + if (options.selectedVertexIndex === -1) { + state.selectedCoordPaths = [] + this.clearSelectedCoordinates() + state.feature.changed() + this._ctx.store.render() + } + }, + + onSelectionChange (state, e) { + // Refresh vertex list so numVertecies reflects the latest geometry (e.g. after midpoint insertion) + this.syncVertices(state) + + const vertexCoord = e.points[e.points.length - 1]?.geometry.coordinates + + // Only update selectedVertexIndex from event if not keyboard mode AND event has valid vertex + // For keyboard mode or when we have coordPath, trust the existing selectedVertexIndex + if (state.interfaceType !== 'keyboard' && vertexCoord && !state.coordPath) { + // No coordPath available - need to search for vertex by coordinates + const geom = e.features[0]?.geometry + const coords = getCoords(geom) + state.selectedVertexIndex = this.findVertexIndex(coords, vertexCoord, state.selectedVertexIndex) + } + // If we have coordPath, selectedVertexIndex is already correct from onTap/changeMode + + state.selectedVertexType ??= state.selectedVertexIndex >= 0 ? 'vertex' : null + + this.map.fire(EVENT_VERTEX_SELECTION, { + index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, + numVertecies: state.vertecies.length + }) + + // Use vertex from event if available, otherwise fall back to state + const vertex = vertexCoord || (state.selectedVertexIndex >= 0 ? state.vertecies[state.selectedVertexIndex] : null) + this.updateTouchVertexTarget(state, vertex ? scalePoint(this.map.project(vertex), state.scale) : null) + }, + + onScaleChange (state, e) { + state.scale = e.scale + }, + + onInterfaceTypeChange (state, e) { + state.interfaceType = e.interfaceType + const vertex = state.selectedVertexIndex >= 0 ? state.vertecies[state.selectedVertexIndex] : null + this.updateTouchVertexTarget(state, vertex ? scalePoint(this.map.project(vertex), state.scale) : null) + }, + + onUpdate (state) { + const prev = new Set(state.vertecies.map(c => JSON.stringify(c))) + if (prev.size === state.vertecies.length) { + return + } + // Duplicate coordinates exist (e.g. a self-touching ring). Comparing the list + // against itself cannot surface a distinct new vertex, so clear the selection. + state.selectedVertexIndex = -1 + state.selectedVertexType ??= null + }, + + onMove (state) { + const vertex = state.vertecies[state.selectedVertexIndex] + if (vertex) { + this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) + } + }, + + // Inbound signal from MaplibreDrawAdapter.nudgeSelectedVertex — bridges into the + // running mode the same way onInterfaceTypeChange does, since the adapter has no + // direct reference to this mode's live state. Unlike OL (where setState's shared + // onUpdate hook repositions the touch target for any vertex move), moveVertex + // here has no equivalent hook, so the reposition has to happen explicitly — + // same as onMove/onSelectionChange/onInterfaceTypeChange already do. + onNudgeVertex (state, e) { + this.nudgeVertexByDelta(state, e.dx, e.dy, e.isLargeStep) + const vertex = state.vertecies[state.selectedVertexIndex] + if (vertex) { + this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) + } + }, + + onButtonClick (state, e) { + if (e.target.closest(`#${state.deleteVertexButtonId}`) && state.selectedVertexType === 'vertex') { + this.deleteVertex(state) + } + if (e.target.closest(`#${state.undoButtonId}`)) { + this.handleUndo(state) + } + }, + + clickNoTarget (state) { + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null, isPanEnabled: true }) + }, + + // Prevent selecting other features + changeMode (state, updates) { + if (!state.featureId) { + return + } + this._ctx.api.changeMode('edit_vertex', { ...state, ...updates }) + }, + + onStop (state) { + this.map._drawEditContainer = null + this.map._editingFeatureId = null + const h = this.handlers + state.container.removeEventListener('pointerdown', h.pointerdown) + state.container.removeEventListener('pointermove', h.pointermove) + state.container.removeEventListener('pointerup', h.pointerup) + state.container.removeEventListener('touchstart', h.touchstart) + state.container.removeEventListener('touchmove', h.touchmove) + state.container.removeEventListener('touchend', h.touchend) + this.map.off('draw.selectionchange', h.selectionchange) + this.map.off('draw.scalechange', h.scalechange) + this.map.off('draw.update', h.update) + this.map.off('move', h.move) + this.map.off('draw.interfacetypechange', h.interfacetypechange) + this.map.off(EVENT_NUDGE_VERTEX, h.nudgevertex) + this.map.dragPan.enable() + window.removeEventListener('click', h.click) + window.removeEventListener('keydown', h.keydown, { capture: true }) + window.removeEventListener('keyup', h.keyup, { capture: true }) + this.hideTouchVertexIndicator(state) + } +} diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js new file mode 100644 index 000000000..debc66630 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js @@ -0,0 +1,153 @@ +import { createHarness, POLYGON } from './editVertexMode/__helpers__/harness.js' + +/** + * Tests for EditVertexMode's own methods: setup/teardown lifecycle, selection/scale/update + * events, and the small move/button/changeMode routing. The keyboard, pointer, touch, undo, + * vertex-query, vertex-operation and geometry helpers are covered in their own colocated + * test files under editVertexMode/. + */ + +describe('onSetup / onStop lifecycle', () => { + test('populates state, clears the undo stack for a new feature and registers listeners', () => { + const { ctx, state, map } = createHarness() + expect(state.featureId).toBe('feat-1') + // Real PolygonFeature stores rings without the duplicate closing coordinate + expect(state.vertecies).toHaveLength(4) + expect(state.midpoints).toHaveLength(4) + expect(map._lastEditFeatureId).toBe('feat-1') + expect(map._drawEditContainer).toBe(state.container) + expect(ctx.map.on).toHaveBeenCalledWith('draw.update', expect.any(Function)) + expect(ctx.map.on).toHaveBeenCalledWith('draw.nudgevertex', expect.any(Function)) + }) + + test('does not clear the undo stack when re-entering the same feature', () => { + const { ctx, map, options } = createHarness() + map._undoStack.push({ type: 'move_vertex' }) + // map._lastEditFeatureId is already 'feat-1' from the first setup + ctx.onSetup(options) + expect(map._undoStack.length).toBe(1) + }) + + test('onStop removes listeners and clears editing container; DirectSelect touch stubs are inert', () => { + const { ctx, state, map } = createHarness() + expect(() => { ctx.onTouchStart(); ctx.onTouchMove(); ctx.onTouchEnd() }).not.toThrow() + ctx.onStop(state) + expect(map._drawEditContainer).toBeNull() + expect(map.off).toHaveBeenCalledWith('draw.update', expect.any(Function)) + expect(map.off).toHaveBeenCalledWith('draw.nudgevertex', expect.any(Function)) + }) + + test('onSetup edge cases: clears an active snap indicator, positions or skips the touch target, clears an explicit -1 selection', () => { + jest.useFakeTimers() + const { ctx, map, options } = createHarness() + map._snapInstance = { status: true, snapStatus: false, snapCoords: null } + ctx.onSetup(options) + expect(map.getLayer).toHaveBeenCalledWith('snap-helper-circle') + + const touch = createHarness(POLYGON(), { interfaceType: 'touch', selectedVertexIndex: 0, selectedVertexType: 'vertex' }) + jest.runAllTimers() + expect(touch.state.touchVertexTarget.style.display).toBe('block') + + const outOfRange = createHarness(POLYGON(), { interfaceType: 'touch', selectedVertexIndex: 99, selectedVertexType: 'vertex' }) + jest.runAllTimers() + expect(outOfRange.state.touchVertexTarget.style.display).toBe('none') + + const cleared = createHarness(POLYGON(), { selectedVertexIndex: -1 }) + expect(cleared.ctx.clearSelectedCoordinates).toHaveBeenCalled() + }) +}) + +describe('selection, scale and update events', () => { + test('applyVertexSelection handles midpoint entry and cleared selection', () => { + jest.useFakeTimers() + const midpoint = createHarness(POLYGON(), { selectedVertexType: 'midpoint', selectedVertexIndex: 4 }) + jest.runAllTimers() + expect(midpoint.ctx.clearSelectedCoordinates).toHaveBeenCalled() + expect(midpoint.map.getSource).toHaveBeenCalledWith('mapbox-gl-draw-hot') + }) + + test('onSelectionChange searches by coordinate for mouse, trusts coordPath/keyboard otherwise', () => { + const { ctx, state, map } = createHarness() + const geom = ctx.getFeature('feat-1').toGeoJSON().geometry + ctx.onSelectionChange(state, { points: [{ geometry: { coordinates: [10, 0] } }], features: [{ geometry: geom }] }) + expect(state.selectedVertexIndex).toBe(1) + expect(map.fire).toHaveBeenCalledWith('draw.vertexselection', expect.objectContaining({ numVertecies: 4 })) + + state.coordPath = '0.2' + state.selectedVertexIndex = 2 + ctx.onSelectionChange(state, { points: [{ geometry: { coordinates: [10, 10] } }], features: [{ geometry: geom }] }) + expect(state.selectedVertexIndex).toBe(2) // trusted, not re-searched + + // Keyboard mode trusts the selection; with no event vertex it falls back to state, then null + const kb = createHarness() + const g2 = kb.ctx.getFeature('feat-1').toGeoJSON().geometry + kb.state.interfaceType = 'keyboard' + kb.state.selectedVertexIndex = 2 + kb.ctx.onSelectionChange(kb.state, { points: [{ geometry: { coordinates: [10, 10] } }], features: [{ geometry: g2 }] }) + expect(kb.state.selectedVertexType).toBe('vertex') + kb.ctx.onSelectionChange({ ...kb.state, interfaceType: 'mouse', selectedVertexType: 'vertex', selectedVertexIndex: 0 }, { points: [], features: [{ geometry: g2 }] }) + kb.ctx.onSelectionChange({ ...kb.state, interfaceType: 'mouse', selectedVertexType: null, selectedVertexIndex: -1 }, { points: [], features: [{ geometry: g2 }] }) + }) + + test('onScaleChange and onInterfaceTypeChange update state and the touch target', () => { + const { ctx, state } = createHarness() + ctx.onScaleChange(state, { scale: 2 }) + expect(state.scale).toBe(2) + state.selectedVertexIndex = 0 + ctx.onInterfaceTypeChange(state, { interfaceType: 'touch' }) + expect(state.interfaceType).toBe('touch') + ctx.onInterfaceTypeChange({ ...state, selectedVertexIndex: -1 }, { interfaceType: 'mouse' }) + }) + + test('draw.nudgevertex moves the selected vertex and repositions the touch target — the inbound bridge for MoveControl.mapProvider.activeMoveTarget', () => { + jest.useFakeTimers() + const { state, map } = createHarness(POLYGON(), { interfaceType: 'touch', selectedVertexIndex: 0, selectedVertexType: 'vertex' }) + jest.runAllTimers() // flush onSetup's deferred initial touch-target positioning + const before = [...state.vertecies[0]] + const targetBefore = { top: state.touchVertexTarget.style.top, left: state.touchVertexTarget.style.left } + map.fire('draw.nudgevertex', { dx: 1, dy: 0, isLargeStep: true }) + expect(state.vertecies[0]).not.toEqual(before) + expect({ top: state.touchVertexTarget.style.top, left: state.touchVertexTarget.style.left }).not.toEqual(targetBefore) + }) + + test('onUpdate re-selects a changed vertex only when the vertex count is ambiguous', () => { + const { ctx, state } = createHarness() + ctx.onUpdate(state) // unique coords → no change + state.vertecies = [[0, 0], [0, 0], [1, 1]] + ctx.onUpdate(state) + expect(state.selectedVertexIndex).toBe(-1) + }) +}) + +describe('move, button and changeMode routing', () => { + test('onMove keeps the touch target aligned with the selected vertex, ignoring an unselected vertex', () => { + const { ctx, state } = createHarness() + state.selectedVertexIndex = 0 + state.interfaceType = 'touch' + ctx.onMove(state) + expect(state.touchVertexTarget.style.display).toBe('block') + expect(() => ctx.onMove({ ...state, selectedVertexIndex: -1 })).not.toThrow() + }) + + test('onButtonClick deletes or undoes based on the clicked control', () => { + const { ctx, state, container } = createHarness() + const del = document.createElement('button') + del.id = 'delete-vertex' + const undo = document.createElement('button') + undo.id = 'undo-vertex' + container.append(del, undo) + const deleteSpy = jest.spyOn(ctx, 'deleteVertex').mockImplementation(() => {}) + const undoSpy = jest.spyOn(ctx, 'handleUndo').mockImplementation(() => {}) + + ctx.onButtonClick({ ...state, selectedVertexType: 'vertex' }, { target: del }) + expect(deleteSpy).toHaveBeenCalled() + ctx.onButtonClick(state, { target: undo }) + expect(undoSpy).toHaveBeenCalled() + }) + + test('changeMode is a no-op without a feature id', () => { + const { ctx, api } = createHarness() + ctx.changeMode({ featureId: null }, { selectedVertexIndex: -1 }) + expect(api.changeMode).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js new file mode 100644 index 000000000..697bb0b5d --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js @@ -0,0 +1,107 @@ +import { EditVertexMode } from '../../editVertexMode.js' +import { createUndoStack } from '../../../../../utils/undoStack.js' +import PolygonFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' +import LineStringFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' + +/** + * Shared test harness for the vertex-edit mode and its handler modules. Builds the real + * EditVertexMode (with the real DirectSelect parent and real mapbox-gl-draw feature + * classes) over test doubles for the map, store, api and DOM. Excluded from coverage. + */ + +// Deterministic projection: lngLat <-> pixel is a x10 scale. +const project = (p) => { + const [lng, lat] = Array.isArray(p) ? p : [p.lng, p.lat] + return { x: lng * 10, y: lat * 10 } +} +const unproject = ({ x, y }) => ({ lng: x / 10, lat: y / 10 }) + +export const POLYGON = () => ({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] }) +export const LINE = () => ({ type: 'LineString', coordinates: [[0, 0], [10, 0], [10, 10]] }) +export const dragEvt = (lng, lat) => ({ originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng, lat }, point: { x: lng * 10, y: lat * 10 } }) +export const svgTarget = (state) => ({ parentNode: state.touchVertexTarget }) + +const contexts = [] + +export const createHarness = (geometry = POLYGON(), setupOptions = {}) => { + const features = new Map() + const store = { + featureChanged: jest.fn(), + render: jest.fn(), + get: (id) => features.get(id) + } + const api = { + add: jest.fn((geojson) => { + const FeatureClass = geojson.geometry.type === 'Polygon' ? PolygonFeature : LineStringFeature + // Mirror Draw.add: re-create the feature so Polygon closing coords are normalised + features.set(geojson.id, new FeatureClass(_ctx, { ...geojson, id: geojson.id })) + return [geojson.id] + }), + changeMode: jest.fn(), + delete: jest.fn(), + trash: jest.fn() + } + const _ctx = { store, api, options: {} } + + const FeatureClass = geometry.type === 'Polygon' ? PolygonFeature : LineStringFeature + const feature = new FeatureClass(_ctx, { type: 'Feature', id: 'feat-1', properties: {}, geometry }) + features.set('feat-1', feature) + + const listeners = {} + const map = { + _undoStack: createUndoStack(() => {}), + _snapInstance: null, + _lastEditFeatureId: null, + _drawEditContainer: null, + _editingFeatureId: null, + _drawCurrentMapStyle: { mapColorScheme: 'light' }, + dragPan: { enable: jest.fn(), disable: jest.fn(), isEnabled: () => true }, + doubleClickZoom: { enable: jest.fn(), disable: jest.fn() }, + getSource: jest.fn(() => ({ setData: jest.fn() })), + getLayer: jest.fn(() => null), + setLayoutProperty: jest.fn(), + getCenter: () => ({ lng: 5, lat: 5 }), + project: jest.fn(project), + unproject: jest.fn(unproject), + fire: jest.fn(function (type, e) { (listeners[type] ?? []).forEach((h) => h(e)) }), + on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }), + off: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter((x) => x !== h) }) + } + + const container = document.createElement('div') + container.tabIndex = 0 + document.body.appendChild(container) + + const ctx = { + map, + _ctx, + getFeature: (id) => features.get(id), + getSelected: jest.fn(() => [feature]), + setSelected: jest.fn(), + setSelectedCoordinates: jest.fn(), + clearSelectedCoordinates: jest.fn(), + setActionableState: jest.fn(), + updateUIClasses: jest.fn(), + fire: jest.fn() + } + Object.assign(ctx, EditVertexMode) + + const options = { + container, + featureId: 'feat-1', + interfaceType: 'mouse', + deleteVertexButtonId: 'delete-vertex', + undoButtonId: 'undo-vertex', + isPanEnabled: true, + ...setupOptions + } + const state = ctx.onSetup(options) + contexts.push({ ctx, state }) + return { ctx, state, feature, features, map, store, api, container, options } +} + +afterEach(() => { + contexts.splice(0).forEach(({ ctx, state }) => { try { ctx.onStop(state) } catch { /* already stopped */ } }) + document.body.innerHTML = '' + jest.useRealTimers() +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js new file mode 100644 index 000000000..7479e4037 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js @@ -0,0 +1,139 @@ +/** + * Pure geometry helper functions for multi-ring/multi-part geometry support. + * These functions handle coordinate transformations between flat arrays and + * hierarchical GeoJSON structures for Polygon, MultiPolygon, LineString, and MultiLineString. + */ + +/** + * Get flat coordinates array from feature for all geometry types. + * Flattens all rings/parts into a single array for unified vertex navigation. + * + * @param {Object} feature - GeoJSON geometry object + * @returns {Array<[number, number]>} Flat array of all coordinates + */ +export const getCoords = (feature) => { + if (!feature?.coordinates) { + return [] + } + switch (feature.type) { + case 'LineString': + return feature.coordinates + case 'Polygon': + return feature.coordinates.flat(1) + case 'MultiLineString': + return feature.coordinates.flat(1) + case 'MultiPolygon': + return feature.coordinates.flat(2) + default: + return [] + } +} + +/** + * Get segment metadata for multi-ring/multi-part geometries. + * Each segment represents a ring (for Polygon) or part (for Multi*). + * + * @param {Object} feature - GeoJSON geometry object + * @returns {Array<{start: number, length: number, path: number[], closed: boolean}>} + * Array of segment metadata objects where: + * - start: Starting index in flat coordinates array + * - length: Number of coordinates in this segment + * - path: Hierarchical path indices to reach this segment in GeoJSON + * - closed: Whether this segment is closed (true for Polygon rings) + */ +export const getRingSegments = (feature) => { + if (!feature?.coordinates) { + return [] + } + const segments = [] + let start = 0 + + switch (feature.type) { + case 'LineString': + segments.push({ start: 0, length: feature.coordinates.length, path: [], closed: false }) + break + case 'Polygon': + feature.coordinates.forEach((ring, ringIdx) => { + segments.push({ start, length: ring.length, path: [ringIdx], closed: true }) + start += ring.length + }) + break + case 'MultiLineString': + feature.coordinates.forEach((line, lineIdx) => { + segments.push({ start, length: line.length, path: [lineIdx], closed: false }) + start += line.length + }) + break + case 'MultiPolygon': + feature.coordinates.forEach((polygon, polyIdx) => { + polygon.forEach((ring, ringIdx) => { + segments.push({ start, length: ring.length, path: [polyIdx, ringIdx], closed: true }) + start += ring.length + }) + }) + break + default: + break + } + + return segments +} + +/** + * Find which segment a flat vertex index belongs to. + * + * @param {Array} segments - Array of segment metadata from getRingSegments + * @param {number} flatIdx - Flat vertex index + * @returns {{segment: Object, localIdx: number}|null} + * Object with segment metadata and local index within that segment, or null if not found + */ +export const getSegmentForIndex = (segments, flatIdx) => { + for (const seg of segments) { + if (flatIdx >= seg.start && flatIdx < seg.start + seg.length) { + return { segment: seg, localIdx: flatIdx - seg.start } + } + } + return null +} + +/** + * Get modifiable coordinate array at a specific hierarchical path. + * Returns a reference to the actual coordinate array in the GeoJSON structure. + * + * @param {Object} geojson - Full GeoJSON feature object + * @param {number[]} path - Hierarchical path indices (e.g., [0] for first ring, [1, 0] for second polygon's first ring) + * @returns {Array<[number, number]>} Reference to coordinate array at path + */ +export const getModifiableCoords = (geojson, path) => { + let coords = geojson.geometry.coordinates + for (const idx of path) { + coords = coords[idx] + } + return coords +} + +/** + * Convert mapbox-gl-draw coord_path string to flat vertex index. + * coord_path format: "ringIdx.vertexIdx" for Polygon, "polyIdx.ringIdx.vertexIdx" for MultiPolygon, etc. + * + * @param {Object} feature - GeoJSON geometry object + * @param {string} coordPath - coord_path string from mapbox-gl-draw + * @returns {number} Flat vertex index in the unified coordinate array + */ +export const coordPathToFlatIndex = (feature, coordPath) => { + const parts = coordPath.split('.').map(Number) + const segments = getRingSegments(feature) + + // Match coord_path to segment + for (const seg of segments) { + // Check if path matches (compare all but last element which is the local vertex index) + const pathMatches = seg.path.every((val, idx) => val === parts[idx]) + if (pathMatches && parts.length === seg.path.length + 1) { + const localIdx = parts[parts.length - 1] // NOSONAR, .length greater borwser support + return seg.start + localIdx + } + } + + // Fallback: just use the last number (works for simple geometries) + return parts[parts.length - 1] // NOSONAR, .length greater borwser support +} diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js new file mode 100644 index 000000000..1f9bc00f5 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js @@ -0,0 +1,46 @@ +import { + getCoords, getRingSegments, getSegmentForIndex, getModifiableCoords, coordPathToFlatIndex +} from './geometryHelpers.js' + +const POLYGON = () => ({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] }) +const LINE = () => ({ type: 'LineString', coordinates: [[0, 0], [10, 0], [10, 10]] }) + +describe('geometryHelpers', () => { + test('getCoords flattens every geometry type and guards missing input', () => { + expect(getCoords(LINE())).toHaveLength(3) + expect(getCoords(POLYGON())).toHaveLength(5) + expect(getCoords({ type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[2, 2]]] })).toHaveLength(3) + expect(getCoords({ type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 1]]], [[[2, 2]]]] })).toHaveLength(3) + expect(getCoords({ type: 'Point', coordinates: [0, 0] })).toEqual([]) + expect(getCoords(null)).toEqual([]) + }) + + test('getRingSegments describes each geometry type and guards missing input', () => { + expect(getRingSegments(LINE())).toEqual([{ start: 0, length: 3, path: [], closed: false }]) + expect(getRingSegments(POLYGON())).toEqual([{ start: 0, length: 5, path: [0], closed: true }]) + expect(getRingSegments({ type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[2, 2]]] })) + .toEqual([{ start: 0, length: 2, path: [0], closed: false }, { start: 2, length: 1, path: [1], closed: false }]) + expect(getRingSegments({ type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 1]]], [[[2, 2]]]] })) + .toEqual([{ start: 0, length: 2, path: [0, 0], closed: true }, { start: 2, length: 1, path: [1, 0], closed: true }]) + expect(getRingSegments({ type: 'Point', coordinates: [0, 0] })).toEqual([]) + expect(getRingSegments(null)).toEqual([]) + }) + + test('getSegmentForIndex finds the owning segment or returns null', () => { + const segments = getRingSegments(POLYGON()) + expect(getSegmentForIndex(segments, 2)).toEqual({ segment: segments[0], localIdx: 2 }) + expect(getSegmentForIndex(segments, 99)).toBeNull() + }) + + test('getModifiableCoords walks the hierarchical path', () => { + const geojson = { geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0]]], [[[9, 9]]]] } } + expect(getModifiableCoords(geojson, [1, 0])).toEqual([[9, 9]]) + }) + + test('coordPathToFlatIndex resolves matched paths and falls back to the last index', () => { + const poly = POLYGON() + expect(coordPathToFlatIndex(poly, '0.2')).toBe(2) + // Unmatched path falls back to the trailing number + expect(coordPathToFlatIndex(poly, '5.7')).toBe(7) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js new file mode 100644 index 000000000..29e29e091 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js @@ -0,0 +1,2 @@ +export const scalePoint = (point, scale) => ({ x: point.x * scale, y: point.y * scale }) +export const isOnSVG = (el) => el instanceof window.SVGElement || el.ownerSVGElement diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js new file mode 100644 index 000000000..40435d299 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js @@ -0,0 +1,17 @@ +import { scalePoint, isOnSVG } from './helpers.js' + +describe('helpers', () => { + test('scalePoint multiplies both axes by the scale', () => { + expect(scalePoint({ x: 2, y: 3 }, 4)).toEqual({ x: 8, y: 12 }) + expect(scalePoint({ x: 5, y: 5 }, 1)).toEqual({ x: 5, y: 5 }) + }) + + test('isOnSVG detects SVG elements and their descendants, not plain DOM nodes', () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + svg.appendChild(circle) + expect(isOnSVG(svg)).toBeTruthy() // instanceof SVGElement + expect(isOnSVG(circle)).toBeTruthy() // nested SVG element + expect(isOnSVG(document.createElement('div'))).toBeFalsy() + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js new file mode 100644 index 000000000..bd4e5ed5d --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js @@ -0,0 +1,151 @@ +import { getSnapInstance, clearSnapIndicator } from '../../utils/snapHelpers.js' +import { getCoords } from './geometryHelpers.js' + +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) +const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } +const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) + +// Keyboard shortcuts are ignored while a form control outside the map viewport has +// focus, but still work for elements inside the viewport (e.g. draw toolbar buttons). +const isInteractiveElementFocused = (state) => { + const el = document.activeElement + if (!el || el === document.body) { return false } + if (state.container?.contains(el)) { return false } + return INTERACTIVE_TAGS.has(el.tagName) || el.isContentEditable || el.hasAttribute('tabindex') +} + +const isUndoShortcut = (e) => e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey + +/** + * Keyboard interaction for the vertex-edit mode: arrow-key vertex movement/insertion, + * space-to-select, Escape, and Cmd/Ctrl+Z undo. Mixed into EditVertexMode. + */ +export const keyboardHandlers = { + onKeydown (state, e) { + if (isInteractiveElementFocused(state)) { + return + } + + state.interfaceType = 'keyboard' + this.hideTouchVertexIndicator(state) + + if (e.key === ' ') { + this.handleSpace(state, e) + return + } + if (ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { + this.handleArrowKey(state, e) + return + } + if (e.key === 'Escape') { + this.changeMode(state, { isPanEnabled: true, selectedVertexIndex: -1, selectedVertexType: null }) + return + } + if (isUndoShortcut(e)) { + this.handleUndoShortcut(state, e) + } + }, + + // Space always cancels the default; with no active selection it starts keyboard editing. + handleSpace (state, e) { + e.preventDefault() + if (state.selectedVertexIndex < 0) { + this.startKeyboardSelection(state) + } + }, + + // Alt+arrow steps to the next vertex/midpoint; a plain arrow nudges the selected vertex. + handleArrowKey (state, e) { + e.preventDefault() + e.stopPropagation() + if (e.altKey) { + this.updateVertex(state, e.key) + return + } + this.moveVertexByKey(state, e) + }, + + // Space with no active selection: select the first vertex for keyboard editing. + startKeyboardSelection (state) { + const snap = getSnapInstance(this.map) + if (snap) { + clearSnapIndicator(snap, this.map) + } + if (!state.vertecies?.length) { + state.vertecies = this.getVerticies(state.featureId) + state.midpoints = this.getMidpoints(state.featureId) + } + if (!state.vertecies?.length) { + return + } + state.isPanEnabled = false + this.updateVertex(state) + }, + + // Arrow key with a selected vertex: insert (midpoint) or nudge the vertex, honouring snap. + moveVertexByKey (state, e) { + if (state.selectedVertexType === 'midpoint') { + this.insertVertex(state, e) + return + } + + const feature = this.getFeature(state.featureId) + const currentCoord = feature && getCoords(feature)?.[state.selectedVertexIndex] + if (!currentCoord) { + return + } + + // Save starting position for undo (only on first move of sequence) + if (!state._keyboardMoveStartPosition) { + state._keyboardMoveStartPosition = [...currentCoord] + state._keyboardMoveStartIndex = state.selectedVertexIndex + } + + this.moveVertex(state, this._keyboardMoveTarget(state, e, currentCoord)) + }, + + // Resolve the destination coordinate for a keyboard nudge, applying or breaking + // snap — delegates to the shared resolver (vertexOperations.js) also used by + // MoveControl's nudgeVertexByDelta, so both snap identically. + _keyboardMoveTarget (state, e, currentCoord) { + const [dx, dy] = ARROW_OFFSETS[e.key] + return this.resolveSnapTarget(state, dx, dy, currentCoord, () => this.getNewCoord(state, e)) + }, + + // Cmd/Ctrl+Z: undo the last edit, unless the user is typing in a text field. + handleUndoShortcut (state, e) { + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') { + return + } + e.preventDefault() + e.stopPropagation() + this.handleUndo(state) + }, + + onKeyup (state, e) { + if (isInteractiveElementFocused(state)) { + return + } + + state.interfaceType = 'keyboard' + if (ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { + e.stopPropagation() + + // Push undo for keyboard move sequence + if (state._keyboardMoveStartPosition && state._keyboardMoveStartIndex != null) { + this.pushUndo({ + type: 'move_vertex', + featureId: state.featureId, + vertexIndex: state._keyboardMoveStartIndex, + previousPosition: state._keyboardMoveStartPosition + }) + state._keyboardMoveStartPosition = null + state._keyboardMoveStartIndex = null + } + } + if (e.key === 'Delete') { + this.deleteVertex(state) + } + } +} diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js new file mode 100644 index 000000000..58e74c7de --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js @@ -0,0 +1,123 @@ +import { createHarness } from './__helpers__/harness.js' + +describe('keyboardHandlers', () => { + const keydown = (ctx, state, key, extra = {}) => ctx.onKeydown(state, { key, preventDefault: jest.fn(), stopPropagation: jest.fn(), ...extra }) + const keyup = (ctx, state, key) => ctx.onKeyup(state, { key, stopPropagation: jest.fn() }) + + test('shortcuts are ignored outside the viewport (input, focusable non-input) and when the container is absent', () => { + const { ctx, state } = createHarness() + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + keydown(ctx, state, 'ArrowRight') + keyup(ctx, state, 'ArrowRight') + expect(state.interfaceType).not.toBe('keyboard') + + const focusable = document.createElement('div') + focusable.tabIndex = 0 + document.body.appendChild(focusable) + focusable.focus() + keydown(ctx, state, 'ArrowRight') + expect(state.interfaceType).not.toBe('keyboard') + + input.focus() + const s = { ...state, container: undefined, interfaceType: 'mouse' } + keydown(ctx, s, 'ArrowRight') + expect(s.interfaceType).toBe('mouse') + }) + + test('space selects the first vertex, or does nothing when a vertex is selected or there are none', () => { + const { ctx, state, map } = createHarness() + map._snapInstance = { status: true, snapStatus: true, snapCoords: [1, 1] } + const updateSpy = jest.spyOn(ctx, 'updateVertex').mockImplementation(() => {}) + keydown(ctx, state, ' ') + expect(state.isPanEnabled).toBe(false) + expect(updateSpy).toHaveBeenCalledTimes(1) + + keydown(ctx, { ...state, selectedVertexIndex: 0 }, ' ') // already selected → just cancels default + expect(updateSpy).toHaveBeenCalledTimes(1) + + map._snapInstance = null // no snap indicator to clear + const s = { ...state, featureId: 'missing', vertecies: [], selectedVertexIndex: -1, isPanEnabled: true } + keydown(ctx, s, ' ') + expect(s.isPanEnabled).toBe(true) + }) + + test('an arrow key moves the selected vertex, snapping when active and using the raw coord otherwise', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 1 + keydown(ctx, state, 'ArrowRight') + expect(state._keyboardMoveStartIndex).toBe(1) + + state.getSnapEnabled = () => true + map._snapInstance = { status: true, snapStatus: true, snapCoords: [7, 8], snapToClosestPoint: jest.fn() } + keydown(ctx, state, 'ArrowUp') + expect(state._isSnapped).toBe(true) + expect(state.vertecies[1]).toEqual([7, 8]) + + keydown(ctx, state, 'ArrowLeft') // snapped already → break out of the snap radius + expect(state._isSnapped).toBe(false) + + map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + keydown(ctx, state, 'ArrowRight') // snap enabled but inactive → raw new coord + expect(state._isSnapped).toBe(false) + }) + + test('an arrow key on a midpoint inserts a vertex; guards a missing feature or out-of-range vertex', () => { + const { ctx, state } = createHarness() + const insertSpy = jest.spyOn(ctx, 'insertVertex').mockImplementation(() => {}) + keydown(ctx, { ...state, selectedVertexIndex: state.vertecies.length, selectedVertexType: 'midpoint' }, 'ArrowRight') + expect(insertSpy).toHaveBeenCalled() + + keydown(ctx, { ...state, featureId: 'missing', selectedVertexIndex: 0 }, 'ArrowRight') + keydown(ctx, { ...state, selectedVertexIndex: 99 }, 'ArrowRight') + expect(ctx.map._undoStack.length).toBe(0) + }) + + test('alt+arrow steps the selection, Escape clears it, and Cmd/Ctrl+Z undoes (ignoring shift and text fields)', () => { + const { ctx, state, api } = createHarness() + state.selectedVertexIndex = 0 + const updateSpy = jest.spyOn(ctx, 'updateVertex').mockImplementation(() => {}) + keydown(ctx, state, 'ArrowRight', { altKey: true }) + expect(updateSpy).toHaveBeenCalledWith(state, 'ArrowRight') + + keydown(ctx, state, 'Escape') + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ isPanEnabled: true })) + + const undoSpy = jest.spyOn(ctx, 'handleUndo').mockImplementation(() => {}) + keydown(ctx, state, 'z', { metaKey: true }) + keydown(ctx, state, 'z', { ctrlKey: true }) + expect(undoSpy).toHaveBeenCalledTimes(2) + keydown(ctx, state, 'z', { metaKey: true, shiftKey: true }) // shift → not undo + expect(undoSpy).toHaveBeenCalledTimes(2) + + // Ignored while typing in a text field inside the viewport + const input = document.createElement('input') + state.container.appendChild(input) + input.focus() + keydown(ctx, state, 'z', { metaKey: true }) + expect(undoSpy).toHaveBeenCalledTimes(2) + }) + + test('onKeyup pushes a move undo after a sequence, deletes on Delete, no-ops otherwise, and allows viewport focus', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 1 + state._keyboardMoveStartPosition = [10, 0] + state._keyboardMoveStartIndex = 1 + keyup(ctx, state, 'ArrowRight') + expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 }) + + keyup(ctx, { ...state, selectedVertexIndex: 1 }, 'ArrowRight') // no active sequence → no undo + expect(map._undoStack.length).toBe(0) + + const deleteSpy = jest.spyOn(ctx, 'deleteVertex').mockImplementation(() => {}) + keyup(ctx, state, 'Delete') + expect(deleteSpy).toHaveBeenCalled() + + const child = document.createElement('button') // focus inside the viewport is non-blocking + state.container.appendChild(child) + child.focus() + keyup(ctx, state, 'ArrowRight') + expect(state.interfaceType).toBe('keyboard') + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js new file mode 100644 index 000000000..254eba080 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js @@ -0,0 +1,161 @@ +import DirectSelect from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/direct_select.js' // NOSONAR +import { + getSnapInstance, isSnapEnabled, getSnapLngLat, triggerSnapAtPoint, clearSnapState +} from '../../utils/snapHelpers.js' +import { getCoords, coordPathToFlatIndex } from './geometryHelpers.js' + +const EVENT_VERTEX_SELECTION = 'draw.vertexselection' + +/** + * Mouse/pointer interaction for the vertex-edit mode: vertex/midpoint mouse-down, + * click-to-commit insertions, drag (with snapping) and mouse-up undo bookkeeping. + * Wraps the parent DirectSelect handlers. Mixed into EditVertexMode. + */ +export const pointerHandlers = { + onMouseDown (state, e) { + clearSnapState(getSnapInstance(this.map)) + const meta = e.featureTarget?.properties.meta + const coordPath = e.featureTarget?.properties.coord_path + + if (['vertex', 'midpoint'].includes(meta)) { + state.dragMoveLocation = e.lngLat + state.dragMoving = false + DirectSelect.onMouseDown.call(this, state, e) + + // Update selection state for vertex clicks (so onSelectionChange has correct context) + if (meta === 'vertex' && coordPath) { + const feature = this.getFeature(state.featureId) + const vertexIndex = coordPathToFlatIndex(feature, coordPath) + state.selectedVertexIndex = vertexIndex + state.selectedVertexType = 'vertex' + state.coordPath = coordPath + const vertex = state.vertecies?.[vertexIndex] + if (vertex) { + state._moveStartPosition = [...vertex] + state._moveStartIndex = vertexIndex + } + } + } + if (meta === 'midpoint') { + // DirectSelect converts midpoint to vertex - track this as an insert + const feature = this.getFeature(state.featureId) + const insertedIndex = coordPathToFlatIndex(feature, coordPath) + + // Track this insertion for undo (will be pushed on mouseUp if drag occurred) + state._insertedVertexIndex = insertedIndex + state._isInsertingVertex = true + + state.selectedVertexIndex = this.getVertexIndexFromMidpoint(state, coordPath) + state.selectedVertexType = 'vertex' + state.coordPath = null // Clear coordPath for midpoints + this.map.fire(EVENT_VERTEX_SELECTION, { index: state.selectedVertexIndex, numVertecies: state.vertecies.length }) + } + }, + + onClick (state, e) { // NOSONAR — complexity accumulated from object-level context; single guard clause, see feedback_mgl_click_vs_mouseup.md + if (state._isInsertingVertex && state._insertedVertexIndex != null) { + const insertedIndex = state._insertedVertexIndex + this.syncVertices(state) + this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: insertedIndex }) + state.selectedVertexIndex = insertedIndex + state.selectedVertexType = 'vertex' + state._isInsertingVertex = false + state._insertedVertexIndex = null + this.map.fire(EVENT_VERTEX_SELECTION, { index: insertedIndex, numVertecies: state.vertecies.length }) + return + } + DirectSelect.onClick.call(this, state, e) + }, + + onMouseUp (state, e) { + clearSnapState(getSnapInstance(this.map)) + + const wasInsertion = state._isInsertingVertex && state._insertedVertexIndex != null + const vertexMoved = this._didVertexMove(state) + + if (state.dragMoving || vertexMoved || wasInsertion) { + this.syncVertices(state) + if (wasInsertion) { + this._recordInsertionUndo(state) + } else if (vertexMoved) { + this._recordMoveUndo(state) + } else { + // dragMoving without an actual move or insertion: nothing to record + } + } + + // Clean up move state + state._moveStartPosition = null + state._moveStartIndex = null + + DirectSelect.onMouseUp.call(this, state, e) + }, + + // Did the selected vertex actually change position during this interaction? + // Reads the live feature (not the cached state.vertecies) for reliability. + _didVertexMove (state) { + if (!state._moveStartPosition || state._moveStartIndex == null) { + return false + } + const feature = this.getFeature(state.featureId) + const currentVertex = feature && getCoords(feature)?.[state._moveStartIndex] + if (!currentVertex) { + return false + } + return currentVertex[0] !== state._moveStartPosition[0] || + currentVertex[1] !== state._moveStartPosition[1] + }, + + // Commit a midpoint-drag insertion: record undo, reselect the new vertex, broadcast the count. + _recordInsertionUndo (state) { + const insertedIndex = state._insertedVertexIndex + this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: insertedIndex }) + // selectedVertexIndex pointed at the old midpoint-range index; use the real flat index + state.selectedVertexIndex = insertedIndex + state.selectedVertexType = 'vertex' + state._isInsertingVertex = false + state._insertedVertexIndex = null + // DirectSelect.onMouseUp fires draw.update but not draw.selectionchange, so broadcast the count here + this.map.fire(EVENT_VERTEX_SELECTION, { index: insertedIndex, numVertecies: state.vertecies.length }) + }, + + _recordMoveUndo (state) { + this.pushUndo({ + type: 'move_vertex', + featureId: state.featureId, + vertexIndex: state._moveStartIndex, + previousPosition: state._moveStartPosition + }) + }, + + onDrag (state, e) { + if (state.interfaceType === 'touch') { + return + } + + this.map.fire('draw.geometrychange', state.feature) + + const snap = getSnapInstance(this.map) + if (snap) { + snap.snapStatus = false + snap.snapCoords = null + } + + if (!isSnapEnabled(state) || !snap?.status) { + DirectSelect.onDrag.call(this, state, e) + return + } + + if (!state.selectedCoordPaths?.length || !state.canDragMove) { + return + } + + state.dragMoving = true + e.originalEvent.stopPropagation() + triggerSnapAtPoint(snap, this.map, e.point) + + const finalLngLat = getSnapLngLat(snap) || e.lngLat + state.feature.updateCoordinate(state.selectedCoordPaths[0], finalLngLat.lng, finalLngLat.lat) + state.dragMoveLocation = e.lngLat + } +} diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js new file mode 100644 index 000000000..3d0ecc01f --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js @@ -0,0 +1,98 @@ +import { createHarness, dragEvt } from './__helpers__/harness.js' + +describe('pointerHandlers', () => { + test('onMouseDown selects a vertex and records the move start, ignoring empty/no-coord targets', () => { + const { ctx, state } = createHarness() + ctx.onMouseDown(state, { lngLat: { lng: 10, lat: 0 }, featureTarget: { properties: { meta: 'vertex', coord_path: '0.1' } } }) + expect(state.selectedVertexIndex).toBe(1) + expect(state._moveStartIndex).toBe(1) + + const h = createHarness() + h.ctx.onMouseDown(h.state, { lngLat: { lng: 0, lat: 0 }, featureTarget: undefined }) + h.ctx.onMouseDown(h.state, { lngLat: { lng: 0, lat: 0 }, featureTarget: { properties: { meta: 'vertex', coord_path: '0.9' } } }) + expect(h.state._moveStartIndex).toBeUndefined() + }) + + test('onMouseDown on a midpoint marks an insertion in progress', () => { + const { ctx, state, map } = createHarness() + ctx.onMouseDown(state, { lngLat: { lng: 5, lat: 0 }, featureTarget: { properties: { meta: 'midpoint', coord_path: '0.1', lng: 5, lat: 0 } } }) + expect(state._isInsertingVertex).toBe(true) + expect(map.fire).toHaveBeenCalledWith('draw.vertexselection', expect.any(Object)) + }) + + test('onClick commits an in-progress insertion, otherwise delegates to DirectSelect', () => { + const { ctx, state, api } = createHarness() + state._isInsertingVertex = true + state._insertedVertexIndex = 2 + ctx.onClick(state, {}) + expect(ctx.map._undoStack.pop()).toMatchObject({ type: 'insert_vertex', vertexIndex: 2 }) + expect(state._isInsertingVertex).toBe(false) + + ctx.onClick(state, { featureTarget: undefined }) // noTarget → clickNoTarget → changeMode + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + }) + + test('onMouseUp records a move undo, an insertion undo, or nothing', () => { + const { ctx, state, map } = createHarness() + // nothing + ctx.onMouseUp(state, {}) + expect(map._undoStack.length).toBe(0) + + // move + state._moveStartIndex = 1 + state._moveStartPosition = [10, 0] + ctx.moveVertex({ ...state, selectedVertexIndex: 1 }, { lng: 3, lat: 3 }) + ctx.onMouseUp(state, {}) + expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 }) + + // insertion + state.dragMoving = true + state._isInsertingVertex = true + state._insertedVertexIndex = 2 + ctx.onMouseUp(state, {}) + expect(map._undoStack.pop()).toMatchObject({ type: 'insert_vertex', vertexIndex: 2 }) + + // In-progress drag with no move/insertion records nothing; guards missing feature/stale index; detects a vertical-only move + const h = createHarness() + h.ctx.onMouseUp({ ...h.state, dragMoving: true }, {}) + h.ctx.onMouseUp({ ...h.state, _moveStartPosition: [0, 0], _moveStartIndex: 0, featureId: 'missing' }, {}) + h.ctx.onMouseUp({ ...h.state, _moveStartPosition: [0, 0], _moveStartIndex: 99 }, {}) + expect(h.map._undoStack.length).toBe(0) + h.ctx.moveVertex({ ...h.state, selectedVertexIndex: 1 }, { lng: 10, lat: 5 }) // same lng, new lat + h.ctx.onMouseUp({ ...h.state, _moveStartPosition: [10, 0], _moveStartIndex: 1 }, {}) + expect(h.map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 }) + }) + + test('onDrag skips touch, delegates without snap, and snaps when enabled', () => { + const { ctx, state, map } = createHarness() + ctx.onDrag({ ...state, interfaceType: 'touch' }, { originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng: 1, lat: 1 }, point: { x: 10, y: 10 } }) + + // no snap → DirectSelect.onDrag moves the vertex + Object.assign(state, { canDragMove: true, selectedCoordPaths: ['0.1'], dragMoveLocation: { lng: 0, lat: 0 } }) + ctx.onDrag(state, { originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng: 2, lat: 2 }, point: { x: 20, y: 20 } }) + expect(map.fire).toHaveBeenCalledWith('draw.geometrychange', expect.anything()) + + // snap enabled + state.getSnapEnabled = () => true + map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + ctx.onDrag(state, { originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng: 3, lat: 3 }, point: { x: 30, y: 30 } }) + expect(state.dragMoving).toBe(true) + + // Snapping enabled but nothing selected → bail out + const nosel = createHarness() + nosel.map._snapInstance = { status: true, snapStatus: true, snapCoords: [1, 1] } + const ns = { ...nosel.state, getSnapEnabled: () => true, selectedCoordPaths: [], canDragMove: false } + nosel.ctx.onDrag(ns, dragEvt(1, 1)) + expect(ns.dragMoving).not.toBe(true) + + // Snapping active → snapped point; inactive → raw point + const snapped = createHarness() + snapped.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn(() => { snapped.map._snapInstance.snapStatus = true; snapped.map._snapInstance.snapCoords = [7, 8] }) } + snapped.ctx.onDrag({ ...snapped.state, getSnapEnabled: () => true, selectedCoordPaths: ['0.1'], canDragMove: true }, dragEvt(2, 2)) + expect(snapped.ctx.getFeature('feat-1').getCoordinate('0.1')).toEqual([7, 8]) + const raw = createHarness() + raw.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + raw.ctx.onDrag({ ...raw.state, getSnapEnabled: () => true, selectedCoordPaths: ['0.1'], canDragMove: true }, dragEvt(3, 3)) + expect(raw.ctx.getFeature('feat-1').getCoordinate('0.1')).toEqual([3, 3]) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js new file mode 100644 index 000000000..ed088d2c8 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js @@ -0,0 +1,133 @@ +import { + getSnapInstance, isSnapEnabled, triggerSnapAtPoint, getSnapLngLat, + clearSnapState, clearSnapIndicator +} from '../../utils/snapHelpers.js' +import { coordPathToFlatIndex } from './geometryHelpers.js' +import { isOnSVG } from './helpers.js' +import { createTouchTarget, applyTouchTargetColors } from '../../../../utils/touchTarget.js' +import { resolveColors } from '../../../../utils/resolveColors.js' + +export const applyTouchVertexColors = (el, mapStyle, pluginConfig = {}) => { + if (!el) { return } + const { editActive, editHalo, editVertex } = resolveColors(mapStyle, pluginConfig) + applyTouchTargetColors(el, { editActive, editHalo, editVertex }) +} + +export const touchHandlers = { + addTouchVertexTarget (state) { + state.touchVertexTarget = createTouchTarget(state.container) + applyTouchVertexColors(state.touchVertexTarget, this.map._drawCurrentMapStyle, this.map._drawPluginConfig) + }, + + updateTouchVertexTarget (state, point) { + if (point && state.interfaceType === 'touch' && state.selectedVertexIndex >= 0) { + Object.assign(state.touchVertexTarget.style, { display: 'block', top: `${point.y}px`, left: `${point.x}px` }) + } else { + state.touchVertexTarget.style.display = 'none' + } + }, + + hideTouchVertexIndicator (state) { + state.touchVertexTarget.style.display = 'none' + }, + + onPointerevent (state, e) { + state.interfaceType = e.pointerType === 'touch' ? 'touch' : 'mouse' + state.isPanEnabled = true + if (e.pointerType === 'touch' && e.type === 'pointermove' && !isOnSVG(e.target.parentNode) && !state._ignorePointermoveDeselect) { + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null, coordPath: null }) + } + }, + + // Empty stubs required by DirectSelect + onTouchStart () {}, + onTouchMove () {}, + onTouchEnd () {}, + + onTouchend (state) { + clearSnapState(getSnapInstance(this.map)) + if (state?.featureId) { + this.syncVertices(state) + + // Push undo for the move if touch actually moved + if (state._touchMoved && state._moveStartPosition && state._moveStartIndex !== undefined) { + this.pushUndo({ + type: 'move_vertex', + featureId: state.featureId, + vertexIndex: state._moveStartIndex, + previousPosition: state._moveStartPosition + }) + } + state._moveStartPosition = null + state._moveStartIndex = undefined + state._touchMoved = false + } + }, + + onTap (state, e) { + // Hide snap indicator on any tap + const snap = getSnapInstance(this.map) + if (snap) { + clearSnapIndicator(snap, this.map) + } + + const meta = e.featureTarget?.properties.meta + const coordPath = e.featureTarget?.properties.coord_path + + if (meta === 'vertex') { + const feature = this.getFeature(state.featureId) + const idx = coordPathToFlatIndex(feature, coordPath) + this.changeMode(state, { + selectedVertexIndex: idx, + selectedVertexType: 'vertex', + coordPath + }) + } else if (meta === 'midpoint') { + this.insertVertex({ ...state, selectedVertexIndex: this.getVertexIndexFromMidpoint(state, coordPath), selectedVertexType: 'midpoint' }) + } else { + this.clickNoTarget(state) + } + }, + + onTouchstart (state, e) { + clearSnapState(getSnapInstance(this.map)) + // Always get fresh vertex data in case coordinates changed during previous edits + const freshVertices = this.getVerticies(state.featureId) + const vertex = freshVertices?.[state.selectedVertexIndex] + if (!vertex || !isOnSVG(e.target.parentNode)) { + return + } + + // Save starting position for undo + state._moveStartPosition = [...vertex] + state._moveStartIndex = state.selectedVertexIndex + state._touchMoved = false + + const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY } + const style = window.getComputedStyle(state.touchVertexTarget) + state.deltaTarget = { x: touch.x - Number.parseFloat(style.left), y: touch.y - Number.parseFloat(style.top) } + const vertexPt = this.map.project(vertex) + state.deltaVertex = { x: (touch.x / state.scale) - vertexPt.x, y: (touch.y / state.scale) - vertexPt.y } + }, + + onTouchmove (state, e) { + if (state.selectedVertexIndex < 0 || !isOnSVG(e.target.parentNode)) { + return + } + + state._touchMoved = true + + const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY } + const screenPt = { x: (touch.x / state.scale) - state.deltaVertex.x, y: (touch.y / state.scale) - state.deltaVertex.y } + + let finalCoord = this.map.unproject(screenPt) + if (isSnapEnabled(state)) { + const snap = getSnapInstance(this.map) + triggerSnapAtPoint(snap, this.map, screenPt) + finalCoord = getSnapLngLat(snap) || finalCoord + } + + this.moveVertex(state, finalCoord) + this.updateTouchVertexTarget(state, { x: touch.x - state.deltaTarget.x, y: touch.y - state.deltaTarget.y }) + } +} diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js new file mode 100644 index 000000000..edc246665 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js @@ -0,0 +1,105 @@ +import { createHarness, svgTarget } from './__helpers__/harness.js' +import { applyTouchVertexColors } from './touchHandlers.js' + +describe('touchHandlers', () => { + test('applyTouchVertexColors returns early for a null element and defaults the colour scheme', () => { + expect(() => applyTouchVertexColors(null, {})).not.toThrow() + const { state } = createHarness() + expect(() => applyTouchVertexColors(state.touchVertexTarget, null)).not.toThrow() + }) + + test('applyTouchVertexColors applies a pluginConfig override to the CSS custom properties', () => { + const el = document.createElement('div') + applyTouchVertexColors(el, { mapColorScheme: 'light' }, { editVertex: '#custom' }) + expect(el.style.getPropertyValue('--draw-primary')).toBe('#custom') + }) + + test('addTouchVertexTarget resolves colours from map._drawPluginConfig', () => { + const { ctx, state, map } = createHarness() + map._drawPluginConfig = { editVertex: '#custom' } + ctx.addTouchVertexTarget(state) + expect(state.touchVertexTarget.style.getPropertyValue('--draw-primary')).toBe('#custom') + }) + + test('updateTouchVertexTarget shows the target for a touch selection and hides it otherwise', () => { + const { ctx, state } = createHarness() + state.interfaceType = 'touch' + state.selectedVertexIndex = 0 + ctx.updateTouchVertexTarget(state, { x: 12, y: 34 }) + expect(state.touchVertexTarget.style.display).toBe('block') + ctx.updateTouchVertexTarget(state, null) + expect(state.touchVertexTarget.style.display).toBe('none') + }) + + test('onPointerevent deselects on touch drag off the target, and just tracks the interface for mouse', () => { + const { ctx, state, api } = createHarness() + ctx.onPointerevent(state, { pointerType: 'mouse', type: 'pointermove', target: { parentNode: document.createElement('div') } }) + expect(state.interfaceType).toBe('mouse') + expect(api.changeMode).not.toHaveBeenCalled() + + state._ignorePointermoveDeselect = false + ctx.onPointerevent(state, { pointerType: 'touch', type: 'pointermove', target: { parentNode: document.createElement('div') } }) + expect(state.interfaceType).toBe('touch') + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + }) + + test('onTouchend records a move undo only when a touch move occurred', () => { + const { ctx, state, map } = createHarness() + ctx.onTouchend({ }) // no featureId → no-op + ctx.onTouchend({ ...state, _touchMoved: false }) // feature but no move → no undo + expect(map._undoStack.length).toBe(0) + state._touchMoved = true + state._moveStartPosition = [0, 0] + state._moveStartIndex = 1 + ctx.onTouchend(state) + expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 }) + }) + + test('onTouchstart captures move start state, ignoring taps off the vertex/target', () => { + const { ctx, state } = createHarness() + ctx.onTouchstart(state, { target: { parentNode: document.createElement('div') }, touches: [{ clientX: 1, clientY: 1 }] }) + expect(state._moveStartIndex).toBeUndefined() + + state.selectedVertexIndex = 1 + ctx.onTouchstart(state, { target: svgTarget(state), touches: [{ clientX: 20, clientY: 20 }] }) + expect(state._moveStartIndex).toBe(1) + expect(state._touchMoved).toBe(false) + }) + + test('onTouchmove moves the selected vertex, honouring snap, and ignores non-target moves', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 1 + ctx.onTouchstart(state, { target: svgTarget(state), touches: [{ clientX: 20, clientY: 20 }] }) + + ctx.onTouchmove(state, { target: { parentNode: document.createElement('div') }, touches: [{ clientX: 5, clientY: 5 }] }) + expect(state.vertecies[1]).toEqual([10, 0]) // off-target move ignored + ctx.onTouchmove(state, { target: svgTarget(state), touches: [{ clientX: 30, clientY: 40 }] }) + + state.getSnapEnabled = () => true + map._snapInstance = { status: true, snapStatus: true, snapCoords: [7, 8], snapToClosestPoint: jest.fn() } + ctx.onTouchmove(state, { target: svgTarget(state), touches: [{ clientX: 50, clientY: 60 }] }) + expect(state.vertecies[1]).toEqual([7, 8]) + + // Snap enabled but no snap point → falls back to the pointer position + map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + ctx.onTouchmove(state, { target: svgTarget(state), touches: [{ clientX: 35, clientY: 45 }] }) + expect(state._touchMoved).toBe(true) + }) + + test('onTap clears the snap indicator, then selects a vertex, inserts on a midpoint, or clears with no target', () => { + const { ctx, state, api, map } = createHarness() + map._snapInstance = { status: true, snapStatus: true, snapCoords: [1, 1] } + ctx.onTap(state, { featureTarget: { properties: { meta: 'vertex', coord_path: '0.2' } } }) + expect(map.getLayer).toHaveBeenCalledWith('snap-helper-circle') + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexType: 'vertex' })) + + api.changeMode.mockClear() + ctx.onTap(state, { featureTarget: { properties: { meta: 'midpoint', coord_path: '0.1' } } }) + expect(api.add).toHaveBeenCalled() // insertVertex path + + api.changeMode.mockClear() + map._snapInstance = null // no snap indicator to clear + ctx.onTap(state, { featureTarget: undefined }) + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js new file mode 100644 index 000000000..4c9dea8e0 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js @@ -0,0 +1,166 @@ +import { + getRingSegments, + getSegmentForIndex, + getModifiableCoords +} from './geometryHelpers.js' +import { scalePoint } from './helpers.js' + +// Map an undo-stack op type onto the geometry-change `phase` consumed by validation. +const UNDO_OP_PHASE = { + move_vertex: 'commit-move', + insert_vertex: 'commit-insert', + delete_vertex: 'commit-delete' +} + +// Undoing an op commits the inverse change (undo of a delete re-inserts, etc.), +// so its re-validation reports the inverse phase. +const UNDO_INVERSE_PHASE = { + move_vertex: 'commit-move', + insert_vertex: 'commit-delete', + delete_vertex: 'commit-insert' +} + +export const undoHandlers = { + // Fire geometry change event (for external listeners) + fireGeometryChange (state) { + const feature = this.getFeature(state.featureId) + if (feature) { + this.map.fire('draw.update', { + features: [feature.toGeoJSON()], + action: 'change_coordinates' + }) + } + }, + + // Emit a commit-level geometrychange (feature + change phase + vertex index) so the + // validation layer can accept or reject the change. Deferred a tick to avoid + // re-entrancy: rejection calls draw.undo(), which must run after the current + // mutation (and its undo bookkeeping) has fully settled. + emitGeometryValidation (phase, vertexIndex, featureId) { + if (!phase) { return } + setTimeout(() => { + const feature = this.getFeature(featureId) + if (!feature) { return } + this.map.fire('draw.geometrychange', { feature: feature.toGeoJSON(), phase, vertexIndex }) + }, 0) + }, + + // Undo support + pushUndo (operation) { + const undoStack = this.map._undoStack + if (!undoStack) { + return + } + undoStack.push(operation) + // Every edit commit (move/insert/delete, via mouse or keyboard) records an undo + // op here, so this is the single point that feeds commit-level validation. + this.emitGeometryValidation(UNDO_OP_PHASE[operation.type], operation.vertexIndex, operation.featureId) + }, + + handleUndo (state) { + const undoStack = this.map._undoStack + if (!undoStack || undoStack.length === 0) { + return + } + + const op = undoStack.pop() + + if (op.type === 'move_vertex') { + this.undoMoveVertex(state, op) + } else if (op.type === 'insert_vertex') { + this.undoInsertVertex(state, op) + } else if (op.type === 'delete_vertex') { + this.undoDeleteVertex(state, op) + } else { + // No action + } + // An undo commits the inverse change, so it must re-validate like any other + // commit — otherwise the invalid stroke and the Done gate go stale. + this.emitGeometryValidation(UNDO_INVERSE_PHASE[op.type], op.vertexIndex, op.featureId) + }, + + undoMoveVertex (state, op) { + const { vertexIndex, previousPosition, featureId } = op + const feature = this.getFeature(featureId) + if (!feature) { return } + + const geojson = feature.toGeoJSON() + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, vertexIndex) + if (!result) { return } + + const coords = getModifiableCoords(geojson, result.segment.path) + coords[result.localIdx] = previousPosition + this._applyUndoAndSync(state, geojson, featureId) + + // Update touch vertex target position + const vertex = state.vertecies[state.selectedVertexIndex] + if (vertex) { + this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) + } + }, + + undoInsertVertex (state, op) { + const { vertexIndex, featureId } = op + const feature = this.getFeature(featureId) + if (!feature) { return } + + const geojson = feature.toGeoJSON() + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, vertexIndex) + if (!result) { return } + + const coords = getModifiableCoords(geojson, result.segment.path) + coords.splice(result.localIdx, 1) + this._applyUndoAndSync(state, geojson, featureId) + + // Clear DirectSelect's coordinate selection + this.clearSelectedCoordinates() + this.hideTouchVertexIndicator(state) + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null }) + }, + + undoDeleteVertex (state, op) { + const { vertexIndex, position, featureId } = op + const feature = this.getFeature(featureId) + if (!feature) { + return + } + + const geojson = feature.toGeoJSON() + const segments = getRingSegments(feature) + + // Try to find segment containing vertexIndex + let result = getSegmentForIndex(segments, vertexIndex) + + // If not found, vertex might be at segment boundary + if (!result) { + for (const seg of segments) { + if (vertexIndex === seg.start + seg.length) { + result = { segment: seg, localIdx: seg.length } + break + } + } + } + + if (!result) { + return + } + + const coords = getModifiableCoords(geojson, result.segment.path) + coords.splice(result.localIdx, 0, position) + this._applyUndoAndSync(state, geojson, featureId) + + // Re-insertion always lands the vertex back at vertexIndex, so it is guaranteed present + this.updateTouchVertexTarget(state, scalePoint(this.map.project(state.vertecies[vertexIndex]), state.scale)) + this.changeMode(state, { selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, vertexIndex) }) + }, + + _applyUndoAndSync (state, geojson, featureId) { + this._ctx.api.add(geojson) + state.vertecies = this.getVerticies(featureId) + state.midpoints = this.getMidpoints(featureId) + this._ctx.store.render() + this.fireGeometryChange(state) + } +} diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js new file mode 100644 index 000000000..6d2c45c6e --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js @@ -0,0 +1,133 @@ +import { createHarness } from './__helpers__/harness.js' +import { createUndoStack } from '../../../../utils/undoStack.js' +import { getRingSegments } from './geometryHelpers.js' + +describe('undoHandlers', () => { + test('pushUndo is a no-op without a stack and pushes otherwise', () => { + const { ctx, map } = createHarness() + map._undoStack = null + expect(() => ctx.pushUndo({ type: 'move_vertex' })).not.toThrow() + map._undoStack = createUndoStack(() => {}) + ctx.pushUndo({ type: 'move_vertex' }) + expect(map._undoStack.length).toBe(1) + }) + + test('pushUndo emits a deferred commit-level geometrychange with the change phase', () => { + jest.useFakeTimers() + const { ctx, map } = createHarness() + map.fire.mockClear() + + ctx.pushUndo({ type: 'move_vertex', featureId: 'feat-1', vertexIndex: 2 }) + // Deferred a tick to avoid re-entrancy — nothing fired synchronously. + expect(map.fire).not.toHaveBeenCalledWith('draw.geometrychange', expect.anything()) + + jest.runAllTimers() + expect(map.fire).toHaveBeenCalledWith('draw.geometrychange', expect.objectContaining({ + phase: 'commit-move', + vertexIndex: 2, + feature: expect.any(Object) + })) + jest.useRealTimers() + }) + + test('pushUndo does not emit a geometrychange for unmapped op types', () => { + jest.useFakeTimers() + const { ctx, map } = createHarness() + map.fire.mockClear() + ctx.pushUndo({ type: 'draw_vertex', featureId: 'feat-1' }) + jest.runAllTimers() + expect(map.fire).not.toHaveBeenCalledWith('draw.geometrychange', expect.anything()) + jest.useRealTimers() + }) + + test('emitGeometryValidation does not fire once the feature is gone', () => { + jest.useFakeTimers() + const { ctx, map } = createHarness() + map.fire.mockClear() + ctx.emitGeometryValidation('commit-move', 0, 'missing-feature') + jest.runAllTimers() + expect(map.fire).not.toHaveBeenCalledWith('draw.geometrychange', expect.anything()) + jest.useRealTimers() + }) + + test('handleUndo ignores an empty stack and dispatches by operation type', () => { + const { ctx, state, map } = createHarness() + ctx.handleUndo(state) // empty → no throw + const spies = ['undoMoveVertex', 'undoInsertVertex', 'undoDeleteVertex'].map((m) => jest.spyOn(ctx, m).mockImplementation(() => {})) + map._undoStack.push({ type: 'move_vertex', featureId: 'feat-1', vertexIndex: 0, previousPosition: [0, 0] }) + ctx.handleUndo(state) + map._undoStack.push({ type: 'insert_vertex', featureId: 'feat-1', vertexIndex: 1 }) + ctx.handleUndo(state) + map._undoStack.push({ type: 'delete_vertex', featureId: 'feat-1', vertexIndex: 1, position: [0, 0] }) + ctx.handleUndo(state) + expect(spies.every((s) => s.mock.calls.length === 1)).toBe(true) + + map._undoStack.push({ type: 'unknown' }) + expect(() => ctx.handleUndo(state)).not.toThrow() + }) + + test('handleUndo re-validates with the inverse change phase (undo of a delete re-inserts)', () => { + jest.useFakeTimers() + const { ctx, state, map } = createHarness() + jest.spyOn(ctx, 'undoDeleteVertex').mockImplementation(() => {}) + map._undoStack.push({ type: 'delete_vertex', featureId: 'feat-1', vertexIndex: 1, position: [0, 0] }) + map.fire.mockClear() + ctx.handleUndo(state) + jest.runAllTimers() + expect(map.fire).toHaveBeenCalledWith('draw.geometrychange', expect.objectContaining({ + phase: 'commit-insert', + vertexIndex: 1, + feature: expect.any(Object) + })) + jest.useRealTimers() + }) + + test('fireGeometryChange fires draw.update only when the feature exists', () => { + const { ctx, state, map } = createHarness() + ctx.fireGeometryChange(state) + expect(map.fire).toHaveBeenCalledWith('draw.update', expect.objectContaining({ action: 'change_coordinates' })) + map.fire.mockClear() + ctx.fireGeometryChange({ featureId: 'missing' }) + expect(map.fire).not.toHaveBeenCalled() + }) + + test('undoMoveVertex restores the previous position, guarding missing feature/segment', () => { + const { ctx, state } = createHarness() + ctx.undoMoveVertex({ ...state, featureId: 'missing' }, { vertexIndex: 0, previousPosition: [0, 0], featureId: 'missing' }) + ctx.undoMoveVertex(state, { vertexIndex: 99, previousPosition: [0, 0], featureId: 'feat-1' }) + state.selectedVertexIndex = 0 + ctx.undoMoveVertex(state, { vertexIndex: 0, previousPosition: [3, 4], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')[0]).toEqual([3, 4]) + + // Skips the touch target update when the selected vertex is gone + ctx.undoMoveVertex({ ...state, selectedVertexIndex: 99 }, { vertexIndex: 0, previousPosition: [1, 1], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')[0]).toEqual([1, 1]) + }) + + test('undoInsertVertex removes the inserted vertex and clears the selection', () => { + const { ctx, state, api } = createHarness() + ctx.undoInsertVertex(state, { vertexIndex: 1, featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(3) + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + + // Guards a missing feature and an out-of-range index + ctx.undoInsertVertex({ ...state, featureId: 'missing' }, { vertexIndex: 0, featureId: 'missing' }) + ctx.undoInsertVertex(state, { vertexIndex: 999, featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(3) + }) + + test('undoDeleteVertex re-inserts, handling boundary, fallback and missing feature', () => { + const { ctx, state } = createHarness() + ctx.undoDeleteVertex({ ...state, featureId: 'missing' }, { vertexIndex: 0, position: [1, 1], featureId: 'missing' }) + // Fallback: index far beyond any segment boundary → no result → no-op + ctx.undoDeleteVertex(state, { vertexIndex: 999, position: [1, 1], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(4) + // Success: re-insert at index 1 + ctx.undoDeleteVertex(state, { vertexIndex: 1, position: [1, 1], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(5) + // Boundary: index === segment.start + segment.length + const seg = getRingSegments(ctx.getFeature('feat-1'))[0] + ctx.undoDeleteVertex(state, { vertexIndex: seg.start + seg.length, position: [2, 2], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(6) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js new file mode 100644 index 000000000..27ad0a1e3 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js @@ -0,0 +1,210 @@ +import { + getCoords, + getRingSegments, + getSegmentForIndex, + getModifiableCoords +} from './geometryHelpers.js' +import { KEYBOARD } from '../../defaults.js' +import { + getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, + getSnapRadius, triggerSnapAtPoint, clearSnapIndicator +} from '../../utils/snapHelpers.js' +import { MIN_VERTICES } from '../../../../validation/rules.js' + +const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } + +export const vertexOperations = { + updateMidpoint (coordinates) { + setTimeout(() => { + this.map.getSource('mapbox-gl-draw-hot').setData({ + type: 'Feature', + properties: { meta: 'midpoint', active: 'true', id: 'active-midpoint' }, + geometry: { type: 'Point', coordinates } + }) + }, 0) + }, + + updateVertex (state, direction) { + const [idx, type] = this.getVertexOrMidpoint(state, direction) + if (idx < 0 || !type) { + return + } + this.changeMode(state, { selectedVertexIndex: idx, selectedVertexType: type, ...(type === 'vertex' && { coordPath: this.getCoordPath(state, idx) }) }) + }, + + getOffset (coord, e) { + const pt = this.map.project(coord) + const offset = e?.shiftKey ? KEYBOARD.nudgeAmount : KEYBOARD.stepAmount + const [dx, dy] = e ? ARROW_OFFSETS[e.key].map(v => v * offset) : [0, 0] + return this.map.unproject({ x: pt.x + dx, y: pt.y + dy }) + }, + + getNewCoord (state, e) { + return this.getOffset(getCoords(this.getFeature(state.featureId))[state.selectedVertexIndex], e) + }, + + // Explicit-delta counterpart to getOffset, driven by a unit direction vector and + // MoveControl's own Precision toggle rather than a KeyboardEvent — used by + // nudgeVertexByDelta below. + getOffsetByDelta (coord, dx, dy, isLargeStep) { + const pt = this.map.project(coord) + const amount = isLargeStep ? KEYBOARD.stepAmount : KEYBOARD.nudgeAmount + return this.map.unproject({ x: pt.x + dx * amount, y: pt.y + dy * amount }) + }, + + // Resolves the destination coordinate for a vertex nudge by (dx, dy) unit + // direction, applying snap or breaking out of an already-active one — shared by + // the keyboard arrow-key path (_keyboardMoveTarget) and MoveControl's + // explicit-delta path (nudgeVertexByDelta) so both snap identically. dx/dy are + // only needed for the snap-escape offset; getCandidate computes the raw, + // un-snapped destination the caller would otherwise have used. + resolveSnapTarget (state, dx, dy, currentCoord, getCandidate) { + const snap = getSnapInstance(this.map) + + // Break out of an active snap by moving beyond the snap radius + if (isSnapEnabled(state) && state._isSnapped && snap) { + const offset = getSnapRadius(snap) + 1 + const pt = this.map.project(currentCoord) + state._isSnapped = false + clearSnapIndicator(snap, this.map) + return this.map.unproject({ x: pt.x + dx * offset, y: pt.y + dy * offset }) + } + + const newCoord = getCandidate() + if (isSnapEnabled(state) && snap) { + triggerSnapAtPoint(snap, this.map, this.map.project(newCoord)) + if (isSnapActive(snap)) { + state._isSnapped = true + return getSnapLngLat(snap) + } + } + state._isSnapped = false + return newCoord + }, + + // Moves the selected vertex by an explicit (dx, dy) unit direction — the entry + // point for MoveControl's D-pad (see mapProvider.activeMoveTarget in events.js), + // as opposed to moveVertexByKey's KeyboardEvent-driven path. Each call is treated + // as one complete, undoable action (no held-key sequencing, since a button click + // has no "held" state to batch the way arrow keys do) — but still honours snap + // via the shared resolveSnapTarget, same as keyboard nudging. + nudgeVertexByDelta (state, dx, dy, isLargeStep) { + if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) { + return + } + const feature = this.getFeature(state.featureId) + const currentCoord = feature && getCoords(feature)?.[state.selectedVertexIndex] + if (!currentCoord) { + return + } + const previousPosition = [...currentCoord] + const vertexIndex = state.selectedVertexIndex + const target = this.resolveSnapTarget(state, dx, dy, currentCoord, () => this.getOffsetByDelta(currentCoord, dx, dy, isLargeStep)) + this.moveVertex(state, target) + this.pushUndo({ type: 'move_vertex', featureId: state.featureId, vertexIndex, previousPosition }) + }, + + insertVertex (state, e) { + const midIdx = state.selectedVertexIndex - state.vertecies.length + const newCoord = this.getOffset(state.midpoints[midIdx], e) + const feature = this.getFeature(state.featureId) + const geojson = feature.toGeoJSON() + + // Find which segment this midpoint belongs to and calculate insertion position + const segments = getRingSegments(feature) + let globalInsertIdx = midIdx + 1 + let insertSegment = null + let localInsertIdx = 0 + + // Map midpoint index to segment and local position + let midpointCounter = 0 + for (const seg of segments) { + // Must match getMidpoints calculation + const segMidpoints = seg.closed ? seg.length : seg.length - 1 + if (midIdx < midpointCounter + segMidpoints) { + insertSegment = seg + localInsertIdx = (midIdx - midpointCounter) + 1 + globalInsertIdx = seg.start + localInsertIdx + break + } + midpointCounter += segMidpoints + } + + if (!insertSegment) { return } + + const coords = getModifiableCoords(geojson, insertSegment.path) + coords.splice(localInsertIdx, 0, [newCoord.lng, newCoord.lat]) + this._ctx.api.add(geojson) + + this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: globalInsertIdx }) + this.changeMode(state, { selectedVertexIndex: globalInsertIdx, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, globalInsertIdx) }) + }, + + moveVertex (state, coord, options = {}) { + if (options.checkSnap && state.enableSnap !== false) { + const snap = this.map._snapInstance + if (snap?.snapStatus && snap.snapCoords?.length >= 2) { + coord = { lng: snap.snapCoords[0], lat: snap.snapCoords[1] } + } + } + + const feature = this.getFeature(state.featureId) + const geojson = feature.toGeoJSON() + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, state.selectedVertexIndex) + if (!result) { return } + + const coords = getModifiableCoords(geojson, result.segment.path) + coords[result.localIdx] = [coord.lng, coord.lat] + this._ctx.api.add(geojson) + state.vertecies = this.getVerticies(state.featureId) + + this.map.fire('draw.geometrychange', state.feature) + }, + + deleteVertex (state) { + const feature = this.getFeature(state.featureId) + if (!feature) { + return + } + + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, state.selectedVertexIndex) + if (!result) { + return + } + + const { segment } = result + // Minimum vertices per segment (mapbox-gl-draw's internal representation is + // a closed ring for polygons, an open path for lines) — MIN_VERTICES is the + // single source for this threshold (validation/rules.js). + const minVertices = segment.closed ? MIN_VERTICES.Polygon : MIN_VERTICES.LineString + if (segment.length <= minVertices) { + return + } + + // Save position for undo before deletion + const deletedPosition = [...state.vertecies[state.selectedVertexIndex]] + const deletedIndex = state.selectedVertexIndex + + // Remove the coordinate directly rather than via this._ctx.api.trash(), which routes through + // DirectSelect.trash() and calls onSetup({ featureId }) with incomplete options, crashing event registration. + const coordPath = [...result.segment.path, result.localIdx].join('.') + feature.removeCoordinate(coordPath) + this.fireUpdate() + this.clearSelectedCoordinates() + feature.changed() + this._ctx.store.render() + + // Push undo operation + this.pushUndo({ + type: 'delete_vertex', + featureId: state.featureId, + vertexIndex: deletedIndex, + position: deletedPosition + }) + + // Clear selection after delete + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null }) + } +} diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js new file mode 100644 index 000000000..5792d1c7e --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js @@ -0,0 +1,167 @@ +import { createHarness, LINE } from './__helpers__/harness.js' + +describe('vertexOperations', () => { + test('updateMidpoint pushes point data to the hot source', () => { + jest.useFakeTimers() + const { ctx, map } = createHarness() + ctx.updateMidpoint([1, 2]) + jest.runAllTimers() + expect(map.getSource).toHaveBeenCalledWith('mapbox-gl-draw-hot') + }) + + test('updateVertex changes mode for a valid target and no-ops otherwise', () => { + const { ctx, state, api } = createHarness() + state.selectedVertexIndex = 0 + ctx.updateVertex(state, 'ArrowRight') + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ + selectedVertexIndex: expect.any(Number) + })) + + api.changeMode.mockClear() + ctx.updateVertex({ featureId: 'missing', vertecies: [], selectedVertexIndex: -1 }, 'ArrowRight') + expect(api.changeMode).not.toHaveBeenCalled() + + // A vertex target carries a coordPath; a midpoint target does not + jest.spyOn(ctx, 'getVertexOrMidpoint').mockReturnValueOnce([1, 'vertex']) + ctx.updateVertex(state, 'ArrowRight') + expect(api.changeMode.mock.calls.at(-1)[1].coordPath).toBeDefined() + jest.spyOn(ctx, 'getVertexOrMidpoint').mockReturnValueOnce([5, 'midpoint']) + ctx.updateVertex(state, 'ArrowRight') + expect(api.changeMode.mock.calls.at(-1)[1].coordPath).toBeUndefined() + }) + + test('getOffset applies step/nudge amounts and returns the coord unchanged without an event', () => { + const { ctx } = createHarness() + const stepped = ctx.getOffset([5, 5], { key: 'ArrowRight', shiftKey: false }) + const nudged = ctx.getOffset([5, 5], { key: 'ArrowRight', shiftKey: true }) + expect(stepped.lng).not.toBe(5) + expect(Math.abs(nudged.lng - 5)).toBeLessThan(Math.abs(stepped.lng - 5)) + expect(ctx.getOffset([5, 5], null)).toEqual({ lng: 5, lat: 5 }) + }) + + test('getNewCoord offsets the currently selected vertex', () => { + const { ctx, state } = createHarness() + state.selectedVertexIndex = 1 + expect(ctx.getNewCoord(state, { key: 'ArrowRight', shiftKey: false })).toHaveProperty('lng') + }) + + test('getOffsetByDelta applies step/nudge amounts along an explicit direction, mirroring getOffset\'s shiftKey polarity', () => { + const { ctx } = createHarness() + const large = ctx.getOffsetByDelta([5, 5], 1, 0, true) + const small = ctx.getOffsetByDelta([5, 5], 1, 0, false) + expect(large.lng).not.toBe(5) + expect(Math.abs(small.lng - 5)).toBeLessThan(Math.abs(large.lng - 5)) + expect(ctx.getOffsetByDelta([5, 5], 0, 0, true)).toEqual({ lng: 5, lat: 5 }) + }) + + test('nudgeVertexByDelta moves the selected vertex and pushes a single undo entry, or no-ops for a midpoint/no selection', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 0 + state.selectedVertexType = 'vertex' + const before = [...state.vertecies[0]] + ctx.nudgeVertexByDelta(state, 1, 0, true) + expect(state.vertecies[0]).not.toEqual(before) + expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 0, previousPosition: before }) + + map._undoStack.clear() + ctx.nudgeVertexByDelta({ ...state, selectedVertexType: 'midpoint' }, 1, 0, true) + ctx.nudgeVertexByDelta({ ...state, selectedVertexIndex: -1 }, 1, 0, true) + // Passes the type/index guard but resolves to no coordinate — missing feature, + // and a valid feature with an out-of-range index. + ctx.nudgeVertexByDelta({ ...state, featureId: 'missing' }, 1, 0, true) + ctx.nudgeVertexByDelta({ ...state, selectedVertexIndex: 99 }, 1, 0, true) + expect(map._undoStack).toHaveLength(0) + }) + + test('nudgeVertexByDelta snaps to a nearby target, breaks out of an active snap, and falls back to the raw coord when snap is inactive — same as keyboard nudging (regression: MoveControl bypassed snap entirely before resolveSnapTarget was shared)', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 1 + state.selectedVertexType = 'vertex' + state.getSnapEnabled = () => true + + map._snapInstance = { status: true, snapStatus: true, snapCoords: [7, 8], snapToClosestPoint: jest.fn() } + ctx.nudgeVertexByDelta(state, 0, -1, true) + expect(state._isSnapped).toBe(true) + expect(state.vertecies[1]).toEqual([7, 8]) + + ctx.nudgeVertexByDelta(state, -1, 0, true) // already snapped → breaks out of the snap radius + expect(state._isSnapped).toBe(false) + + map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + ctx.nudgeVertexByDelta(state, 1, 0, true) // snap enabled but inactive → raw offset coord + expect(state._isSnapped).toBe(false) + }) + + test('resolveSnapTarget is a no-op passthrough to the candidate coordinate when snap is disabled', () => { + const { ctx, state } = createHarness() + const candidate = { lng: 3, lat: 4 } + expect(ctx.resolveSnapTarget(state, 1, 0, [0, 0], () => candidate)).toEqual(candidate) + }) + + test('insertVertex splits a midpoint into a new vertex, records undo and selects it', () => { + const { ctx, state, api } = createHarness() + ctx.insertVertex({ ...state, selectedVertexIndex: state.vertecies.length, selectedVertexType: 'midpoint' }, { key: 'ArrowRight', shiftKey: false }) + expect(api.add).toHaveBeenCalled() + expect(state.map ?? ctx.map._undoStack.length).toBeGreaterThan(0) + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ + selectedVertexType: 'vertex' + })) + + // Also works for an open line segment + const line = createHarness(LINE()) + line.ctx.insertVertex({ ...line.state, selectedVertexIndex: line.state.vertecies.length, selectedVertexType: 'midpoint' }, { key: 'ArrowRight', shiftKey: false }) + expect(line.api.add).toHaveBeenCalled() + }) + + test('insertVertex bails out when the midpoint maps to no segment', () => { + const { ctx, state, api } = createHarness() + // midIdx beyond the segment's midpoint count, but with a defined midpoint coord + const badState = { ...state, selectedVertexIndex: state.vertecies.length + 4, midpoints: [...state.midpoints, [5, 5]] } + api.add.mockClear() + ctx.insertVertex(badState, { key: 'ArrowRight', shiftKey: false }) + expect(api.add).not.toHaveBeenCalled() + }) + + test('moveVertex repositions a vertex, applies snap and guards an out-of-range index', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 0 + ctx.moveVertex(state, { lng: 1, lat: 2 }) + expect(state.vertecies[0]).toEqual([1, 2]) + + map._snapInstance = { snapStatus: true, snapCoords: [7, 8] } + ctx.moveVertex(state, { lng: 1, lat: 2 }, { checkSnap: true }) + expect(state.vertecies[0]).toEqual([7, 8]) + + const before = [...state.vertecies] + ctx.moveVertex({ ...state, selectedVertexIndex: 99 }, { lng: 3, lat: 3 }) + expect(ctx.getVerticies('feat-1')).toEqual(before) + + // checkSnap requested but no active snap → coordinate used as-is + const h = createHarness() + h.ctx.moveVertex({ ...h.state, selectedVertexIndex: 0 }, { lng: 1, lat: 2 }, { checkSnap: true }) + expect(h.ctx.getVerticies('feat-1')[0]).toEqual([1, 2]) + }) + + test('deleteVertex removes a vertex, or no-ops for missing feature / bad index / minimum size', () => { + const { ctx, state, api } = createHarness() + ctx.deleteVertex({ ...state, featureId: 'missing' }) + ctx.deleteVertex({ ...state, selectedVertexIndex: 99 }) + expect(api.changeMode).not.toHaveBeenCalled() + + const triangle = createHarness({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [5, 10], [0, 0]]] }) + triangle.state.selectedVertexIndex = 0 + triangle.ctx.deleteVertex(triangle.state) // 3 vertices → at minimum, rejected + expect(triangle.api.changeMode).not.toHaveBeenCalled() + + state.selectedVertexIndex = 1 + ctx.deleteVertex(state) + expect(ctx.map._undoStack.pop()).toMatchObject({ type: 'delete_vertex', vertexIndex: 1 }) + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + + // Also works on an open line segment + const line = createHarness(LINE()) + line.state.selectedVertexIndex = 1 + line.ctx.deleteVertex(line.state) + expect(line.ctx.getVerticies('feat-1')).toHaveLength(2) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js new file mode 100644 index 000000000..8f0660c0c --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js @@ -0,0 +1,121 @@ +import { + getCoords, + getRingSegments, + getSegmentForIndex +} from './geometryHelpers.js' +import { spatialNavigate } from '../../../../utils/spatial.js' + +export const vertexQueries = { + findVertexIndex (coords, targetCoord, currentIdx) { + // Search for vertex, preferring matches near currentIdx to handle duplicate coords (e.g., closing vertices) + const matches = [] + coords.forEach((c, i) => { + if (c[0] === targetCoord[0] && c[1] === targetCoord[1]) { + matches.push(i) + } + }) + + if (matches.length === 0) { return -1 } + if (matches.length === 1) { return matches[0] } + + // Multiple matches - pick closest to current selection + if (currentIdx >= 0) { + return matches.reduce((best, idx) => + Math.abs(idx - currentIdx) < Math.abs(best - currentIdx) ? idx : best + , matches[0]) + } + return matches[0] + }, + + getCoordPath (state, idx) { + const feature = this.getFeature(state.featureId) + if (!feature) { return '0' } + + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, idx) + if (!result) { return '0' } + + const { segment, localIdx } = result + return [...segment.path, localIdx].join('.') + }, + + syncVertices (state) { + state.vertecies = this.getVerticies(state.featureId) + state.midpoints = this.getMidpoints(state.featureId) + }, + + getVerticies (featureId) { + return getCoords(this.getFeature(featureId)) + }, + + getMidpoints (featureId) { + const feature = this.getFeature(featureId) + const coords = getCoords(feature) + const segments = getRingSegments(feature) + if (!coords?.length || !segments.length) { + return [] + } + + const midpoints = [] + // Create midpoints within each segment, respecting boundaries + for (const seg of segments) { + // For closed rings, create midpoint between every vertex including last→first + // For open lines, create midpoints only between consecutive vertices (no wrap-around) + const count = seg.closed ? seg.length : seg.length - 1 + for (let i = 0; i < count; i++) { + const idx = seg.start + i + const nextIdx = seg.start + ((i + 1) % seg.length) + const [x1, y1] = coords[idx] + const [x2, y2] = coords[nextIdx] + midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2]) + } + } + return midpoints + }, + + getVertexOrMidpoint (state, direction) { + // Ensure vertices and midpoints are populated + if (!state.vertecies?.length) { + state.vertecies = this.getVerticies(state.featureId) + state.midpoints = this.getMidpoints(state.featureId) + } + if (!state.vertecies?.length) { + return [-1, null] + } + const project = (p) => p ? Object.values(this.map.project(p)) : null + const pixels = [...state.vertecies.map(project), ...state.midpoints.map(project)].filter(Boolean) + if (!pixels.length) { + return [-1, null] + } + const start = pixels[state.selectedVertexIndex] || Object.values(this.map.project(this.map.getCenter())) + const idx = spatialNavigate(start, pixels, direction) + return [idx, idx < state.vertecies.length ? 'vertex' : 'midpoint'] + }, + + getVertexIndexFromMidpoint (state, coordPath) { + const feature = this.getFeature(state.featureId) + const segments = getRingSegments(feature) + const parts = coordPath.split('.').map(Number) + + // Find which segment this coord_path belongs to + let midpointOffset = 0 + for (const seg of segments) { + const pathMatches = seg.path.every((val, idx) => val === parts[idx]) + if (pathMatches && parts.length === seg.path.length + 1) { + // In DirectSelect, midpoint coord_path represents the insertion index + // The midpoint between vertex N and N+1 has coord_path ending in N+1 + // So our flat midpoint index is one less than the coord_path index + const insertionIdx = parts[parts.length - 1] + const localMidpointIdx = insertionIdx > 0 ? insertionIdx - 1 : seg.length - 2 + // Midpoints are indexed after all vertices + return state.vertecies.length + midpointOffset + localMidpointIdx + } + // Count midpoints in this segment (must match getMidpoints calculation) + const segMidpoints = seg.closed ? seg.length : seg.length - 1 + midpointOffset += segMidpoints + } + + // Fallback + return state.vertecies.length + } +} diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js new file mode 100644 index 000000000..703919771 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js @@ -0,0 +1,73 @@ +import { createHarness, LINE } from './__helpers__/harness.js' + +describe('vertexQueries', () => { + test('findVertexIndex handles no, single and duplicate matches', () => { + const { ctx } = createHarness() + expect(ctx.findVertexIndex([[0, 0], [1, 1]], [9, 9], -1)).toBe(-1) + expect(ctx.findVertexIndex([[0, 0], [1, 1]], [1, 1], -1)).toBe(1) + expect(ctx.findVertexIndex([[0, 0], [1, 1], [0, 0]], [0, 0], 2)).toBe(2) + expect(ctx.findVertexIndex([[0, 0], [1, 1], [0, 0]], [0, 0], -1)).toBe(0) + expect(ctx.findVertexIndex([[0, 0], [1, 1], [0, 0]], [0, 0], 0)).toBe(0) + }) + + test('getCoordPath resolves an index, or returns "0" without a feature or segment', () => { + const { ctx, state } = createHarness() + expect(ctx.getCoordPath(state, 2)).toBe('0.2') + expect(ctx.getCoordPath(state, 99)).toBe('0') + expect(ctx.getCoordPath({ featureId: 'missing' }, 0)).toBe('0') + }) + + test('getVerticies / getMidpoints for closed rings, open lines and missing features', () => { + const { ctx } = createHarness() + expect(ctx.getVerticies('feat-1')).toHaveLength(4) + expect(ctx.getMidpoints('feat-1')).toHaveLength(4) + expect(ctx.getMidpoints('missing')).toEqual([]) + const line = createHarness(LINE()) + expect(line.ctx.getMidpoints('feat-1')).toHaveLength(2) + }) + + test('syncVertices refreshes the cached vertex and midpoint lists', () => { + const { ctx, state } = createHarness() + state.vertecies = [] + state.midpoints = [] + ctx.syncVertices(state) + expect(state.vertecies).toHaveLength(4) + expect(state.midpoints).toHaveLength(4) + }) + + test('getVertexOrMidpoint repopulates empty lists, navigates, and guards empty features', () => { + const { ctx, state } = createHarness() + state.vertecies = [] + state.selectedVertexIndex = 0 + const [idx, type] = ctx.getVertexOrMidpoint(state, 'ArrowRight') + expect(idx).toBeGreaterThanOrEqual(0) + expect(['vertex', 'midpoint']).toContain(type) + + expect(ctx.getVertexOrMidpoint({ featureId: 'missing', vertecies: [] }, 'ArrowRight')).toEqual([-1, null]) + expect(ctx.getVertexOrMidpoint({ ...state, vertecies: [null], midpoints: [], selectedVertexIndex: 0 }, 'ArrowRight')).toEqual([-1, null]) + + // Falls back to the map centre when the current index has no pixel, and can resolve a vertex target + const fresh = createHarness() + expect(fresh.ctx.getVertexOrMidpoint({ ...fresh.state, selectedVertexIndex: -1 }, 'ArrowRight')[0]).toBeGreaterThanOrEqual(0) + const types = new Set() + for (let s = 0; s < fresh.state.vertecies.length + fresh.state.midpoints.length; s++) { + for (const d of ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown']) { + types.add(fresh.ctx.getVertexOrMidpoint({ ...fresh.state, selectedVertexIndex: s }, d)[1]) + } + } + expect(types.has('vertex')).toBe(true) + }) + + test('getVertexIndexFromMidpoint maps coord paths (insertion index, wrap-around and fallback)', () => { + const { ctx, state, features } = createHarness() + expect(ctx.getVertexIndexFromMidpoint(state, '0.2')).toBe(5) + expect(ctx.getVertexIndexFromMidpoint(state, '0.0')).toBe(6) + expect(ctx.getVertexIndexFromMidpoint(state, '5.0')).toBe(4) + + // Counts earlier segments for multi-part geometry (closed and open) + features.set('multipoly', { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]], [[[5, 5], [6, 5], [6, 6], [5, 5]]]] }) + expect(ctx.getVertexIndexFromMidpoint({ ...state, featureId: 'multipoly', vertecies: new Array(8).fill([0, 0]) }, '1.0.1')).toBeGreaterThan(8) + features.set('multiline', { type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[5, 5], [6, 6]]] }) + expect(ctx.getVertexIndexFromMidpoint({ ...state, featureId: 'multiline', vertecies: new Array(4).fill([0, 0]) }, '1.1')).toBeGreaterThan(4) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/snap/constants.js b/plugins/draw/src/adapters/maplibre/snap/constants.js new file mode 100644 index 000000000..f19866478 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/snap/constants.js @@ -0,0 +1,2 @@ +export const SNAP_HELPER_LAYER = 'snap-helper-circle' +export const DRAW_HOT_SOURCE = 'mapbox-gl-draw-hot' diff --git a/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js b/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js new file mode 100644 index 000000000..608b72841 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js @@ -0,0 +1,39 @@ +import { pollUntil, patchSourceData } from './sourceData.js' +import { createSnapInstance, ensureSnapLayer } from './snapInstance.js' +import { SNAP_HELPER_LAYER, DRAW_HOT_SOURCE } from './constants.js' + +/** Re-patch the source and restore the snap layer whenever the style reloads */ +export function registerStyleLoadHandler (map, draw, config) { + map.on('style.load', () => { + pollUntil( + () => map._removed ? null : map.getSource(DRAW_HOT_SOURCE), + (source) => { + patchSourceData(source) + ensureSnapLayer(map) + if (!map._snapInstance) { + createSnapInstance(map, draw, source, config) + } + } + ) + }) +} + +/** Suppress snap processing during zoom and reset the indicator afterwards */ +export function registerZoomHandlers (map) { + // Suppress snap processing during zoom (indicator freezes in place) + map.on('zoomstart', () => { + map._isZooming = true + }) + + map.on('zoomend', () => { + map._isZooming = false + // Force hide then re-show to reset indicator at new zoom level (Safari fix) + if (map.getLayer(SNAP_HELPER_LAYER)) { + map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'none') + const snap = map._snapInstance + if (snap?.status) { + map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible') + } + } + }) +} diff --git a/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js b/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js new file mode 100644 index 000000000..ce9c54b91 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js @@ -0,0 +1,106 @@ +import { registerStyleLoadHandler, registerZoomHandlers } from './mapHandlers.js' +import { createSnapInstance, ensureSnapLayer } from './snapInstance.js' +import { patchSourceData } from './sourceData.js' + +const SNAP_LAYER = 'snap-helper-circle' +const DRAW_SOURCE = 'mapbox-gl-draw-hot' + +jest.mock('./snapInstance.js', () => ({ + createSnapInstance: jest.fn(), + ensureSnapLayer: jest.fn() +})) + +jest.mock('./sourceData.js', () => ({ + ...jest.requireActual('./sourceData.js'), + patchSourceData: jest.fn() +})) + +const config = { layers: [], radius: 12, rules: [], status: false, onSnapped: () => {} } + +const handlerFor = (map, name) => map.on.mock.calls.find(([event]) => event === name)?.[1] + +const makeMap = (overrides = {}) => ({ + on: jest.fn(), + getSource: jest.fn((id) => (id === DRAW_SOURCE ? { id: 'hot' } : null)), + getLayer: jest.fn(() => null), + setLayoutProperty: jest.fn(), + ...overrides +}) + +beforeEach(() => { + jest.clearAllMocks() + global.requestAnimationFrame = jest.fn() +}) + +describe('registerStyleLoadHandler', () => { + test('re-patches the source, ensures the layer and creates a missing instance', () => { + const map = makeMap() + const draw = { id: 'draw' } + + registerStyleLoadHandler(map, draw, config) + handlerFor(map, 'style.load')() + + expect(patchSourceData).toHaveBeenCalledWith({ id: 'hot' }) + expect(ensureSnapLayer).toHaveBeenCalledWith(map) + expect(createSnapInstance).toHaveBeenCalledWith(map, draw, { id: 'hot' }, config) + }) + + test('does not recreate an existing instance', () => { + const map = makeMap({ _snapInstance: { id: 'exists' } }) + + registerStyleLoadHandler(map, {}, config) + handlerFor(map, 'style.load')() + + expect(ensureSnapLayer).toHaveBeenCalledWith(map) + expect(createSnapInstance).not.toHaveBeenCalled() + }) + + test('stops when the map has been removed', () => { + const map = makeMap({ _removed: true }) + + registerStyleLoadHandler(map, {}, config) + handlerFor(map, 'style.load')() + + expect(ensureSnapLayer).not.toHaveBeenCalled() + expect(createSnapInstance).not.toHaveBeenCalled() + }) +}) + +describe('registerZoomHandlers', () => { + test('zoomstart sets the zooming flag', () => { + const map = makeMap() + registerZoomHandlers(map) + handlerFor(map, 'zoomstart')() + expect(map._isZooming).toBe(true) + }) + + test('zoomend clears the flag and re-shows the indicator when snapping is active', () => { + const map = makeMap({ getLayer: jest.fn(() => ({ id: 'layer' })), _snapInstance: { status: true } }) + + registerZoomHandlers(map) + handlerFor(map, 'zoomend')() + + expect(map._isZooming).toBe(false) + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'visible') + }) + + test('zoomend hides but does not re-show when snapping is disabled', () => { + const map = makeMap({ getLayer: jest.fn(() => ({ id: 'layer' })), _snapInstance: { status: false } }) + + registerZoomHandlers(map) + handlerFor(map, 'zoomend')() + + expect(map.setLayoutProperty).toHaveBeenCalledTimes(1) + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'none') + }) + + test('zoomend does nothing when the snap layer is absent', () => { + const map = makeMap({ getLayer: jest.fn(() => null) }) + + registerZoomHandlers(map) + handlerFor(map, 'zoomend')() + + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js new file mode 100644 index 000000000..5c6e23457 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js @@ -0,0 +1,148 @@ +import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' +import { polygon, lineString } from '@turf/helpers' +import { TOLERANCES } from '../defaults.js' +import { SNAP_HELPER_LAYER } from './constants.js' + +const MAX_SNAP_FEATURE_CACHE = 100 + +// Skip setMapData/drawingSnapCheck when disabled; ensure layer visibility when enabled +function patchDataMethods (proto, orig) { + proto.setMapData = function (data) { + if (!this.status) { + return undefined + } + const result = orig.setMapData.call(this, data) + if (data?.features?.length > 0 && this.map?.getLayer(SNAP_HELPER_LAYER)) { + this.map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible') + } + return result + } + + // Skip drawingSnapCheck when disabled + proto.drawingSnapCheck = function () { + if (!this.status) { + return undefined + } + return orig.drawingSnapCheck.call(this) + } +} + +// Fix typo ('coodinates') and validate coordinates; query within a radius bbox +function patchGeometryMethods (proto, orig) { + proto.getLines = function (feature, mouse, radiusArg) { + const geom = feature.geometry + if (!geom?.coordinates) { + return [] + } + const coords = geom.coordinates + // Validate that we have actual coordinate arrays + if (!Array.isArray(coords) || coords.length === 0) { + return [] + } + try { + if (geom.type === 'MultiPolygon') { + return coords.filter(c => Array.isArray(c) && c.length > 0).map(c => polygon(c)) + } + if (geom.type === 'MultiLineString') { + return coords.filter(c => Array.isArray(c) && c.length > 0).map(c => lineString(c)) + } + return orig.getLines.call(this, feature, mouse, radiusArg) + } catch { + // Invalid geometry - skip this feature + return [] + } + } + + // Query within radius bbox instead of just point, filter to existing layers + proto.getCloseFeatures = function (e, radiusInMeters) { + if (!this.status) { + return [] + } + // Use active layers (per-call override) or fall back to default layers + const activeLayers = this._activeLayers || this._defaultLayers || [] + this.options.layers = activeLayers.filter(l => this.map.getLayer(l)) + const r = this.options.radius || TOLERANCES.snapRadius + const origPt = e.point + e.point = [[origPt.x - r, origPt.y - r], [origPt.x + r, origPt.y + r]] + const result = orig.getCloseFeatures.call(this, e, radiusInMeters) + e.point = origPt + return result + } +} + +// Custom colors for snap indicators +function patchColorMethods (proto, orig, colors) { + proto.searchInVertex = function (...args) { + const r = orig.searchInVertex.apply(this, args) + if (r) { + r.color = colors.vertex + } + return r + } + proto.searchInMidPoint = function (...args) { + const r = orig.searchInMidPoint.apply(this, args) + if (r) { + r.color = colors.midpoint + } + return r + } + proto.searchInEdge = function (...args) { + const r = orig.searchInEdge.apply(this, args) + if (r) { + r.color = colors.edge + } + return r + } +} + +// Skip when disabled or zooming; clean up internal arrays to prevent memory accumulation +function patchSnapMethod (proto, orig) { + proto.snapToClosestPoint = function (e) { + if (!this.status || this.map?._isZooming) { + return undefined + } + try { + const result = orig.snapToClosestPoint.call(this, e) + if (this.closeFeatures?.length > MAX_SNAP_FEATURE_CACHE) { + this.closeFeatures.length = 0 + } + if (this.lines?.length > MAX_SNAP_FEATURE_CACHE) { + this.lines.length = 0 + } + return result + } catch { + // Invalid geometry encountered - clear state and continue + this.snapStatus = false + this.snapCoords = null + return undefined + } + } +} + +/** Apply patches to MapboxSnap prototype (once only) */ +export function applyMapboxSnapPatches (colors) { + if (MapboxSnap.prototype.__snapPatched) { + return + } + MapboxSnap.prototype.__snapPatched = true + + const proto = MapboxSnap.prototype + const orig = { + setMapData: proto.setMapData, + drawingSnapCheck: proto.drawingSnapCheck, + getLines: proto.getLines, + getCloseFeatures: proto.getCloseFeatures, + searchInVertex: proto.searchInVertex, + searchInMidPoint: proto.searchInMidPoint, + searchInEdge: proto.searchInEdge, + snapToClosestPoint: proto.snapToClosestPoint + } + + // Disable changeSnappedPoints - we handle snap ourselves in drag handlers + proto.changeSnappedPoints = () => {} + + patchDataMethods(proto, orig) + patchGeometryMethods(proto, orig) + patchColorMethods(proto, orig, colors) + patchSnapMethod(proto, orig) +} diff --git a/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js new file mode 100644 index 000000000..109785049 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js @@ -0,0 +1,238 @@ +import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' +import { polygon, lineString } from '@turf/helpers' +import { applyMapboxSnapPatches } from './prototypePatches.js' + +const SNAP_LAYER = 'snap-helper-circle' +const COLORS = { vertex: 'colour-v', midpoint: 'colour-m', edge: 'colour-e' } + +jest.mock('mapbox-gl-snap/dist/esm/MapboxSnap.js', () => { + const orig = { + setMapData: jest.fn(function () { return 'orig-setMapData' }), + drawingSnapCheck: jest.fn(function () { return 'orig-drawingSnapCheck' }), + getLines: jest.fn(function () { return 'orig-getLines' }), + getCloseFeatures: jest.fn(function () { return 'orig-getCloseFeatures' }), + searchInVertex: jest.fn(function () { return { id: 'v' } }), + searchInMidPoint: jest.fn(function () { return { id: 'm' } }), + searchInEdge: jest.fn(function () { return { id: 'e' } }), + snapToClosestPoint: jest.fn(function () { return 'orig-snap' }) + } + const MockSnap = jest.fn() + Object.assign(MockSnap.prototype, orig) + MockSnap.__orig = orig + return { __esModule: true, default: MockSnap } +}) + +jest.mock('@turf/helpers', () => ({ + polygon: jest.fn((c) => ({ poly: c })), + lineString: jest.fn((c) => ({ line: c })) +})) + +jest.mock('../defaults.js', () => ({ TOLERANCES: { snapRadius: 12 } })) + +beforeAll(() => { + applyMapboxSnapPatches(COLORS) +}) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('applyMapboxSnapPatches', () => { + test('patches the prototype only once', () => { + const patched = MapboxSnap.prototype.setMapData + applyMapboxSnapPatches(COLORS) + expect(MapboxSnap.prototype.setMapData).toBe(patched) + }) + + test('replaces changeSnappedPoints with a no-op', () => { + expect(MapboxSnap.prototype.changeSnappedPoints()).toBeUndefined() + }) +}) + +describe('setMapData', () => { + test('returns undefined and skips the original when disabled', () => { + expect(MapboxSnap.prototype.setMapData.call({ status: false }, {})).toBeUndefined() + expect(MapboxSnap.__orig.setMapData).not.toHaveBeenCalled() + }) + + test('delegates and shows the layer when features exist and the layer is present', () => { + const map = { getLayer: jest.fn(() => ({})), setLayoutProperty: jest.fn() } + const data = { features: [{}] } + + const result = MapboxSnap.prototype.setMapData.call({ status: true, map }, data) + + expect(MapboxSnap.__orig.setMapData).toHaveBeenCalledWith(data) + expect(result).toBe('orig-setMapData') + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'visible') + }) + + test('does not show the layer when there are no features', () => { + const map = { getLayer: jest.fn(() => ({})), setLayoutProperty: jest.fn() } + MapboxSnap.prototype.setMapData.call({ status: true, map }, { features: [] }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('does not show the layer when it is absent', () => { + const map = { getLayer: jest.fn(() => null), setLayoutProperty: jest.fn() } + MapboxSnap.prototype.setMapData.call({ status: true, map }, { features: [{}] }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('tolerates a missing map and missing data', () => { + expect(MapboxSnap.prototype.setMapData.call({ status: true }, { features: [{}] })).toBe('orig-setMapData') + expect(MapboxSnap.prototype.setMapData.call({ status: true }, undefined)).toBe('orig-setMapData') + }) +}) + +describe('drawingSnapCheck', () => { + test('returns undefined when disabled', () => { + expect(MapboxSnap.prototype.drawingSnapCheck.call({ status: false })).toBeUndefined() + }) + + test('delegates to the original when enabled', () => { + expect(MapboxSnap.prototype.drawingSnapCheck.call({ status: true })).toBe('orig-drawingSnapCheck') + expect(MapboxSnap.__orig.drawingSnapCheck).toHaveBeenCalled() + }) +}) + +describe('getLines', () => { + const call = (self, feature) => MapboxSnap.prototype.getLines.call(self, feature, 'mouse', 5) + + test('returns [] when geometry or coordinates are missing', () => { + expect(call({}, { geometry: null })).toEqual([]) + expect(call({}, { geometry: {} })).toEqual([]) + }) + + test('returns [] when coordinates are not a non-empty array', () => { + expect(call({}, { geometry: { coordinates: 'x' } })).toEqual([]) + expect(call({}, { geometry: { coordinates: [] } })).toEqual([]) + }) + + test('maps MultiPolygon coordinates via turf polygon, filtering empties', () => { + const feature = { geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0]]], []] } } + expect(call({}, feature)).toEqual([{ poly: [[[0, 0]]] }]) + expect(polygon).toHaveBeenCalledWith([[[0, 0]]]) + }) + + test('maps MultiLineString coordinates via turf lineString', () => { + const feature = { geometry: { type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]]] } } + expect(call({}, feature)).toEqual([{ line: [[0, 0], [1, 1]] }]) + expect(lineString).toHaveBeenCalledWith([[0, 0], [1, 1]]) + }) + + test('delegates other geometry types to the original', () => { + const feature = { geometry: { type: 'Polygon', coordinates: [[0, 0]] } } + expect(call({}, feature)).toBe('orig-getLines') + expect(MapboxSnap.__orig.getLines).toHaveBeenCalledWith(feature, 'mouse', 5) + }) + + test('returns [] when turf throws on invalid geometry', () => { + polygon.mockImplementationOnce(() => { throw new Error('bad') }) + const feature = { geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0]]]] } } + expect(call({}, feature)).toEqual([]) + }) +}) + +describe('getCloseFeatures', () => { + test('returns [] when disabled', () => { + expect(MapboxSnap.prototype.getCloseFeatures.call({ status: false }, {}, 1)).toEqual([]) + }) + + test('expands the query to a radius bbox and restores the point', () => { + const map = { getLayer: jest.fn((l) => l === 'layerA') } + const self = { status: true, map, _activeLayers: ['layerA', 'missing'], options: { radius: 10 } } + const e = { point: { x: 100, y: 200 } } + + MapboxSnap.__orig.getCloseFeatures.mockImplementationOnce(function (ev) { + expect(ev.point).toEqual([[90, 190], [110, 210]]) + return 'orig-getCloseFeatures' + }) + + const result = MapboxSnap.prototype.getCloseFeatures.call(self, e, 3) + + expect(self.options.layers).toEqual(['layerA']) + expect(e.point).toEqual({ x: 100, y: 200 }) + expect(result).toBe('orig-getCloseFeatures') + }) + + test('falls back to default layers and the configured radius', () => { + const map = { getLayer: jest.fn(() => true) } + const self = { status: true, map, _activeLayers: null, _defaultLayers: ['b'], options: {} } + const e = { point: { x: 0, y: 0 } } + + MapboxSnap.__orig.getCloseFeatures.mockImplementationOnce(function (ev) { + expect(ev.point).toEqual([[-12, -12], [12, 12]]) + return [] + }) + + MapboxSnap.prototype.getCloseFeatures.call(self, e, 1) + expect(self.options.layers).toEqual(['b']) + }) + + test('handles a completely missing layer configuration', () => { + const map = { getLayer: jest.fn(() => true) } + const self = { status: true, map, options: {} } + const e = { point: { x: 0, y: 0 } } + + MapboxSnap.prototype.getCloseFeatures.call(self, e, 1) + expect(self.options.layers).toEqual([]) + }) +}) + +describe('colour methods', () => { + test('searchInVertex tints the result and passes through falsy results', () => { + expect(MapboxSnap.prototype.searchInVertex.call({}).color).toBe(COLORS.vertex) + MapboxSnap.__orig.searchInVertex.mockReturnValueOnce(null) + expect(MapboxSnap.prototype.searchInVertex.call({})).toBeNull() + }) + + test('searchInMidPoint tints the result and passes through falsy results', () => { + expect(MapboxSnap.prototype.searchInMidPoint.call({}).color).toBe(COLORS.midpoint) + MapboxSnap.__orig.searchInMidPoint.mockReturnValueOnce(null) + expect(MapboxSnap.prototype.searchInMidPoint.call({})).toBeNull() + }) + + test('searchInEdge tints the result and passes through falsy results', () => { + expect(MapboxSnap.prototype.searchInEdge.call({}).color).toBe(COLORS.edge) + MapboxSnap.__orig.searchInEdge.mockReturnValueOnce(null) + expect(MapboxSnap.prototype.searchInEdge.call({})).toBeNull() + }) +}) + +describe('snapToClosestPoint', () => { + test('skips and returns undefined when disabled', () => { + expect(MapboxSnap.prototype.snapToClosestPoint.call({ status: false })).toBeUndefined() + expect(MapboxSnap.__orig.snapToClosestPoint).not.toHaveBeenCalled() + }) + + test('skips while the map is zooming', () => { + expect(MapboxSnap.prototype.snapToClosestPoint.call({ status: true, map: { _isZooming: true } }, {})).toBeUndefined() + }) + + test('delegates and trims oversized caches', () => { + const self = { status: true, map: {}, closeFeatures: new Array(101), lines: new Array(101) } + expect(MapboxSnap.prototype.snapToClosestPoint.call(self, {})).toBe('orig-snap') + expect(self.closeFeatures).toHaveLength(0) + expect(self.lines).toHaveLength(0) + }) + + test('leaves small caches intact', () => { + const self = { status: true, map: {}, closeFeatures: [1], lines: [2] } + MapboxSnap.prototype.snapToClosestPoint.call(self, {}) + expect(self.closeFeatures).toHaveLength(1) + expect(self.lines).toHaveLength(1) + }) + + test('tolerates absent caches and a missing map', () => { + expect(MapboxSnap.prototype.snapToClosestPoint.call({ status: true }, {})).toBe('orig-snap') + }) + + test('clears snap state when the original throws', () => { + MapboxSnap.__orig.snapToClosestPoint.mockImplementationOnce(() => { throw new Error('x') }) + const self = { status: true, map: {}, snapStatus: true, snapCoords: [1, 2] } + + expect(MapboxSnap.prototype.snapToClosestPoint.call(self, {})).toBeUndefined() + expect(self.snapStatus).toBe(false) + expect(self.snapCoords).toBeNull() + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/snap/snapInstance.js b/plugins/draw/src/adapters/maplibre/snap/snapInstance.js new file mode 100644 index 000000000..11a4af885 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/snap/snapInstance.js @@ -0,0 +1,115 @@ +import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' +import { patchSourceData } from './sourceData.js' +import { SNAP_HELPER_LAYER } from './constants.js' + +/** Remove any stale snap helper source/layer before creating a new instance */ +function cleanupOldSnap (map) { + if (map.getLayer(SNAP_HELPER_LAYER)) { + map.removeLayer(SNAP_HELPER_LAYER) + } + if (map.getSource(SNAP_HELPER_LAYER)) { + map.removeSource(SNAP_HELPER_LAYER) + } +} + +// Snap indicators (and all snap processing) are only meaningful while actively +// drawing or editing vertices — outside these modes there is nothing to snap to. +const SNAP_ACTIVE_MODES = new Set(['draw_polygon', 'draw_line', 'edit_vertex']) + +/** + * Externally-controlled status: ignore library writes so status is only changed + * via setSnapStatus(). The library otherwise sets status=true on mode/selection change. + * + * Also gated on the current draw mode: the library's own mousemove listener + * (added once in its constructor) calls snapToClosestPoint on every mouse move + * regardless of draw mode, repainting the snap-helper-circle layer whenever + * status is true. Without this gate, the marker reappears on the very next + * mouse move after leaving draw/edit mode, since mapbox-gl-draw's public + * changeMode API is silent by default and rarely gives us a mode-change event + * to react to. + */ +function defineControlledStatus (snap, initialStatus, draw) { + let controlledStatus = initialStatus + + Object.defineProperty(snap, 'status', { + get () { // nosonar + return controlledStatus && SNAP_ACTIVE_MODES.has(draw.getMode()) + }, + set () { // nosonar + // intentionally empty: library writes are ignored + }, + configurable: true + }) + + snap.setSnapStatus = (value) => { + controlledStatus = value + } +} + +/** Store default snap layers and expose a per-call override setter (null resets) */ +function configureSnapLayers (snap, layers) { + snap._defaultLayers = layers + snap._activeLayers = null + + snap.setSnapLayers = (overrideLayers) => { + if (overrideLayers === null || overrideLayers === undefined) { + snap._activeLayers = null // Use defaults + } else if (Array.isArray(overrideLayers)) { + snap._activeLayers = overrideLayers // Override defaults + } else { + // No action + } + } +} + +/** Create the snap instance once the draw source is available */ +export function createSnapInstance (map, draw, source, config) { + // Prevent duplicate creation (race between initial poll and style.load) + if (map._snapInstance || map._snapCreating) { + return map._snapInstance + } + + map._snapCreating = true + cleanupOldSnap(map) + patchSourceData(source) + + /** @type {any} */ + const snap = new MapboxSnap({ + map, + drawing: draw, + options: { layers: config.layers, radius: config.radius, rules: config.rules }, + status: config.status, + onSnapped: config.onSnapped + }) + + defineControlledStatus(snap, config.status, draw) + configureSnapLayers(snap, config.layers) + + // Apply any pending snap layers that were set before the instance was ready + if (map._pendingSnapLayers !== undefined) { + snap.setSnapLayers(map._pendingSnapLayers) + delete map._pendingSnapLayers + } + + map._snapInstance = snap + return snap +} + +/** Ensure the snap helper source and layer exist after a style change */ +export function ensureSnapLayer (map) { + if (!map.getSource(SNAP_HELPER_LAYER)) { + map.addSource(SNAP_HELPER_LAYER, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }) + } + if (!map.getLayer(SNAP_HELPER_LAYER)) { + map.addLayer({ + id: SNAP_HELPER_LAYER, + type: 'fill', + source: SNAP_HELPER_LAYER, + paint: { 'fill-color': ['get', 'color'] }, + layout: { visibility: map._snapInstance?.status ? 'visible' : 'none' } + }) + } +} diff --git a/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js b/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js new file mode 100644 index 000000000..02399ce89 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js @@ -0,0 +1,151 @@ +import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' +import { patchSourceData } from './sourceData.js' +import { createSnapInstance, ensureSnapLayer } from './snapInstance.js' + +const SNAP_LAYER = 'snap-helper-circle' + +jest.mock('mapbox-gl-snap/dist/esm/MapboxSnap.js', () => ({ + __esModule: true, + default: jest.fn(function (opts) { Object.assign(this, opts) }) +})) + +jest.mock('./sourceData.js', () => ({ patchSourceData: jest.fn() })) + +const config = { layers: ['a'], radius: 12, rules: ['vertex'], status: false, onSnapped: () => {} } + +const makeMap = (overrides = {}) => ({ + getLayer: jest.fn(() => null), + removeLayer: jest.fn(), + getSource: jest.fn(() => null), + removeSource: jest.fn(), + addSource: jest.fn(), + addLayer: jest.fn(), + ...overrides +}) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('createSnapInstance', () => { + test('constructs the snap instance, patches the source and stores it on the map', () => { + const map = makeMap() + const draw = { id: 'draw' } + const source = { id: 'src' } + + const snap = createSnapInstance(map, draw, source, config) + + expect(patchSourceData).toHaveBeenCalledWith(source) + expect(MapboxSnap).toHaveBeenCalledWith({ + map, + drawing: draw, + options: { layers: ['a'], radius: 12, rules: ['vertex'] }, + status: false, + onSnapped: config.onSnapped + }) + expect(map._snapInstance).toBe(snap) + }) + + test('returns the existing instance without recreating', () => { + const map = makeMap({ _snapInstance: { id: 'existing' } }) + expect(createSnapInstance(map, {}, {}, config)).toEqual({ id: 'existing' }) + expect(MapboxSnap).not.toHaveBeenCalled() + }) + + test('bails out while another creation is in progress', () => { + const map = makeMap({ _snapCreating: true }) + createSnapInstance(map, {}, {}, config) + expect(MapboxSnap).not.toHaveBeenCalled() + }) + + test('removes a stale snap layer and source before creating', () => { + const map = makeMap({ getLayer: jest.fn(() => ({})), getSource: jest.fn(() => ({})) }) + createSnapInstance(map, {}, {}, config) + expect(map.removeLayer).toHaveBeenCalledWith(SNAP_LAYER) + expect(map.removeSource).toHaveBeenCalledWith(SNAP_LAYER) + }) + + test('exposes an externally-controlled status via setSnapStatus', () => { + const map = makeMap() + const draw = { getMode: jest.fn(() => 'draw_polygon') } + const snap = createSnapInstance(map, draw, {}, { ...config, status: false }) + + expect(snap.status).toBe(false) + snap.status = true // library write ignored + expect(snap.status).toBe(false) + snap.setSnapStatus(true) + expect(snap.status).toBe(true) + }) + + test('status is false outside draw/edit modes even when the toggle is on', () => { + const map = makeMap() + const draw = { getMode: jest.fn(() => 'simple_select') } + const snap = createSnapInstance(map, draw, {}, { ...config, status: false }) + + snap.setSnapStatus(true) + expect(snap.status).toBe(false) + + draw.getMode.mockReturnValue('edit_vertex') + expect(snap.status).toBe(true) + }) + + test('setSnapLayers overrides, resets and ignores invalid input', () => { + const map = makeMap() + const snap = createSnapInstance(map, {}, {}, { ...config, layers: ['a'] }) + + expect(snap._defaultLayers).toEqual(['a']) + expect(snap._activeLayers).toBeNull() + + snap.setSnapLayers(['b', 'c']) + expect(snap._activeLayers).toEqual(['b', 'c']) + + snap.setSnapLayers(null) + expect(snap._activeLayers).toBeNull() + + snap.setSnapLayers(['d']) + snap.setSnapLayers(undefined) + expect(snap._activeLayers).toBeNull() + + snap.setSnapLayers(['x']) + snap.setSnapLayers('not-an-array') // no action + expect(snap._activeLayers).toEqual(['x']) + }) + + test('applies pending snap layers set before the instance was ready', () => { + const map = makeMap({ _pendingSnapLayers: ['pending'] }) + const snap = createSnapInstance(map, {}, {}, { ...config, layers: ['default'] }) + + expect(snap._activeLayers).toEqual(['pending']) + expect(map._pendingSnapLayers).toBeUndefined() + }) +}) + +describe('ensureSnapLayer', () => { + test('adds the missing source and layer (hidden when snapping is disabled)', () => { + const map = makeMap() + ensureSnapLayer(map) + + expect(map.addSource).toHaveBeenCalledWith(SNAP_LAYER, expect.objectContaining({ type: 'geojson' })) + expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ + id: SNAP_LAYER, + layout: { visibility: 'none' } + })) + }) + + test('shows the layer when snapping is active', () => { + const map = makeMap({ _snapInstance: { status: true } }) + ensureSnapLayer(map) + + expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ + layout: { visibility: 'visible' } + })) + }) + + test('does not re-add an existing source or layer', () => { + const map = makeMap({ getSource: jest.fn(() => ({})), getLayer: jest.fn(() => ({})) }) + ensureSnapLayer(map) + + expect(map.addSource).not.toHaveBeenCalled() + expect(map.addLayer).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/snap/sourceData.js b/plugins/draw/src/adapters/maplibre/snap/sourceData.js new file mode 100644 index 000000000..51c4c8d92 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/snap/sourceData.js @@ -0,0 +1,34 @@ +/** + * Poll until checkFn returns truthy, then call onSuccess with the result. + * A `null` result signals to stop polling; any other falsy value keeps polling. + */ +export function pollUntil (checkFn, onSuccess) { + (function poll () { + const result = checkFn() + if (result === null) { + return + } + result ? onSuccess(result) : requestAnimationFrame(poll) + })() +} + +/** + * Patch a GeoJSON source to expose _data for MapboxSnap compatibility. + * MapboxSnap expects source._data.features but MapLibre doesn't expose this. + */ +export function patchSourceData (source) { + if (!source || (source._data && Array.isArray(source._data?.features))) { + return + } + + let dataCache = { type: 'FeatureCollection', features: [] } + Object.defineProperty(source, '_data', { + get: () => dataCache, + set: (val) => { + dataCache = (val && typeof val === 'object' && Array.isArray(val.features)) + ? val + : { type: 'FeatureCollection', features: [] } + }, + configurable: true + }) +} diff --git a/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js b/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js new file mode 100644 index 000000000..bbd60890f --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js @@ -0,0 +1,62 @@ +import { pollUntil, patchSourceData } from './sourceData.js' + +describe('pollUntil', () => { + beforeEach(() => { + global.requestAnimationFrame = jest.fn() + }) + + test('stops when checkFn returns null', () => { + const onSuccess = jest.fn() + pollUntil(() => null, onSuccess) + expect(onSuccess).not.toHaveBeenCalled() + expect(global.requestAnimationFrame).not.toHaveBeenCalled() + }) + + test('calls onSuccess when checkFn returns a truthy value', () => { + const onSuccess = jest.fn() + pollUntil(() => 'ready', onSuccess) + expect(onSuccess).toHaveBeenCalledWith('ready') + }) + + test('reschedules via requestAnimationFrame until the value becomes available', () => { + const values = [undefined, 'ready'] + let i = 0 + global.requestAnimationFrame = jest.fn((cb) => cb()) + const onSuccess = jest.fn() + + pollUntil(() => values[i++], onSuccess) + + expect(global.requestAnimationFrame).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith('ready') + }) +}) + +describe('patchSourceData', () => { + test('ignores a nullish source', () => { + expect(() => patchSourceData(null)).not.toThrow() + }) + + test('ignores a source that already exposes _data.features', () => { + const existing = { features: [] } + const source = { _data: existing } + patchSourceData(source) + expect(source._data).toBe(existing) + }) + + test('installs a _data accessor that normalizes assigned values', () => { + const source = {} + patchSourceData(source) + + expect(source._data).toEqual({ type: 'FeatureCollection', features: [] }) + + const fc = { type: 'FeatureCollection', features: [{ id: 1 }] } + source._data = fc + expect(source._data).toBe(fc) + + source._data = 'nonsense' + expect(source._data).toEqual({ type: 'FeatureCollection', features: [] }) + + source._data = { features: 'not-array' } + expect(source._data).toEqual({ type: 'FeatureCollection', features: [] }) + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/styles.js b/plugins/draw/src/adapters/maplibre/styles.js new file mode 100755 index 000000000..f512bccc5 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/styles.js @@ -0,0 +1,207 @@ +// styles.js +import { SIZES } from './defaults.js' +import { resolveColors } from '../../utils/resolveColors.js' + +// `defaultValue` is the already-resolved fallback (colors[defaultsKey] — the +// plugin-config override if one was set, else the built-in default), not the +// raw COLORS constant, so a shapeStroke/shapeFill override applies to every +// feature that doesn't set its own stroke/fill property. +const getUserProp = (mapStyle, prop, defaultValue) => [ + 'coalesce', + ['get', `user_${prop}${mapStyle.id.charAt(0).toUpperCase() + mapStyle.id.slice(1)}`], + ['get', `user_${prop}`], + defaultValue +] + +// Inactive lines and fills +const fillInactive = (mapStyle, colors) => ({ + id: 'fill-inactive', + type: 'fill', + filter: ['all', ['==', '$type', 'Polygon'], ['==', 'active', 'false']], + paint: { 'fill-color': getUserProp(mapStyle, 'fill', colors.shapeFill) } +}) + +const strokeInactive = (mapStyle, colors) => ({ + id: 'stroke-inactive', + type: 'line', + filter: ['all', ['any', ['==', '$type', 'Polygon'], ['==', '$type', 'LineString']], ['==', 'active', 'false'], ['!has', 'user_splitter']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { + 'line-color': getUserProp(mapStyle, 'stroke', colors.shapeStroke), + 'line-width': colors.strokeWidth + } +}) + +// Active lines and fills (sketch during drawing) +const fillActive = (editFillColor) => ({ + id: 'fill-active', + type: 'fill', + filter: ['all', ['==', '$type', 'Polygon'], ['==', 'active', 'true']], + paint: { 'fill-color': editFillColor } +}) + +const strokeActive = (editStrokeColor) => ({ + id: 'stroke-active', + type: 'line', + filter: ['all', ['any', ['==', '$type', 'Polygon'], ['==', '$type', 'LineString']], ['==', 'active', 'true'], ['!has', 'user_splitter']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-opacity': 1 } +}) + +// Dashed stroke shown in place of stroke-active while the shape is invalid. Same +// filter as stroke-active; the adapter's setInvalid() toggles the two layers' +// visibility (line-dasharray can't be data-driven per feature in MapLibre). +const strokeActiveInvalid = (invalidStrokeColor) => ({ + id: 'stroke-active-invalid', + type: 'line', + filter: ['all', ['any', ['==', '$type', 'Polygon'], ['==', '$type', 'LineString']], ['==', 'active', 'true'], ['!has', 'user_splitter']], + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' }, + paint: { + 'line-color': invalidStrokeColor, + 'line-width': 2, + 'line-dasharray': [0.2, 2], // NOSONAR + 'line-opacity': 1 + } +}) + +// Splitter line +const drawInvalidSplitter = (splitInvalidColor) => ({ + id: 'stroke-invalid-splitter', + type: 'line', + filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true'], ['==', 'user_splitter', 'invalid']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { + 'line-color': splitInvalidColor, + 'line-width': 2, + 'line-dasharray': [0.2, 2], // NOSONAR + 'line-opacity': 1 + } +}) + +const drawValidSplitter = (splitValidColor) => ({ + id: 'stroke-valid-splitter', + type: 'line', + filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true'], ['==', 'user_splitter', 'valid']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { + 'line-color': splitValidColor, + 'line-width': 2, + 'line-opacity': 1 + } +}) + +// Dashed preview line +const drawPreviewLine = (editStrokeColor) => ({ + id: 'stroke-preview-line', + type: 'line', + filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true'], ['!has', 'user_splitter']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-dasharray': [0.2, 2], 'line-opacity': 1 } // NOSONAR +}) + +// Vertex layers ('draw-vertex' = display-only markers on placed vertices while drawing) +const vertex = (editVertexColor, vertexRadius) => ({ + id: 'vertex', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['in', 'meta', 'vertex', 'draw-vertex']], + paint: { 'circle-radius': vertexRadius, 'circle-color': editVertexColor } +}) + +const vertexHalo = (editHaloColor, editActiveColor, vertexHaloRadius) => ({ + id: 'vertex-halo', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex'], ['==', 'active', 'true']], + paint: { 'circle-radius': vertexHaloRadius, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } +}) + +const vertexActive = (editVertexColor, vertexRadius) => ({ + id: 'vertex-active', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex'], ['==', 'active', 'true']], + paint: { 'circle-radius': vertexRadius, 'circle-color': editVertexColor } +}) + +// Midpoints +const midpoint = (editMidpointColor, midpointRadius) => ({ + id: 'midpoint', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']], + paint: { 'circle-radius': midpointRadius, 'circle-color': editMidpointColor } +}) + +const midpointHalo = (editHaloColor, editActiveColor, midpointHaloRadius) => ({ + id: 'midpoint-halo', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint'], ['==', 'active', 'true']], + paint: { 'circle-radius': midpointHaloRadius, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } +}) + +const midpointActive = (editMidpointColor, midpointRadius) => ({ + id: 'midpoint-active', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint'], ['==', 'active', 'true']], + paint: { 'circle-radius': midpointRadius, 'circle-color': editMidpointColor } +}) + +const circle = (editStrokeColor) => ({ + id: 'circle', + type: 'line', + filter: ['==', 'id', 'circle'], + paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-opacity': 0.8 } +}) + +const touchVertexIndicator = () => ({ + id: 'touch-vertex-indicator', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'touch-vertex-indicator']], + paint: { 'circle-radius': 30, 'circle-color': '#3bb2d0', 'circle-stroke-width': 3, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.9 } +}) + +// `pluginConfig` lets callers override any of these colours/strokeWidth — see +// resolveColors() and the draw plugin's own options docs. Unaffected keys +// still resolve to the built-in COLORS/SIZES defaults. +const createDrawStyles = (mapStyle, pluginConfig = {}) => { + const colors = resolveColors(mapStyle, pluginConfig) + const { vertexRadius, midpointRadius, vertexHaloRadius, midpointHaloRadius } = SIZES + + return [ + fillInactive(mapStyle, colors), + fillActive(colors.editFill), + strokeActive(colors.editStroke), + strokeActiveInvalid(colors.invalidStroke), + strokeInactive(mapStyle, colors), + drawInvalidSplitter(colors.splitInvalid), + drawValidSplitter(colors.splitValid), + drawPreviewLine(colors.editStroke), + midpoint(colors.editMidpoint, midpointRadius), + midpointHalo(colors.editHalo, colors.editActive, midpointHaloRadius), + midpointActive(colors.editMidpoint, midpointRadius), + vertex(colors.editVertex, vertexRadius), + vertexHalo(colors.editHalo, colors.editActive, vertexHaloRadius), + vertexActive(colors.editVertex, vertexRadius), + circle(colors.editStroke), + touchVertexIndicator() + ] +} + +/** + * Helper to iterate over a MapLibre map and apply new paint properties + */ +const updateDrawStyles = (map, mapStyle, pluginConfig = {}) => { + const layers = createDrawStyles(mapStyle, pluginConfig) + layers.forEach(layer => { + Object.entries(layer.paint).forEach(([prop, value]) => { + if (map.getLayer(`${layer.id}.cold`)) { + map.setPaintProperty(`${layer.id}.cold`, prop, value) + } + if (map.getLayer(`${layer.id}.hot`)) { + map.setPaintProperty(`${layer.id}.hot`, prop, value) + } + }) + }) +} + +export { + createDrawStyles, + updateDrawStyles +} diff --git a/plugins/draw/src/adapters/maplibre/styles.test.js b/plugins/draw/src/adapters/maplibre/styles.test.js new file mode 100644 index 000000000..91d30e3f5 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/styles.test.js @@ -0,0 +1,155 @@ +import { createDrawStyles, updateDrawStyles } from './styles.js' +import { COLORS, SIZES } from './defaults.js' +import { getValueForStyle } from '../../utils/getValueForStyle.js' + +const findLayer = (layers, id) => layers.find((l) => l.id === id) + +describe('createDrawStyles', () => { + const mapStyle = { id: 'outdoor', mapColorScheme: 'light' } + + test('returns every draw layer in order', () => { + const layers = createDrawStyles(mapStyle) + expect(layers.map((l) => l.id)).toEqual([ + 'fill-inactive', 'fill-active', 'stroke-active', 'stroke-active-invalid', 'stroke-inactive', + 'stroke-invalid-splitter', 'stroke-valid-splitter', 'stroke-preview-line', + 'midpoint', 'midpoint-halo', 'midpoint-active', + 'vertex', 'vertex-halo', 'vertex-active', 'circle', 'touch-vertex-indicator' + ]) + }) + + test('the invalid stroke layer is a hidden dashed line matching the active shape', () => { + const layers = createDrawStyles(mapStyle) + const invalid = findLayer(layers, 'stroke-active-invalid') + expect(invalid.layout.visibility).toBe('none') // hidden until the shape is invalid + expect(invalid.paint['line-dasharray']).toEqual([0.2, 2]) + expect(invalid.paint['line-color']).toBe(getValueForStyle(COLORS.invalidStroke, 'light')) + expect(invalid.filter).toEqual(findLayer(layers, 'stroke-active').filter) + }) + + test('resolves light-scheme colours', () => { + const layers = createDrawStyles({ id: 'outdoor', mapColorScheme: 'light' }) + expect(findLayer(layers, 'stroke-active').paint['line-color']).toBe(getValueForStyle(COLORS.editStroke, 'light')) + expect(findLayer(layers, 'fill-active').paint['fill-color']).toBe(getValueForStyle(COLORS.editFill, 'light')) + expect(findLayer(layers, 'vertex').paint['circle-color']).toBe(getValueForStyle(COLORS.editVertex, 'light')) + expect(findLayer(layers, 'midpoint').paint['circle-color']).toBe(getValueForStyle(COLORS.editMidpoint, 'light')) + }) + + test('resolves dark-scheme colours', () => { + const layers = createDrawStyles({ id: 'night', mapColorScheme: 'dark' }) + expect(findLayer(layers, 'stroke-active').paint['line-color']).toBe(getValueForStyle(COLORS.editStroke, 'dark')) + expect(findLayer(layers, 'vertex-halo').paint['circle-color']).toBe(getValueForStyle(COLORS.editHalo, 'dark')) + expect(findLayer(layers, 'vertex-halo').paint['circle-stroke-color']).toBe(getValueForStyle(COLORS.editActive, 'dark')) + }) + + test('defaults to the light scheme when none is provided', () => { + const layers = createDrawStyles({ id: 'outdoor' }) + expect(findLayer(layers, 'stroke-active').paint['line-color']).toBe(getValueForStyle(COLORS.editStroke, 'light')) + }) + + test('applies the configured sizes', () => { + const layers = createDrawStyles(mapStyle) + expect(findLayer(layers, 'vertex').paint['circle-radius']).toBe(SIZES.vertexRadius) + expect(findLayer(layers, 'vertex-halo').paint['circle-radius']).toBe(SIZES.vertexHaloRadius) + expect(findLayer(layers, 'midpoint').paint['circle-radius']).toBe(SIZES.midpointRadius) + expect(findLayer(layers, 'midpoint-halo').paint['circle-radius']).toBe(SIZES.midpointHaloRadius) + expect(findLayer(layers, 'stroke-inactive').paint['line-width']).toBe(SIZES.strokeWidth) + }) + + test('builds per-style user-property coalesce expressions for inactive fill and stroke', () => { + const layers = createDrawStyles({ id: 'outdoor', mapColorScheme: 'light' }) + + expect(findLayer(layers, 'fill-inactive').paint['fill-color']).toEqual([ + 'coalesce', + ['get', 'user_fillOutdoor'], + ['get', 'user_fill'], + COLORS.shapeFill + ]) + expect(findLayer(layers, 'stroke-inactive').paint['line-color']).toEqual([ + 'coalesce', + ['get', 'user_strokeOutdoor'], + ['get', 'user_stroke'], + COLORS.shapeStroke + ]) + }) + + test('capitalises the style id in the user-property key', () => { + const layers = createDrawStyles({ id: 'satellite', mapColorScheme: 'light' }) + expect(findLayer(layers, 'fill-inactive').paint['fill-color'][1]).toEqual(['get', 'user_fillSatellite']) + }) + + test('colours the splitter and touch-indicator layers', () => { + const layers = createDrawStyles(mapStyle) + expect(findLayer(layers, 'stroke-invalid-splitter').paint['line-color']).toBe(getValueForStyle(COLORS.splitInvalid, 'light')) + expect(findLayer(layers, 'stroke-valid-splitter').paint['line-color']).toBe(getValueForStyle(COLORS.splitValid, 'light')) + expect(findLayer(layers, 'touch-vertex-indicator').paint['circle-color']).toBe('#3bb2d0') + }) + + test('a pluginConfig override replaces the default colour/width everywhere it applies', () => { + const pluginConfig = { editStroke: '#custom-stroke', shapeStroke: '#custom-shape', strokeWidth: 9 } + const layers = createDrawStyles(mapStyle, pluginConfig) + + expect(findLayer(layers, 'stroke-active').paint['line-color']).toBe('#custom-stroke') + expect(findLayer(layers, 'stroke-preview-line').paint['line-color']).toBe('#custom-stroke') + expect(findLayer(layers, 'circle').paint['line-color']).toBe('#custom-stroke') + expect(findLayer(layers, 'stroke-inactive').paint['line-color']).toEqual([ + 'coalesce', + ['get', 'user_strokeOutdoor'], + ['get', 'user_stroke'], + '#custom-shape' + ]) + expect(findLayer(layers, 'stroke-inactive').paint['line-width']).toBe(9) + }) + + test('a per-feature stroke/fill property still wins over a pluginConfig override — coalesce checks it first', () => { + const layers = createDrawStyles(mapStyle, { shapeStroke: '#custom-shape' }) + const [, userStyleKey, userKey, fallback] = findLayer(layers, 'stroke-inactive').paint['line-color'] + expect(userStyleKey).toEqual(['get', 'user_strokeOutdoor']) + expect(userKey).toEqual(['get', 'user_stroke']) + expect(fallback).toBe('#custom-shape') // only reached when the feature sets neither + }) +}) + +describe('updateDrawStyles', () => { + const mapStyle = { id: 'outdoor', mapColorScheme: 'light' } + + test('applies paint properties to both the cold and hot copies of each layer', () => { + const map = { getLayer: jest.fn(() => true), setPaintProperty: jest.fn() } + + updateDrawStyles(map, mapStyle) + + expect(map.setPaintProperty).toHaveBeenCalledWith('fill-inactive.cold', 'fill-color', expect.anything()) + expect(map.setPaintProperty).toHaveBeenCalledWith('fill-inactive.hot', 'fill-color', expect.anything()) + expect(map.setPaintProperty).toHaveBeenCalledWith('vertex.cold', 'circle-radius', SIZES.vertexRadius) + expect(map.setPaintProperty).toHaveBeenCalledWith('vertex.hot', 'circle-color', expect.anything()) + }) + + test('skips layers that are not present on the map', () => { + const map = { getLayer: jest.fn(() => false), setPaintProperty: jest.fn() } + + updateDrawStyles(map, mapStyle) + + expect(map.setPaintProperty).not.toHaveBeenCalled() + }) + + test('updates only the cold layer when the hot copy is absent', () => { + const map = { + getLayer: jest.fn((id) => id.endsWith('.cold')), + setPaintProperty: jest.fn() + } + + updateDrawStyles(map, mapStyle) + + const targets = map.setPaintProperty.mock.calls.map(([id]) => id) + expect(targets.length).toBeGreaterThan(0) + expect(targets.every((id) => id.endsWith('.cold'))).toBe(true) + expect(targets.some((id) => id.endsWith('.hot'))).toBe(false) + }) + + test('applies a pluginConfig override on the re-styled layers', () => { + const map = { getLayer: jest.fn(() => true), setPaintProperty: jest.fn() } + + updateDrawStyles(map, mapStyle, { editStroke: '#custom' }) + + expect(map.setPaintProperty).toHaveBeenCalledWith('stroke-active.cold', 'line-color', '#custom') + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js b/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js new file mode 100644 index 000000000..d6c55ded5 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js @@ -0,0 +1,202 @@ +/** + * Snap helper utilities for draw modes + * Provides a consistent interface for snap detection and coordinate retrieval + */ + +import { TOLERANCES } from '../../../defaults.js' + +/** + * Get the snap instance from the map + * @param {maplibregl.Map} map - Map instance + * @returns {MapboxSnap|null} Snap instance or null + */ +export function getSnapInstance (map) { + return map?._snapInstance ?? null +} + +/** + * Check if snapping is currently active + * @param {MapboxSnap} snap - Snap instance + * @returns {boolean} True if snap is active with valid coordinates + */ +export function isSnapActive (snap) { + // Also check snap.status to ensure snap feature is enabled + return !!(snap?.status && snap?.snapStatus && snap.snapCoords?.length >= 2) +} + +/** + * Get snapped coordinates as lngLat object + * @param {MapboxSnap} snap - Snap instance + * @returns {{ lng: number, lat: number }|null} Snapped coordinates or null + */ +export function getSnapLngLat (snap) { + if (!isSnapActive(snap)) { + return null + } + return { + lng: snap.snapCoords[0], + lat: snap.snapCoords[1] + } +} + +/** + * Get snapped coordinates as array [lng, lat] + * @param {MapboxSnap} snap - Snap instance + * @returns {[number, number]|null} Snapped coordinates or null + */ +export function getSnapCoords (snap) { + if (!isSnapActive(snap)) { + return null + } + return [snap.snapCoords[0], snap.snapCoords[1]] +} + +/** + * Trigger snap detection at a given point + * @param {MapboxSnap} snap - Snap instance + * @param {maplibregl.Map} map - Map instance + * @param {{ x: number, y: number }} point - Screen point + * @returns {boolean} True if snap was triggered + */ +export function triggerSnapAtPoint (snap, map, point) { + if (!snap || !map || !snap.status) { + return false + } + + const lngLat = map.unproject(point) + snap.snapToClosestPoint({ point, lngLat }) + return true +} + +/** + * Trigger snap detection at map center (for touch/keyboard modes) + * @param {MapboxSnap} snap - Snap instance + * @param {maplibregl.Map} map - Map instance + * @returns {boolean} True if snap was triggered + */ +export function triggerSnapAtCenter (snap, map) { + // Don't trigger snap if library is disabled + if (!snap || !map || !snap.status) { + return false + } + + const center = map.getCenter() + const point = map.project(center) + snap.snapToClosestPoint({ point, lngLat: center }) + return true +} + +/** + * Clear the snap indicator circle and all internal snap state + * @param {MapboxSnap} snap - Snap instance + * @param {maplibregl.Map} [map] - Optional map instance for direct layer control + */ +export function clearSnapIndicator (snap, map) { + if (snap) { + snap.snapStatus = false + snap.snapCoords = null + // Clear arrays in place (avoids creating new objects, reduces GC pressure) + if (snap.snappedFeatures?.length) { + snap.snappedFeatures.length = 0 + } + if (snap.closeFeatures?.length) { + snap.closeFeatures.length = 0 + } + if (snap.lines?.length) { + snap.lines.length = 0 + } + // Note: Avoid calling setMapData here - it's expensive in Safari + } + + // Just hide the layer - much cheaper than setData() in Safari + if (map?.getLayer('snap-helper-circle')) { + map.setLayoutProperty('snap-helper-circle', 'visibility', 'none') + } +} + +/** + * Clear all snap state (for use between drag operations) + * @param {MapboxSnap} snap - Snap instance + */ +export function clearSnapState (snap) { + if (!snap) { + return + } + snap.snapStatus = false + snap.snapCoords = null + // Clear arrays in place (avoids creating new objects, reduces GC pressure) + if (snap.snappedFeatures?.length) { + snap.snappedFeatures.length = 0 + } + if (snap.closeFeatures?.length) { + snap.closeFeatures.length = 0 + } + if (snap.lines?.length) { + snap.lines.length = 0 + } +} + +/** + * Get snap radius in pixels + * @param {MapboxSnap} snap - Snap instance + * @returns {number} Snap radius in pixels (falls back to the configured default) + */ +export function getSnapRadius (snap) { + return snap?.options?.radius ?? TOLERANCES.snapRadius +} + +/** + * Check if snapping is enabled for a given state + * @param {object} state - Mode state with optional getSnapEnabled function + * @returns {boolean} True if snapping is enabled + */ +export function isSnapEnabled (state) { + // Only return true if getSnapEnabled exists and explicitly returns true + if (typeof state?.getSnapEnabled !== 'function') { + return false + } + return state.getSnapEnabled() === true +} + +/** + * Create a synthetic map event with snapped coordinates + * @param {object} e - Original event + * @param {MapboxSnap} snap - Snap instance + * @returns {object} Event with snapped lngLat or original event + */ +export function createSnappedEvent (e, snap) { + const lngLat = getSnapLngLat(snap) + if (!lngLat) { + return e + } + + return { + ...e, + lngLat + } +} + +/** + * Create a synthetic click event at snapped coordinates + * @param {maplibregl.Map} map - Map instance + * @param {MapboxSnap} snap - Snap instance + * @returns {object|null} Synthetic event or null if no snap + */ +export function createSnappedClickEvent (map, snap) { + const lngLat = getSnapLngLat(snap) + if (!lngLat) { + return null + } + + const point = map.project([lngLat.lng, lngLat.lat]) + return { + lngLat, + point, + originalEvent: new MouseEvent('click', { + clientX: point.x, + clientY: point.y, + bubbles: true, + cancelable: true + }) + } +} diff --git a/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js b/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js new file mode 100644 index 000000000..520944ae5 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js @@ -0,0 +1,253 @@ +import { + getSnapInstance, + isSnapActive, + getSnapLngLat, + getSnapCoords, + triggerSnapAtPoint, + triggerSnapAtCenter, + clearSnapIndicator, + clearSnapState, + getSnapRadius, + isSnapEnabled, + createSnappedEvent, + createSnappedClickEvent +} from './snapHelpers.js' +import { TOLERANCES } from '../../../defaults.js' + +const activeSnap = (overrides = {}) => ({ + status: true, + snapStatus: true, + snapCoords: [1, 2], + ...overrides +}) + +describe('getSnapInstance', () => { + test('returns the snap instance when present', () => { + const snap = {} + expect(getSnapInstance({ _snapInstance: snap })).toBe(snap) + }) + + test('returns null when the map has no instance', () => { + expect(getSnapInstance({})).toBeNull() + }) + + test('returns null when the map is nullish', () => { + expect(getSnapInstance(null)).toBeNull() + }) +}) + +describe('isSnapActive', () => { + test('returns true when status, snapStatus and >=2 coords are present', () => { + expect(isSnapActive(activeSnap())).toBe(true) + }) + + test('returns false when snap is nullish', () => { + expect(isSnapActive(null)).toBe(false) + }) + + test('returns false when status is falsy', () => { + expect(isSnapActive(activeSnap({ status: false }))).toBe(false) + }) + + test('returns false when snapStatus is falsy', () => { + expect(isSnapActive(activeSnap({ snapStatus: false }))).toBe(false) + }) + + test('returns false when coords have fewer than 2 entries', () => { + expect(isSnapActive(activeSnap({ snapCoords: [1] }))).toBe(false) + }) +}) + +describe('getSnapLngLat', () => { + test('returns lng/lat object when snap is active', () => { + expect(getSnapLngLat(activeSnap())).toEqual({ lng: 1, lat: 2 }) + }) + + test('returns null when snap is inactive', () => { + expect(getSnapLngLat(null)).toBeNull() + }) +}) + +describe('getSnapCoords', () => { + test('returns [lng, lat] array when snap is active', () => { + expect(getSnapCoords(activeSnap())).toEqual([1, 2]) + }) + + test('returns null when snap is inactive', () => { + expect(getSnapCoords(null)).toBeNull() + }) +}) + +describe('triggerSnapAtPoint', () => { + test('unprojects the point and triggers snap detection', () => { + const snap = { status: true, snapToClosestPoint: jest.fn() } + const lngLat = { lng: 5, lat: 6 } + const map = { unproject: jest.fn(() => lngLat) } + const point = { x: 10, y: 20 } + + expect(triggerSnapAtPoint(snap, map, point)).toBe(true) + expect(map.unproject).toHaveBeenCalledWith(point) + expect(snap.snapToClosestPoint).toHaveBeenCalledWith({ point, lngLat }) + }) + + test('returns false when snap is missing', () => { + expect(triggerSnapAtPoint(null, {}, {})).toBe(false) + }) + + test('returns false when map is missing', () => { + expect(triggerSnapAtPoint({ status: true }, null, {})).toBe(false) + }) + + test('returns false when snap is disabled', () => { + expect(triggerSnapAtPoint({ status: false }, {}, {})).toBe(false) + }) +}) + +describe('triggerSnapAtCenter', () => { + test('projects the map centre and triggers snap detection', () => { + const snap = { status: true, snapToClosestPoint: jest.fn() } + const center = { lng: 1, lat: 2 } + const point = { x: 3, y: 4 } + const map = { getCenter: jest.fn(() => center), project: jest.fn(() => point) } + + expect(triggerSnapAtCenter(snap, map)).toBe(true) + expect(map.project).toHaveBeenCalledWith(center) + expect(snap.snapToClosestPoint).toHaveBeenCalledWith({ point, lngLat: center }) + }) + + test('returns false when snap is missing', () => { + expect(triggerSnapAtCenter(null, {})).toBe(false) + }) + + test('returns false when map is missing', () => { + expect(triggerSnapAtCenter({ status: true }, null)).toBe(false) + }) + + test('returns false when snap is disabled', () => { + expect(triggerSnapAtCenter({ status: false }, {})).toBe(false) + }) +}) + +describe('clearSnapIndicator', () => { + test('resets snap state, empties the feature arrays and hides the layer', () => { + const snap = { + snapStatus: true, + snapCoords: [1, 2], + snappedFeatures: [{}, {}], + closeFeatures: [{}], + lines: [{}] + } + const map = { + getLayer: jest.fn(() => ({})), + setLayoutProperty: jest.fn() + } + + clearSnapIndicator(snap, map) + + expect(snap.snapStatus).toBe(false) + expect(snap.snapCoords).toBeNull() + expect(snap.snappedFeatures).toHaveLength(0) + expect(snap.closeFeatures).toHaveLength(0) + expect(snap.lines).toHaveLength(0) + expect(map.setLayoutProperty).toHaveBeenCalledWith('snap-helper-circle', 'visibility', 'none') + }) + + test('handles a snap with empty/absent arrays and no map', () => { + const snap = { snapStatus: true, snapCoords: [1, 2] } + expect(() => clearSnapIndicator(snap)).not.toThrow() + expect(snap.snapStatus).toBe(false) + expect(snap.snapCoords).toBeNull() + }) + + test('does not touch the layer when it is absent', () => { + const map = { getLayer: jest.fn(() => null), setLayoutProperty: jest.fn() } + clearSnapIndicator(null, map) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) +}) + +describe('clearSnapState', () => { + test('returns early when snap is nullish', () => { + expect(() => clearSnapState(null)).not.toThrow() + }) + + test('resets snap state and empties the feature arrays', () => { + const snap = { + snapStatus: true, + snapCoords: [1, 2], + snappedFeatures: [{}, {}], + closeFeatures: [{}], + lines: [{}] + } + + clearSnapState(snap) + + expect(snap.snapStatus).toBe(false) + expect(snap.snapCoords).toBeNull() + expect(snap.snappedFeatures).toHaveLength(0) + expect(snap.closeFeatures).toHaveLength(0) + expect(snap.lines).toHaveLength(0) + }) + + test('handles a snap with empty/absent arrays', () => { + const snap = { snapStatus: true, snapCoords: [1, 2] } + expect(() => clearSnapState(snap)).not.toThrow() + expect(snap.snapCoords).toBeNull() + }) +}) + +describe('getSnapRadius', () => { + test('returns the configured radius', () => { + expect(getSnapRadius({ options: { radius: 25 } })).toBe(25) + }) + + test('falls back to the configured snap radius when unset', () => { + expect(getSnapRadius(null)).toBe(TOLERANCES.snapRadius) + }) +}) + +describe('isSnapEnabled', () => { + test('returns true when getSnapEnabled returns true', () => { + expect(isSnapEnabled({ getSnapEnabled: () => true })).toBe(true) + }) + + test('returns false when getSnapEnabled returns a non-true value', () => { + expect(isSnapEnabled({ getSnapEnabled: () => false })).toBe(false) + }) + + test('returns false when getSnapEnabled is not a function', () => { + expect(isSnapEnabled({})).toBe(false) + }) +}) + +describe('createSnappedEvent', () => { + test('merges the snapped lngLat into the event when snapping', () => { + const e = { type: 'click', lngLat: { lng: 0, lat: 0 } } + const result = createSnappedEvent(e, activeSnap()) + expect(result).toEqual({ type: 'click', lngLat: { lng: 1, lat: 2 } }) + }) + + test('returns the original event when there is no snap', () => { + const e = { type: 'click' } + expect(createSnappedEvent(e, null)).toBe(e) + }) +}) + +describe('createSnappedClickEvent', () => { + test('builds a synthetic click event at the snapped point', () => { + const point = { x: 12, y: 34 } + const map = { project: jest.fn(() => point) } + + const result = createSnappedClickEvent(map, activeSnap()) + + expect(map.project).toHaveBeenCalledWith([1, 2]) + expect(result.lngLat).toEqual({ lng: 1, lat: 2 }) + expect(result.point).toBe(point) + expect(result.originalEvent).toBeInstanceOf(MouseEvent) + expect(result.originalEvent.type).toBe('click') + }) + + test('returns null when there is no snap', () => { + expect(createSnappedClickEvent({}, null)).toBeNull() + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js b/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js new file mode 100644 index 000000000..6f8125802 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js @@ -0,0 +1,57 @@ +const MAX_TAP_DURATION_MS = 300 +const MAX_TAP_MOVEMENT_PX = 10 + +/** + * Workaround: mapbox-gl-draw calls preventDefault() on touchend even in disabled + * mode, which stops the browser synthesizing a click event. Detect quick taps + * while in disabled mode and manually dispatch a click on the canvas so app-level + * click handlers (e.g. feature selection) still fire. + * + * @param {Object} map - MapLibre map instance + * @param {Object} draw - MapboxDraw instance (used to check the current mode) + * @returns {{ remove: Function }} + */ +export const setupTouchClickWorkaround = (map, draw) => { + const canvas = map.getCanvas() + let touchStart = null + + const handleTouchStart = (e) => { + if (e.touches.length === 1) { + touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY, time: Date.now() } + } + } + + const handleTouchEnd = (e) => { + if (draw.getMode() !== 'disabled' || !touchStart) { + touchStart = null + return + } + + const touch = e.changedTouches[0] + const dx = touch.clientX - touchStart.x + const dy = touch.clientY - touchStart.y + const duration = Date.now() - touchStart.time + + // Only synthesize click for quick taps with minimal movement + if (duration < MAX_TAP_DURATION_MS && Math.abs(dx) < MAX_TAP_MOVEMENT_PX && Math.abs(dy) < MAX_TAP_MOVEMENT_PX) { + canvas.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX: touch.clientX, + clientY: touch.clientY + })) + } + + touchStart = null + } + + canvas.addEventListener('touchstart', handleTouchStart, { passive: true }) + canvas.addEventListener('touchend', handleTouchEnd, { passive: true }) + + return { + remove () { + canvas.removeEventListener('touchstart', handleTouchStart) + canvas.removeEventListener('touchend', handleTouchEnd) + } + } +} diff --git a/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js b/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js new file mode 100644 index 000000000..ff8031a88 --- /dev/null +++ b/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js @@ -0,0 +1,106 @@ +import { setupTouchClickWorkaround } from './touchClickWorkaround.js' + +const createCanvas = () => { + const listeners = {} + return { + addEventListener: jest.fn((type, handler) => { listeners[type] = handler }), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + fire (type, event) { listeners[type]?.(event) } + } +} + +const touchStartEvent = (touches) => ({ touches }) +const touchEndEvent = (changedTouches) => ({ changedTouches }) + +describe('setupTouchClickWorkaround', () => { + let canvas + let map + let draw + + beforeEach(() => { + canvas = createCanvas() + map = { getCanvas: () => canvas } + draw = { getMode: jest.fn(() => 'disabled') } + jest.spyOn(Date, 'now') + }) + + afterEach(() => { + Date.now.mockRestore() + }) + + test('registers passive touchstart/touchend listeners on the canvas', () => { + setupTouchClickWorkaround(map, draw) + expect(canvas.addEventListener).toHaveBeenCalledWith('touchstart', expect.any(Function), { passive: true }) + expect(canvas.addEventListener).toHaveBeenCalledWith('touchend', expect.any(Function), { passive: true }) + }) + + test('synthesizes a click for a quick, stationary tap in disabled mode', () => { + Date.now.mockReturnValueOnce(1000).mockReturnValueOnce(1100) + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 50, clientY: 60 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 52, clientY: 61 }])) + + expect(canvas.dispatchEvent).toHaveBeenCalledTimes(1) + const dispatched = canvas.dispatchEvent.mock.calls[0][0] + expect(dispatched).toBeInstanceOf(MouseEvent) + expect(dispatched.type).toBe('click') + expect(dispatched.clientX).toBe(52) + expect(dispatched.clientY).toBe(61) + }) + + test('does not synthesize a click when the tap is too slow', () => { + Date.now.mockReturnValueOnce(1000).mockReturnValueOnce(1400) + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 50, clientY: 60 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 50, clientY: 60 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('does not synthesize a click when the finger moves too far', () => { + Date.now.mockReturnValueOnce(1000).mockReturnValueOnce(1100) + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 50, clientY: 60 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 100, clientY: 60 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('ignores multi-touch gestures on touchstart', () => { + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 1, clientY: 1 }, { clientX: 2, clientY: 2 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 1, clientY: 1 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('does nothing when the current mode is not disabled', () => { + draw.getMode.mockReturnValue('draw_polygon') + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 50, clientY: 60 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 50, clientY: 60 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('does nothing on touchend without a preceding single-finger touchstart', () => { + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchend', touchEndEvent([{ clientX: 50, clientY: 60 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('remove() detaches both listeners', () => { + const { remove } = setupTouchClickWorkaround(map, draw) + remove() + expect(canvas.removeEventListener).toHaveBeenCalledWith('touchstart', expect.any(Function)) + expect(canvas.removeEventListener).toHaveBeenCalledWith('touchend', expect.any(Function)) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js new file mode 100644 index 000000000..c22877b04 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js @@ -0,0 +1,117 @@ +import { createOLDraw } from './olDraw.js' + +// split.js passes this literal — MapLibre's own always-present "already-drawn +// shapes" style layer id — as a snapLayers entry so the split line snaps to the +// polygon it's splitting. On MapLibre it needs no help: mapbox-gl-snap resolves +// it by querying the map's rendered style layers directly. OL's snap engine has +// no such string-based lookup for plain vector layers (only direct instances or +// vector-tile layer names), so this adapter recognises the same literal here and +// resolves it to the draw plugin's own VectorLayer instance instead. +const DRAW_OUTLINE_STYLE_LAYER = 'stroke-inactive.cold' + +/** + * Draw adapter for OpenLayers. + * + * Wraps OLDrawManager (via createOLDraw) and exposes the shared adapter interface + * consumed by events.js, DrawInit, and the api entry points. + * + * OLDrawManager already exposes on/off/emit, changeMode, done/cancel/undo/deleteVertex, + * get/add/delete/deleteAll, and setInterfaceType. This adapter adds the snap methods + * and undo-stack clearing (OL manages its own undo stack on the manager). + * + * Adapter interface (also implemented by MaplibreDrawAdapter): + * changeMode(name, options) + * getMode() + * setInterfaceType(type) + * done() / cancel() / undo() / deleteVertex() + * nudgeSelectedVertex(dx, dy, isLargeStep) + * get(id) / add(feature) / delete(id) / deleteAll() + * setSnapEnabled(bool) / setSnapLayers(layers) / isSnapEnabled() + * setFeatureProperty(id, property, value) / setDrawingPreviewProperty(property, value) + * on(event, handler) / off(event, handler) + * remove() + */ +export class OLDrawAdapter { + _snapEnabled = false + + constructor (mapProvider, options) { + const { manager, remove } = createOLDraw({ + mapProvider, + events: options.events, + eventBus: options.eventBus, + // The full pluginConfig, not just snapLayers — OLDrawManager also reads + // colour/size overrides (shapeStroke, editStroke, strokeWidth, snapRadius, + // etc.) off this object via resolveColors(). + pluginConfig: options.pluginConfig ?? { snapLayers: options.snapLayers }, + mapStyle: options.mapStyle + }) + this._cleanupOLDraw = remove + this._manager = manager + this._mapProvider = mapProvider + } + + changeMode (name, options = {}) { + // Inject OL-specific options that the unified api doesn't supply + const opts = { ...options, mapProvider: this._mapProvider } + if (name === 'draw_polygon') { opts.geometryType = 'Polygon' } + if (name === 'draw_line') { opts.geometryType = 'LineString' } + return this._manager.changeMode(name, opts) + } + + getMode () { return this._manager.getMode() } + + setInterfaceType (type) { this._manager.setInterfaceType(type) } + + done () { + this._manager.undoStack.clear() + this._manager.done() + } + + cancel () { + this._manager.undoStack.clear() + this._manager.cancel() + } + + undo () { this._manager.undo() } + deleteVertex () { this._manager.deleteVertex() } + nudgeSelectedVertex (dx, dy, isLargeStep) { this._manager.nudgeSelectedVertex(dx, dy, isLargeStep) } + + // Record the current geometry validity so the draw mode can block finish gestures + // (double-click / click-to-close) while the in-progress shape is invalid. + setGeometryValid (valid) { this._manager._geometryValid = valid } + + // The api entry points assign the active user validator to the adapter; store it + // on the manager so the draw mode can veto placements synchronously. + set _geometryValidator (fn) { this._manager._geometryValidator = fn } + get _geometryValidator () { return this._manager._geometryValidator } + + // Show/hide the dashed invalid stroke on the active sketch or edit feature. + setInvalid (invalid) { this._manager.setInvalid(invalid) } + + get (id) { return this._manager.get(id) } + add (feature) { return this._manager.add(feature) } + delete (id) { return this._manager.delete(id) } + deleteAll () { return this._manager.deleteAll() } + + setSnapEnabled (bool) { + this._snapEnabled = bool + this._manager.snap?.setActive(bool) + } + + setSnapLayers (layers) { + const translated = layers?.map((l) => (l === DRAW_OUTLINE_STYLE_LAYER ? this._manager._layer : l)) + this._manager.snap?.setSnapLayers(translated) + } + + isSnapEnabled () { return this._snapEnabled } + + setFeatureProperty () { /* not implemented for OL */ } + setDrawingPreviewProperty (property, value) { this._manager.setDrawingPreviewProperty(property, value) } + + on (type, handler) { this._manager.on(type, handler) } + off (type, handler) { this._manager.off(type, handler) } + + remove () { + this._cleanupOLDraw() + } +} diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js new file mode 100644 index 000000000..9395e2463 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js @@ -0,0 +1,159 @@ +import { OLDrawAdapter } from './OLDrawAdapter.js' +import { createOLDraw } from './olDraw.js' + +// The literal split.js passes — see OLDrawAdapter.js's DRAW_OUTLINE_STYLE_LAYER comment. +const DRAW_OUTLINE_STYLE_LAYER = 'stroke-inactive.cold' + +const fakeVectorLayer = { id: 'draw-layer' } + +const fakeManager = () => ({ + _layer: fakeVectorLayer, + changeMode: jest.fn(), + getMode: jest.fn(() => 'disabled'), + setInterfaceType: jest.fn(), + done: jest.fn(), + cancel: jest.fn(), + undo: jest.fn(), + deleteVertex: jest.fn(), + nudgeSelectedVertex: jest.fn(), + setInvalid: jest.fn(), + setDrawingPreviewProperty: jest.fn(), + get: jest.fn(() => 'feature'), + add: jest.fn(), + delete: jest.fn(), + deleteAll: jest.fn(), + on: jest.fn(), + off: jest.fn(), + undoStack: { clear: jest.fn() }, + snap: { setActive: jest.fn(), setSnapLayers: jest.fn() } +}) + +jest.mock('./olDraw.js', () => ({ + createOLDraw: jest.fn(({ mapProvider }) => ({ manager: mapProvider._testManager, remove: jest.fn() })) +})) + +const setup = () => { + const manager = fakeManager() + const mapProvider = { _testManager: manager } + const adapter = new OLDrawAdapter(mapProvider, { + events: { MAP_SET_STYLE: 's' }, + eventBus: {}, + snapLayers: ['boundaries'], + mapStyle: { id: 'default' } + }) + return { manager, mapProvider, adapter } +} + +afterEach(() => jest.clearAllMocks()) + +test('wires olDraw with the plugin options and uses the returned manager', () => { + const { manager, adapter } = setup() + expect(createOLDraw).toHaveBeenCalledWith(expect.objectContaining({ + pluginConfig: { snapLayers: ['boundaries'] }, + mapStyle: { id: 'default' } + })) + expect(adapter.getMode()).toBe('disabled') + expect(manager.getMode).toHaveBeenCalled() +}) + +test('forwards the full pluginConfig (colour/size overrides too), not just snapLayers', () => { + const manager = fakeManager() + const mapProvider = { _testManager: manager } + const pluginConfig = { snapLayers: ['boundaries'], shapeStroke: '#custom', strokeWidth: 5 } + // eslint-disable-next-line no-new + new OLDrawAdapter(mapProvider, { + events: { MAP_SET_STYLE: 's' }, + eventBus: {}, + pluginConfig, + mapStyle: { id: 'default' } + }) + expect(createOLDraw).toHaveBeenCalledWith(expect.objectContaining({ pluginConfig })) +}) + +test('changeMode injects the mapProvider and the OL geometry type per draw mode', () => { + const { manager, mapProvider, adapter } = setup() + adapter.changeMode('draw_polygon', { featureId: 'f1' }) + expect(manager.changeMode).toHaveBeenCalledWith('draw_polygon', { featureId: 'f1', mapProvider, geometryType: 'Polygon' }) + adapter.changeMode('draw_line') + expect(manager.changeMode).toHaveBeenCalledWith('draw_line', { mapProvider, geometryType: 'LineString' }) + adapter.changeMode('edit_vertex', { featureId: 'f1' }) + expect(manager.changeMode).toHaveBeenCalledWith('edit_vertex', { featureId: 'f1', mapProvider }) +}) + +test('done and cancel clear the undo stack before delegating', () => { + const { manager, adapter } = setup() + adapter.done() + adapter.cancel() + expect(manager.undoStack.clear).toHaveBeenCalledTimes(2) + expect(manager.done).toHaveBeenCalled() + expect(manager.cancel).toHaveBeenCalled() +}) + +test('snap state is tracked locally and forwarded, tolerating a missing snap manager', () => { + const { manager, adapter } = setup() + expect(adapter.isSnapEnabled()).toBe(false) + adapter.setSnapEnabled(true) + expect(adapter.isSnapEnabled()).toBe(true) + expect(manager.snap.setActive).toHaveBeenCalledWith(true) + adapter.setSnapLayers(['x']) + expect(manager.snap.setSnapLayers).toHaveBeenCalledWith(['x']) + + // The shared sentinel translates onto the draw plugin's own VectorLayer instance. + adapter.setSnapLayers([DRAW_OUTLINE_STYLE_LAYER, 'x']) + expect(manager.snap.setSnapLayers).toHaveBeenCalledWith([fakeVectorLayer, 'x']) + + manager.snap = null + adapter.setSnapEnabled(false) + adapter.setSnapLayers(['y']) // no throw + expect(adapter.isSnapEnabled()).toBe(false) +}) + +test('remaining calls delegate straight through; setFeatureProperty is a deliberate no-op', () => { + const { manager, adapter } = setup() + adapter.setInterfaceType('touch') + expect(adapter.get('f1')).toBe('feature') + adapter.add({ id: 'f2' }) + adapter.delete('f1') + adapter.deleteAll() + adapter.undo() + adapter.deleteVertex() + adapter.nudgeSelectedVertex(1, 0, true) + const handler = () => {} + adapter.on('create', handler) + adapter.off('create', handler) + expect(adapter.setFeatureProperty('f1', 'stroke', '#000')).toBeUndefined() + adapter.setDrawingPreviewProperty('splitter', 'valid') + expect(manager.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') + expect(manager.setInterfaceType).toHaveBeenCalledWith('touch') + expect(manager.on).toHaveBeenCalledWith('create', handler) + expect(manager.off).toHaveBeenCalledWith('create', handler) + expect(manager.undo).toHaveBeenCalled() + expect(manager.deleteVertex).toHaveBeenCalled() + expect(manager.nudgeSelectedVertex).toHaveBeenCalledWith(1, 0, true) +}) + +test('setGeometryValid records validity on the manager for finish gating', () => { + const { adapter, manager } = setup() + adapter.setGeometryValid(false) + expect(manager._geometryValid).toBe(false) +}) + +test('_geometryValidator accessor stores and retrieves the user callback on the manager', () => { + const { adapter, manager } = setup() + const validator = jest.fn(() => ({ valid: true })) + adapter._geometryValidator = validator + expect(manager._geometryValidator).toBe(validator) + expect(adapter._geometryValidator).toBe(validator) +}) + +test('setInvalid delegates to the manager to toggle the dashed stroke', () => { + const { adapter, manager } = setup() + adapter.setInvalid(true) + expect(manager.setInvalid).toHaveBeenCalledWith(true) +}) + +test('remove runs the olDraw cleanup', () => { + const { adapter } = setup() + adapter.remove() + expect(createOLDraw.mock.results.at(-1).value.remove).toHaveBeenCalled() +}) diff --git a/plugins/draw/src/adapters/openlayers/__helpers__/harness.js b/plugins/draw/src/adapters/openlayers/__helpers__/harness.js new file mode 100644 index 000000000..fc8781536 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/__helpers__/harness.js @@ -0,0 +1,90 @@ +import Feature from 'ol/Feature.js' +import Polygon from 'ol/geom/Polygon.js' +import LineString from 'ol/geom/LineString.js' +import Style from 'ol/style/Style.js' +import { createUndoStack } from '../../../utils/undoStack.js' + +/** + * Shared test doubles for the OL adapter. The fake map uses an identity + * mapping between map coordinates and screen pixels so hit-test assertions + * read naturally: a vertex at [10, 20] sits at pixel (10, 20). + */ + +export const createEmitter = () => { + const listeners = {} + return { + on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }), + un: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter(x => x !== h) }), + once: jest.fn((type, h) => { (listeners[`once:${type}`] ??= []).push(h) }), + emit (type, e) { + (listeners[type] ?? []).forEach(h => h(e)); + (listeners[`once:${type}`] ?? []).splice(0).forEach(h => h(e)) + } + } +} + +export const createFakeMap = ({ center = [50, 50] } = {}) => { + const emitter = createEmitter() + const viewport = document.createElement('div') + document.body.appendChild(viewport) + const view = { ...createEmitter(), getCenter: () => center, getAnimating: jest.fn(() => false) } + return { + ...emitter, + interactions: [], + layers: [], + addInteraction (i) { this.interactions.push(i) }, + removeInteraction: jest.fn(function (i) { this.interactions = this.interactions.filter(x => x !== i) }), + addLayer (l) { this.layers.push(l) }, + removeLayer: jest.fn(function (l) { this.layers = this.layers.filter(x => x !== l) }), + getViewport: () => viewport, + getPixelFromCoordinate: (c) => [c[0], c[1]], + getCoordinateFromPixel: (p) => [p[0], p[1]], + getEventPixel: (e) => [e.clientX, e.clientY], + getView: () => view, + render: jest.fn() + } +} + +// Manager double matching the OLDrawManager surface EditMode/DrawMode consume +export const createFakeManager = () => { + const bus = createEmitter() + return { + on: bus.on, + off: bus.un, + emit: jest.fn(bus.emit), + // Real Style instances — OL asserts on setStyle args; identify them by reference + styles: { + editFeatureStyle: new Style({}), + editFeatureStyleInvalid: new Style({}), + vertexStyle: new Style({}), + midpointStyle: new Style({}), + selectedVertexStyle: new Style({}), + selectedMidpointStyle: new Style({}), + createSketchStyle: jest.fn(() => () => []) + }, + colors: { editVertex: 'rgba(29,112,184,1)' }, + undoStack: createUndoStack(() => {}) + } +} + +export const polygonFeature = (ring, id = 'f1') => { + const feature = new Feature(new Polygon([ring])) + feature.setId(id) + return feature +} + +export const lineFeature = (coords, id = 'f1') => { + const feature = new Feature(new LineString(coords)) + feature.setId(id) + return feature +} + +export const createContainer = () => { + const container = document.createElement('div') + container.tabIndex = 0 + document.body.appendChild(container) + return container +} + +// jsdom has no PointerEvent/TouchEvent constructors — build plain events with the fields the handlers read +export const domEvent = (type, props) => Object.assign(new Event(type, { bubbles: true }), props) diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js new file mode 100644 index 000000000..6890be83c --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -0,0 +1,178 @@ +import VectorLayer from 'ol/layer/Vector.js' +import { createFeatureStore } from './featureStore.js' +import { createUndoStack } from '../../../utils/undoStack.js' +import { createStyles } from './styles.js' +import { resolveColors } from '../../../utils/resolveColors.js' +import { createSnapManager } from '../snap/snapManager.js' +import { createDrawMode } from '../draw/DrawMode.js' +import { createEditMode } from '../edit/EditMode.js' +import { TOLERANCES } from '../defaults.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { STYLES_CHANGED_EVENT } from './internalEvents.js' + +/** + * Mode machine for the OL draw plugin. + * + * Owns the VectorSource/Layer, undo stack, and current mode instance. + * Exposes a minimal on/off/emit event bus for internal plugin communication + * (separate from the public eventBus used for consumer-facing events). + * + * Consumer-facing events are always emitted via eventBus by events.js after + * listening to the manager's internal events. + */ +export class OLDrawManager { + constructor (map, pluginConfig = {}) { + this._map = map + this._pluginConfig = pluginConfig + this._mode = 'disabled' + this._modeInstance = null + this._listeners = new Map() + + this.store = createFeatureStore() + this.undoStack = createUndoStack((length) => this.emit(ADAPTER_EVENTS.UNDO_CHANGE, length)) + + this.colors = resolveColors(null, pluginConfig) + this.styles = createStyles(this.colors) + this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null, this.colors, pluginConfig.snapRadius ?? TOLERANCES.snapRadius) + + this._layer = new VectorLayer({ + source: this.store.source, + style: this.styles.createFeatureStyle(), + zIndex: 100 + }) + this._layer.set('layerId', 'draw') + map.addLayer(this._layer) + } + + // --- Color / style updates --- + + setMapStyle (mapStyle) { + this.colors = resolveColors(mapStyle, this._pluginConfig) + this.styles = createStyles(this.colors) + this._layer.setStyle(this.styles.createFeatureStyle()) + this.store.source.changed() + this.snap?.updateColors(this.colors) + this.emit(STYLES_CHANGED_EVENT, this.styles) + } + + // --- Internal event bus --- + + on (type, handler) { + if (!this._listeners.has(type)) { + this._listeners.set(type, new Set()) + } + this._listeners.get(type).add(handler) + } + + off (type, handler) { + this._listeners.get(type)?.delete(handler) + } + + emit (type, detail) { + const handlers = this._listeners.get(type) + // Array.from, not [...handlers] — see the comment in utils/eventBus.js: + // under a loose-mode Babel build (Docusaurus's docs site), spreading a Set + // compiles to [].concat(handlers), which doesn't flatten it — it appends + // the whole Set as one non-function element, and h(...) throws. + if (handlers) { Array.from(handlers).forEach(h => h(detail)) } + } + + // --- Mode machine --- + + async changeMode (modeName, options = {}) { + this._modeInstance?.destroy() + this._modeInstance = null + this._mode = modeName + + const isDrawMode = modeName === 'draw_polygon' || modeName === 'draw_line' || modeName === 'edit_vertex' + this.snap?.setIndicatorActive(isDrawMode) + + const modeOptions = { ...options, snap: this.snap } + + if (modeName === 'draw_polygon' || modeName === 'draw_line') { + this._modeInstance = createDrawMode({ map: this._map, manager: this, options: modeOptions }) + } else if (modeName === 'edit_vertex') { + this._modeInstance = createEditMode({ map: this._map, manager: this, options: modeOptions }) + } else { + // disabled — no mode instance needed + } + // Reattach snap interaction after mode's interactions are added so it + // processes pointermove first (OL: last-added interaction = first to handle events). + this.snap?.reattach() + } + + getMode () { + return this._mode + } + + // --- High-level operations called by events.js --- + + done () { + this._modeInstance?.done() + } + + cancel () { + this._modeInstance?.cancel() + this.changeMode('disabled') + } + + undo () { + this._modeInstance?.undo() + } + + deleteVertex () { + this._modeInstance?.deleteVertex() + } + + nudgeSelectedVertex (dx, dy, isLargeStep) { + this._modeInstance?.nudgeSelectedVertex?.(dx, dy, isLargeStep) + } + + // Show/hide the dashed invalid stroke on the active draw sketch or edit feature. + setInvalid (invalid) { + this._modeInstance?.setInvalid?.(invalid) + } + + // Tag the active draw sketch with a custom style property (e.g. split's + // valid/invalid line colour). No-op in edit mode or without a mode instance. + setDrawingPreviewProperty (property, value) { + this._modeInstance?.setDrawingPreviewProperty?.(property, value) + } + + setInterfaceType (type) { + this._modeInstance?.setInterfaceType?.(type) + // Parity with the ML adapter: an explicit interface-type write is echoed on + // the bus so events.js can relay it as draw:interfacetypechange. + this.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: type }) + } + + // --- Feature store delegation --- + + get (id) { + return this.store.get(id) + } + + add (geojsonFeature) { + return this.store.add(geojsonFeature) + } + + delete (id) { + return this.store.remove(id) + } + + deleteAll () { + return this.store.clear() + } + + // --- Cleanup --- + + remove () { + this._modeInstance?.destroy() + this._modeInstance = null + this.snap?.destroy() + this.snap = null + this.store.clear() + this._map.removeLayer(this._layer) + this._listeners.clear() + } +} diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js new file mode 100644 index 000000000..84546ee10 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js @@ -0,0 +1,153 @@ +import { OLDrawManager } from './OLDrawManager.js' +import { STYLES_CHANGED_EVENT } from './internalEvents.js' +import { createDrawMode } from '../draw/DrawMode.js' +import { createEditMode } from '../edit/EditMode.js' +import { createSnapManager } from '../snap/snapManager.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { createFakeMap } from '../__helpers__/harness.js' +import { TOLERANCES } from '../defaults.js' + +jest.mock('../draw/DrawMode.js', () => ({ + createDrawMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), nudgeSelectedVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn(), setDrawingPreviewProperty: jest.fn() })) +})) +jest.mock('../edit/EditMode.js', () => ({ + createEditMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), nudgeSelectedVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn() })) +})) +jest.mock('../snap/snapManager.js', () => ({ + createSnapManager: jest.fn(() => ({ setIndicatorActive: jest.fn(), reattach: jest.fn(), updateColors: jest.fn(), destroy: jest.fn() })) +})) + +const geojson = { type: 'Feature', id: 'f1', properties: {}, geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 0]]] } } + +const setup = (pluginConfig) => { + const map = createFakeMap() + const manager = new OLDrawManager(map, pluginConfig) + return { map, manager } +} + +afterEach(() => jest.clearAllMocks()) + +test('sets up the draw layer and the snap manager from the plugin config', () => { + const { map } = setup({ snapLayers: ['boundaries'], snapRadius: 20 }) + expect(map.layers[0].get('layerId')).toBe('draw') + expect(createSnapManager).toHaveBeenCalledWith(map, ['boundaries'], expect.any(Object), 20) + + setup() // defaults + expect(createSnapManager).toHaveBeenLastCalledWith(expect.anything(), null, expect.any(Object), TOLERANCES.snapRadius) +}) + +test('map style changes rebuild the styles and notify modes and snap', () => { + const { map, manager } = setup() + const onStyles = jest.fn() + manager.on(STYLES_CHANGED_EVENT, onStyles) + const before = manager.styles + manager.setMapStyle({ id: 'dark', mapColorScheme: 'dark' }) + expect(manager.styles).not.toBe(before) + expect(map.layers[0].getStyle()).toEqual(expect.any(Function)) + expect(manager.snap.updateColors).toHaveBeenCalledWith(manager.colors) + expect(onStyles).toHaveBeenCalledWith(manager.styles) +}) + +describe('mode machine', () => { + test.each([ + ['draw_polygon', createDrawMode], + ['draw_line', createDrawMode], + ['edit_vertex', createEditMode] + ])('%s creates its mode with snap injected, activates the indicator and reattaches snap last', async (name, factory) => { + const { map, manager } = setup() + await manager.changeMode(name, { featureId: 'f1' }) + expect(manager.getMode()).toBe(name) + expect(factory).toHaveBeenCalledWith({ map, manager, options: { featureId: 'f1', snap: manager.snap } }) + expect(manager.snap.setIndicatorActive).toHaveBeenCalledWith(true) + expect(manager.snap.reattach).toHaveBeenCalled() + }) + + test('disabled destroys the previous mode and deactivates the indicator', async () => { + const { manager } = setup() + await manager.changeMode('draw_polygon') + const instance = createDrawMode.mock.results[0].value + await manager.changeMode('disabled') + expect(instance.destroy).toHaveBeenCalled() + expect(manager.getMode()).toBe('disabled') + expect(manager.snap.setIndicatorActive).toHaveBeenLastCalledWith(false) + }) + + test('operations delegate to the current mode instance and are safe without one', async () => { + const { manager } = setup() + manager.done(); manager.undo(); manager.deleteVertex(); manager.nudgeSelectedVertex(1, 0, true); manager.setInterfaceType('touch'); manager.setInvalid(true) // no mode — no throw + manager.setDrawingPreviewProperty('splitter', 'valid') // no mode — no throw + + await manager.changeMode('draw_polygon') + const instance = createDrawMode.mock.results[0].value + manager.done() + manager.undo() + manager.deleteVertex() + manager.nudgeSelectedVertex(1, 0, true) + const emitted = jest.fn() + manager.on('interfacetypechange', emitted) + manager.setInterfaceType('touch') + manager.setInvalid(true) + manager.setDrawingPreviewProperty('splitter', 'valid') + expect(instance.done).toHaveBeenCalled() + expect(instance.undo).toHaveBeenCalled() + expect(instance.deleteVertex).toHaveBeenCalled() + expect(instance.nudgeSelectedVertex).toHaveBeenCalledWith(1, 0, true) + expect(instance.setInterfaceType).toHaveBeenCalledWith('touch') + // Parity with ML: an explicit interface-type write is echoed on the bus. + expect(emitted).toHaveBeenCalledWith({ interfaceType: 'touch' }) + expect(instance.setInvalid).toHaveBeenCalledWith(true) + expect(instance.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') + + manager.cancel() + expect(instance.cancel).toHaveBeenCalled() + expect(manager.getMode()).toBe('disabled') + }) +}) + +test('undo stack changes are published on the adapter bus; off unsubscribes', () => { + const { manager } = setup() + const onUndoChange = jest.fn() + manager.on(ADAPTER_EVENTS.UNDO_CHANGE, onUndoChange) + manager.undoStack.push({ type: 'draw_vertex' }) + expect(onUndoChange).toHaveBeenCalledWith(1) + manager.off(ADAPTER_EVENTS.UNDO_CHANGE, onUndoChange) + manager.undoStack.push({ type: 'draw_vertex' }) + expect(onUndoChange).toHaveBeenCalledTimes(1) +}) + +test('several handlers can subscribe to the same event type and all fire', () => { + const { manager } = setup() + const h1 = jest.fn() + const h2 = jest.fn() + manager.on(ADAPTER_EVENTS.UNDO_CHANGE, h1) + manager.on(ADAPTER_EVENTS.UNDO_CHANGE, h2) // same type reuses the existing handler set + manager.undoStack.push({ type: 'draw_vertex' }) + expect(h1).toHaveBeenCalledWith(1) + expect(h2).toHaveBeenCalledWith(1) +}) + +test('feature store delegation works with GeoJSON in and out', () => { + const { manager } = setup() + manager.add(geojson) + expect(manager.get('f1')).toMatchObject({ id: 'f1' }) + manager.delete('f1') + expect(manager.get('f1')).toBeNull() + manager.add(geojson) + manager.deleteAll() + expect(manager.get('f1')).toBeNull() +}) + +test('remove tears everything down and silences the bus', async () => { + const { map, manager } = setup() + await manager.changeMode('draw_polygon') + const instance = createDrawMode.mock.results[0].value + const snap = manager.snap + const listener = jest.fn() + manager.on(STYLES_CHANGED_EVENT, listener) + manager.remove() + expect(instance.destroy).toHaveBeenCalled() + expect(snap.destroy).toHaveBeenCalled() + expect(map.removeLayer).toHaveBeenCalled() + manager.emit(STYLES_CHANGED_EVENT, {}) + expect(listener).not.toHaveBeenCalled() +}) diff --git a/plugins/draw/src/adapters/openlayers/core/featureStore.js b/plugins/draw/src/adapters/openlayers/core/featureStore.js new file mode 100644 index 000000000..6c49195e7 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/core/featureStore.js @@ -0,0 +1,69 @@ +import VectorSource from 'ol/source/Vector.js' +import GeoJSON from 'ol/format/GeoJSON.js' + +const PROJECTION = 'EPSG:27700' + +// No coordinate transformation: data and map both use BNG. +const format = new GeoJSON({ dataProjection: PROJECTION, featureProjection: PROJECTION }) + +/** + * Wraps an OL VectorSource with a GeoJSON-oriented API keyed by feature ID. + * All GeoJSON in and out uses EPSG:27700 coordinates (BNG easting/northing). + */ +export const createFeatureStore = () => { + const source = new VectorSource() + + return { + /** The underlying VectorSource, passed to OL interactions and layers. */ + source, + + /** Get the raw OL Feature by ID, or null. */ + getOL (id) { + return source.getFeatureById(String(id)) ?? null + }, + + /** Add or replace a GeoJSON feature. Returns the OL Feature. */ + add (geojsonFeature) { + const existing = this.getOL(geojsonFeature.id) + if (existing) { + source.removeFeature(existing) + } + const olFeature = format.readFeature(geojsonFeature) + source.addFeature(olFeature) + return olFeature + }, + + /** Get a GeoJSON feature by ID, or null. */ + get (id) { + const feature = this.getOL(id) + return feature ? format.writeFeatureObject(feature) : null + }, + + /** Remove one feature, or several, by ID. Matches mapbox-gl-draw's delete(ids), + * which accepts a single id or an array — deleteFeature.js passes either. */ + remove (id) { + const ids = Array.isArray(id) ? id : [id] + for (const featureId of ids) { + const feature = this.getOL(featureId) + if (feature) { + source.removeFeature(feature) + } + } + }, + + /** Remove all features. */ + clear () { + source.clear() + }, + + /** Convert an OL Feature to a GeoJSON object. */ + toGeoJSON (olFeature) { + return format.writeFeatureObject(olFeature) + }, + + /** Convert a GeoJSON object to an OL Feature (no side effects). */ + fromGeoJSON (geojsonFeature) { + return format.readFeature(geojsonFeature) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/core/featureStore.test.js b/plugins/draw/src/adapters/openlayers/core/featureStore.test.js new file mode 100644 index 000000000..33a4e7e9c --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/core/featureStore.test.js @@ -0,0 +1,58 @@ +import { createFeatureStore } from './featureStore.js' + +const geojson = (id, coords = [[[0, 0], [10, 0], [10, 10], [0, 0]]]) => ({ + type: 'Feature', + id, + properties: { label: 'field' }, + geometry: { type: 'Polygon', coordinates: coords } +}) + +test('add returns the OL feature, retrievable by id in both OL and GeoJSON form', () => { + const store = createFeatureStore() + const olFeature = store.add(geojson('f1')) + expect(store.getOL('f1')).toBe(olFeature) + expect(store.get('f1')).toMatchObject({ id: 'f1', properties: { label: 'field' } }) + expect(store.get('f1').geometry.coordinates[0]).toHaveLength(4) +}) + +test('adding the same id replaces the existing feature instead of duplicating', () => { + const store = createFeatureStore() + store.add(geojson('f1')) + store.add(geojson('f1', [[[0, 0], [99, 0], [99, 99], [0, 0]]])) + expect(store.source.getFeatures()).toHaveLength(1) + expect(store.get('f1').geometry.coordinates[0][1]).toEqual([99, 0]) +}) + +test('unknown ids come back null', () => { + const store = createFeatureStore() + expect(store.getOL('missing')).toBeNull() + expect(store.get('missing')).toBeNull() +}) + +test('remove deletes by id and tolerates unknown ids; clear empties the source', () => { + const store = createFeatureStore() + store.add(geojson('f1')) + store.add(geojson('f2')) + store.remove('f1') + store.remove('missing') // no throw + expect(store.source.getFeatures()).toHaveLength(1) + store.clear() + expect(store.source.getFeatures()).toHaveLength(0) +}) + +test('remove accepts an array of ids and deletes all of them', () => { + const store = createFeatureStore() + store.add(geojson('f1')) + store.add(geojson('f2')) + store.add(geojson('f3')) + store.remove(['f1', 'f2', 'missing']) + expect(store.source.getFeatures()).toHaveLength(1) + expect(store.get('f3')).not.toBeNull() +}) + +test('toGeoJSON / fromGeoJSON round-trip without touching the source', () => { + const store = createFeatureStore() + const olFeature = store.fromGeoJSON(geojson('f9')) + expect(store.source.getFeatures()).toHaveLength(0) + expect(store.toGeoJSON(olFeature)).toMatchObject({ id: 'f9', geometry: { type: 'Polygon' } }) +}) diff --git a/plugins/draw/src/adapters/openlayers/core/internalEvents.js b/plugins/draw/src/adapters/openlayers/core/internalEvents.js new file mode 100644 index 000000000..739f9dc58 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/core/internalEvents.js @@ -0,0 +1,8 @@ +/** + * Manager-internal event names (these never cross the adapter boundary — + * the shared contract lives in src/adapterEvents.js). + */ + +// Fired by OLDrawManager when the resolved colour set / style instances are +// rebuilt after a map style change; modes re-style their layers in response. +export const STYLES_CHANGED_EVENT = 'styleschanged' diff --git a/plugins/draw/src/adapters/openlayers/core/styles.js b/plugins/draw/src/adapters/openlayers/core/styles.js new file mode 100644 index 000000000..715d771c2 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/core/styles.js @@ -0,0 +1,155 @@ +import Style from 'ol/style/Style.js' +import Fill from 'ol/style/Fill.js' +import Stroke from 'ol/style/Stroke.js' +import CircleStyle from 'ol/style/Circle.js' +import MultiPoint from 'ol/geom/MultiPoint.js' +import { SIZES } from '../defaults.js' +import { getPlacedSketchCoords } from '../utils/sketchHelpers.js' + +const HALO_RADIUS_OFFSET = 3 + +const selectedVertexRadii = { outer: SIZES.vertexHaloRadius + HALO_RADIUS_OFFSET, mid: SIZES.vertexHaloRadius, inner: SIZES.vertexRadius } +const selectedMidpointRadii = { outer: SIZES.midpointHaloRadius + HALO_RADIUS_OFFSET, mid: SIZES.midpointHaloRadius, inner: SIZES.midpointRadius } + +const fillArc = (ctx, cx, cy, radius, fillStyle) => { + ctx.beginPath() + ctx.arc(cx, cy, radius, 0, Math.PI * 2) + ctx.fillStyle = fillStyle + ctx.fill() +} + +// Custom renderer draws all arcs at the same (cx,cy) so concentric rings never +// drift at fractional CSS scales (e.g. 1.5×) the way separate drawImage calls can. +const makeRingRenderer = ({ outer, mid, inner }, colors, innerKey) => (pixelCoordinates, state) => { + const ctx = state.context + const pr = state.pixelRatio + const [cx, cy] = /** @type {number[]} */ (pixelCoordinates) + ctx.save() + fillArc(ctx, cx, cy, outer * pr, colors.editActive) + fillArc(ctx, cx, cy, mid * pr, colors.editHalo) + fillArc(ctx, cx, cy, inner * pr, colors[innerKey]) + ctx.restore() +} + +const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) + +// Shared by edit-mode vertices and in-progress sketch vertices so they always look the same +const createVertexStyles = (colors) => { + const vertexImage = new CircleStyle({ + radius: SIZES.vertexRadius, + fill: new Fill({ color: colors.editVertex }) + }) + return { + vertexImage, + vertexStyle: new Style({ image: vertexImage }), + selectedVertexStyle: new Style({ renderer: makeRingRenderer(selectedVertexRadii, colors, 'editVertex') }) + } +} + +const createMidpointStyles = (colors) => ({ + midpointStyle: new Style({ + image: new CircleStyle({ + radius: SIZES.midpointRadius, + fill: new Fill({ color: colors.editMidpoint }) + }) + }), + selectedMidpointStyle: new Style({ renderer: makeRingRenderer(selectedMidpointRadii, colors, 'editMidpoint') }) +}) + +// Split-line preview colours: valid is solid, invalid is dashed — matching +// ML's stroke-valid-splitter / stroke-invalid-splitter layers. +const createSketchLineStyles = (colors) => ({ + valid: new Style({ + stroke: new Stroke({ color: colors.editStroke, width: 2 }), + fill: new Fill({ color: colors.editFill }) + }), + invalid: new Style({ + stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }) + }), + splitValid: new Style({ + stroke: new Stroke({ color: colors.splitValid, width: 2 }) + }), + splitInvalid: new Style({ + stroke: new Stroke({ color: colors.splitInvalid, width: 2, lineDash: [2, 4] }) + }) +}) + +/** + * Create all draw-ol style instances for the given resolved color set. + * + * @param {object} colors - Output of resolveColors() + * @returns {{ vertexStyle, selectedVertexStyle, midpointStyle, selectedMidpointStyle, + * editFeatureStyle, createSketchStyle, createFeatureStyle }} + */ +export const createStyles = (colors) => { + const { vertexImage, vertexStyle, selectedVertexStyle } = createVertexStyles(colors) + const { midpointStyle, selectedMidpointStyle } = createMidpointStyles(colors) + + const editFeatureStyle = new Style({ + stroke: new Stroke({ color: colors.editStroke, width: 2 }), + fill: new Fill({ color: colors.editFill }) + }) + + // Dashed variant shown while the edited/drawn shape is invalid — no fill, so an + // invalid shape reads as an outline only. + const editFeatureStyleInvalid = new Style({ + stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }) + }) + + const sketchLineStyles = createSketchLineStyles(colors) + + // Reused across renders — the geometry function runs every frame while sketching, + // so mutate one MultiPoint (setCoordinates bumps its revision, keeping OL's + // render caches correct) instead of allocating a new one per frame + const sketchVertices = new MultiPoint([]) + const sketchVertexStyle = new Style({ + image: vertexImage, + geometry: (feature) => { + const coords = getPlacedSketchCoords(feature.getGeometry()) + if (!coords.length) { + return null + } + sketchVertices.setCoordinates(coords) + return sketchVertices + } + }) + + // No style for the Point sketch (cursor-following ghost marker); placed + // vertices get markers on the sketch feature instead. geometryType filters + // out the extra LineString sketch OL renders alongside a Polygon sketch, + // so vertices aren't drawn twice. `invalid` swaps to the dashed line style. + // A 'splitter' property on the feature (set via setDrawingPreviewProperty) + // overrides both with the split-specific valid/invalid colours. + const createSketchStyle = (geometryType, invalid = false) => (feature) => { + const type = feature.getGeometry().getType() + if (type === 'Point') { return [] } + const splitter = feature.get('splitter') + let lineStyle = invalid ? sketchLineStyles.invalid : sketchLineStyles.valid + if (splitter === 'valid') { lineStyle = sketchLineStyles.splitValid } + if (splitter === 'invalid') { lineStyle = sketchLineStyles.splitInvalid } + return type === geometryType ? [lineStyle, sketchVertexStyle] : [lineStyle] + } + + const createFeatureStyle = () => (feature) => { + const p = feature.getProperties() + const id = colors.mapStyleId + const stroke = (id && p[`stroke${capitalize(id)}`]) || p.stroke || colors.shapeStroke + const fill = (id && p[`fill${capitalize(id)}`]) || p.fill || colors.shapeFill + const strokeWidth = p.strokeWidth || colors.strokeWidth + return [new Style({ + stroke: new Stroke({ color: stroke, width: strokeWidth }), + fill: new Fill({ color: fill }) + })] + } + + return { + vertexStyle, + selectedVertexStyle, + midpointStyle, + selectedMidpointStyle, + editFeatureStyle, + editFeatureStyleInvalid, + createSketchStyle, + createFeatureStyle + } +} diff --git a/plugins/draw/src/adapters/openlayers/core/styles.test.js b/plugins/draw/src/adapters/openlayers/core/styles.test.js new file mode 100644 index 000000000..8438a23c9 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/core/styles.test.js @@ -0,0 +1,143 @@ +import Point from 'ol/geom/Point.js' +import LineString from 'ol/geom/LineString.js' +import Feature from 'ol/Feature.js' +import { createStyles } from './styles.js' +import { SIZES } from '../defaults.js' +import { polygonFeature, lineFeature } from '../__helpers__/harness.js' + +const colors = { + editStroke: '#e5', + editFill: '#ef', + editVertex: '#ev', + editMidpoint: '#em', + editActive: '#ea', + editHalo: '#eh', + invalidStroke: '#is', + splitValid: '#sv', + splitInvalid: '#si', + shapeStroke: '#ss', + shapeFill: '#sf', + strokeWidth: 3, + mapStyleId: 'road' +} + +const styles = createStyles(colors) + +// Records each canvas fill as { radius, fillStyle } so ring order/colours can be asserted +const fakeCanvas = () => { + const fills = [] + const ctx = { + fillStyle: null, + save: jest.fn(), + restore: jest.fn(), + beginPath: jest.fn(), + arc: jest.fn((cx, cy, radius) => { ctx.lastArc = { cx, cy, radius } }), + fill: jest.fn(() => fills.push({ ...ctx.lastArc, fillStyle: ctx.fillStyle })) + } + return { ctx, fills } +} + +describe('handle styles', () => { + test('vertex and midpoint handles use the configured radii and colours', () => { + expect(styles.vertexStyle.getImage().getRadius()).toBe(SIZES.vertexRadius) + expect(styles.vertexStyle.getImage().getFill().getColor()).toBe(colors.editVertex) + expect(styles.midpointStyle.getImage().getRadius()).toBe(SIZES.midpointRadius) + expect(styles.midpointStyle.getImage().getFill().getColor()).toBe(colors.editMidpoint) + }) + + test('selected handles render three concentric rings in one canvas pass, scaled by pixelRatio', () => { + const { ctx, fills } = fakeCanvas() + styles.selectedVertexStyle.getRenderer()([10, 20], { context: ctx, pixelRatio: 2 }) + expect(fills).toEqual([ + { cx: 10, cy: 20, radius: (SIZES.vertexHaloRadius + 3) * 2, fillStyle: colors.editActive }, + { cx: 10, cy: 20, radius: SIZES.vertexHaloRadius * 2, fillStyle: colors.editHalo }, + { cx: 10, cy: 20, radius: SIZES.vertexRadius * 2, fillStyle: colors.editVertex } + ]) + + const midpoint = fakeCanvas() + styles.selectedMidpointStyle.getRenderer()([0, 0], { context: midpoint.ctx, pixelRatio: 1 }) + expect(midpoint.fills.map(f => f.radius)).toEqual( + [SIZES.midpointHaloRadius + 3, SIZES.midpointHaloRadius, SIZES.midpointRadius]) + expect(midpoint.fills[2].fillStyle).toBe(colors.editMidpoint) + }) + + test('the edited feature gets the edit stroke and fill', () => { + expect(styles.editFeatureStyle.getStroke().getColor()).toBe(colors.editStroke) + expect(styles.editFeatureStyle.getFill().getColor()).toBe(colors.editFill) + }) + + test('the invalid edited feature gets a dashed stroke and no fill', () => { + expect(styles.editFeatureStyleInvalid.getStroke().getColor()).toBe(colors.invalidStroke) + expect(styles.editFeatureStyleInvalid.getStroke().getLineDash()).toEqual([2, 4]) + expect(styles.editFeatureStyleInvalid.getFill()).toBeNull() + }) +}) + +describe('sketch styles while drawing', () => { + const polygonStyleFn = styles.createSketchStyle('Polygon') + + test('the cursor-follow point renders nothing; the companion line gets stroke only', () => { + expect(polygonStyleFn(new Feature(new Point([0, 0])))).toEqual([]) + expect(polygonStyleFn(new Feature(new LineString([[0, 0], [1, 1]])))).toHaveLength(1) + }) + + test('the invalid sketch renders a dashed line in the invalid colour with no fill', () => { + const invalidStyleFn = styles.createSketchStyle('Polygon', true) + const [lineStyle] = invalidStyleFn(new Feature(new LineString([[0, 0], [1, 1]]))) + expect(lineStyle.getStroke().getColor()).toBe(colors.invalidStroke) + expect(lineStyle.getStroke().getLineDash()).toEqual([2, 4]) + expect(lineStyle.getFill()).toBeNull() + }) + + test('a splitter-tagged sketch renders the split colours (valid solid, invalid dashed), overriding invalid', () => { + const valid = lineFeature([[0, 0], [1, 1]]) + valid.set('splitter', 'valid') + const [validStyle] = styles.createSketchStyle('LineString')(valid) + expect(validStyle.getStroke().getColor()).toBe(colors.splitValid) + expect(validStyle.getStroke().getLineDash()).toBeNull() + + const invalid = lineFeature([[0, 0], [1, 1]]) + invalid.set('splitter', 'invalid') + const [invalidStyle] = styles.createSketchStyle('LineString', true)(invalid) + expect(invalidStyle.getStroke().getColor()).toBe(colors.splitInvalid) + expect(invalidStyle.getStroke().getLineDash()).toEqual([2, 4]) + }) + + test('placed vertices render with the shared vertex image on a reused MultiPoint', () => { + const sketch = polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) // 2 placed + rubber + closing + const [, vertexStyle] = polygonStyleFn(sketch) + expect(vertexStyle.getImage()).toBe(styles.vertexStyle.getImage()) + const geometry = vertexStyle.getGeometry()(sketch) + expect(geometry.getCoordinates()).toEqual([[0, 0], [10, 0]]) + expect(vertexStyle.getGeometry()(sketch)).toBe(geometry) // instance reused per render + + const empty = lineFeature([[9, 9]]) // rubber band only — nothing placed + expect(styles.createSketchStyle('LineString')(empty)[1].getGeometry()(empty)).toBeNull() + }) +}) + +describe('createFeatureStyle (inactive shapes)', () => { + const styleFor = (properties) => styles.createFeatureStyle()({ getProperties: () => properties })[0] + + test('falls back to the configured shape colours', () => { + const style = styleFor({}) + expect(style.getStroke().getColor()).toBe(colors.shapeStroke) + expect(style.getStroke().getWidth()).toBe(colors.strokeWidth) + expect(style.getFill().getColor()).toBe(colors.shapeFill) + }) + + test('per-feature properties override, and map-style-specific properties win overall', () => { + const style = styleFor({ stroke: '#f1', fill: '#f2', strokeWidth: 7, strokeRoad: '#r1', fillRoad: '#r2' }) + expect(style.getStroke().getColor()).toBe('#r1') + expect(style.getFill().getColor()).toBe('#r2') + expect(style.getStroke().getWidth()).toBe(7) + + expect(styleFor({ stroke: '#f1' }).getStroke().getColor()).toBe('#f1') + }) + + test('without a map style id only the generic properties apply', () => { + const plain = createStyles({ ...colors, mapStyleId: null }) + const style = plain.createFeatureStyle()({ getProperties: () => ({ strokeRoad: '#r1' }) })[0] + expect(style.getStroke().getColor()).toBe(colors.shapeStroke) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/defaults.js b/plugins/draw/src/adapters/openlayers/defaults.js new file mode 100644 index 000000000..c92e6ee24 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/defaults.js @@ -0,0 +1 @@ +export { COLORS, SIZES, TOLERANCES, KEYBOARD, MAP_SIZE_SCALES } from '../../defaults.js' diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js new file mode 100644 index 000000000..4067e4038 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -0,0 +1,272 @@ +import Draw from 'ol/interaction/Draw.js' +import { noModifierKeys } from 'ol/events/condition.js' +import { createDrawInput } from './drawInput.js' +import { getPlacedSketchCoords, getLastPlacedSketchCoord } from '../utils/sketchHelpers.js' +import { TOLERANCES } from '../defaults.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' +import { attemptPlacement, MODE_BY_GEOMETRY } from '../../../validation/validateGeometry.js' +import { MIN_VERTICES } from '../../../validation/rules.js' +import { createLiveStroke } from '../../../validation/liveStroke.js' +import { createLiveDrawChecks } from '../../../validation/liveDrawChecks.js' + +const canFinish = (geometryType, sketchFeature) => { + if (!sketchFeature) { return false } + return getPlacedSketchCoords(sketchFeature.getGeometry()).length >= MIN_VERTICES[geometryType] +} + +const DUPLICATE_TOLERANCE_PX = 2 + +// Blocks clicks with modifier keys, vetoed placements (the vertex never appears — +// see canPlaceVertex in createDrawMode), and duplicate clicks on the last placed +// vertex while the shape is not yet finishable (once finishable, clicking the last +// vertex is OL's finish gesture). +export const buildCondition = (map, geometryType, getSketchFeature, canPlaceVertex) => (e) => { + if (!noModifierKeys(e)) { return false } + const sf = getSketchFeature() + if (!canPlaceVertex(e.coordinate)) { return false } + if (!sf || canFinish(geometryType, sf)) { return true } + const prev = getLastPlacedSketchCoord(sf.getGeometry()) + if (!prev) { return true } + const pp = map.getPixelFromCoordinate(prev) + if (!pp) { return true } + const dx = e.pixel[0] - pp[0]; const dy = e.pixel[1] - pp[1] + return dx * dx + dy * dy > DUPLICATE_TOLERANCE_PX * DUPLICATE_TOLERANCE_PX +} + +// During drawing the sketch has trailing rubber-band (+ closing, for polygons) coords. +const TRAILING_COORDS = { Polygon: 2, LineString: 1 } + +// The placed-only GeoJSON of an in-progress sketch (lon/lat), dropping the trailing +// cursor-tracking coords so validation tests just the committed vertices. +const placedFeatureGeoJSON = (store, sketchFeature) => { + const gj = store.toGeoJSON(sketchFeature) + const type = gj.geometry.type + const trailing = TRAILING_COORDS[type] + const ring = type === 'Polygon' ? gj.geometry.coordinates[0] : gj.geometry.coordinates + const placed = ring.slice(0, -trailing) + const geometry = type === 'Polygon' + ? { type: 'Polygon', coordinates: [placed] } + : { type: 'LineString', coordinates: placed } + return { type: 'Feature', geometry, properties: gj.properties } +} + +// Attempt a placement (mouse click, crosshair tap, Enter) through the shared +// hard rules + user callback. On a veto the vertex never appears and a +// PLACEMENT_BLOCKED event carries the reason. +export const buildCanPlaceVertex = ({ manager, geometryType, getSketch }) => (coordinate) => { + const sketch = getSketch() + const result = attemptPlacement({ + placed: sketch ? getPlacedSketchCoords(sketch.getGeometry()) : [], + point: coordinate, + geometryType, + onGeometryChange: manager._geometryValidator + }) + if (!result.valid) { + manager.emit(ADAPTER_EVENTS.PLACEMENT_BLOCKED, result.blocked) + } + return result.valid +} + +// Build the displayed-geometry payload (placed vertices + cursor) for the live +// stroke check from the current sketch, plus the placed-vertex count for the +// minimum-vertices threshold. +const displayedSketch = (geometryType, sketch) => { + const geom = sketch.getGeometry() + const coords = geom.getCoordinates() + const ring = geometryType === 'Polygon' ? (coords[0] ?? []) : coords + const geometry = geometryType === 'Polygon' + ? { type: 'Polygon', coordinates: [ring] } + : { type: 'LineString', coordinates: ring } + return { feature: { type: 'Feature', geometry }, numVertices: getPlacedSketchCoords(geom).length } +} + +// Commit a finished sketch to the store under the requested id and emit CREATE. +const finalizeDrawnFeature = (manager, olFeature, featureId, properties) => { + olFeature.setId(String(featureId)) + olFeature.setProperties(properties) + manager.store.source.addFeature(olFeature) + manager.emit(ADAPTER_EVENTS.CREATE, manager.store.toGeoJSON(olFeature)) +} + +// Wire the OL Draw interaction's lifecycle: track the sketch, react to every sketch +// geometry change (vertex count + live validity), finalise on end, and report a +// cancel on abort. +const attachDrawListeners = (drawInteraction, { manager, featureId, properties, onStart, onSketchChange }) => { + drawInteraction.on('drawstart', (e) => { + onStart(e.feature) + e.feature.getGeometry().on('change', onSketchChange) + }) + drawInteraction.on('drawend', (e) => finalizeDrawnFeature(manager, e.feature, featureId, properties)) + drawInteraction.on('drawabort', () => { manager.emit(ADAPTER_EVENTS.CANCEL) }) +} + +// Tracks placed-vertex count and emits VERTEX_CHANGE plus, on each genuine placement, +// a commit-level GEOMETRY_CHANGE ('commit-add') for validation. Deferred a tick so a +// rejection's revert runs after the current click settles. +const createVertexTracker = (manager, getSketch) => { + let lastPlacedCount = 0 + const emit = (phase, vertexIndex) => { + setTimeout(() => { + const sketch = getSketch() + if (!sketch) { return } + manager.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: placedFeatureGeoJSON(manager.store, sketch), phase, vertexIndex }) + }, 0) + } + return { + resetCount: () => { lastPlacedCount = 0 }, + updateVertexCount: () => { + const sketch = getSketch() + if (!sketch) { return } + const placed = getPlacedSketchCoords(sketch.getGeometry()).length + manager.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: placed }) + if (placed > lastPlacedCount) { emit('commit-add', placed - 1) } + lastPlacedCount = placed + }, + // An undo commits a vertex removal, so it must re-validate like any other + // commit — otherwise the Done gate goes stale. + emitUndoValidation: () => { + const sketch = getSketch() + if (!sketch) { return } + emit('commit-delete', getPlacedSketchCoords(sketch.getGeometry()).length) + } + } +} + +// The mode interface consumed by OLDrawManager. +const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, getSketch, updateVertexCount, emitUndoValidation, onStylesChanged, clearSketch, setInvalid, liveStroke, liveDrawChecks }) => ({ + done () { + if (canFinish(geometryType, getSketch())) { drawInteraction.finishDrawing() } + }, + cancel () { drawInteraction.abortDrawing() }, + undo () { drawInteraction.removeLastPoint(); updateVertexCount(); emitUndoValidation() }, + setInvalid, + setInterfaceType (type) { input.setInterfaceType(type) }, + // Tags the sketch feature so createSketchStyle can pick the split-specific + // colour (see styles.js); no-op before any sketch exists. + setDrawingPreviewProperty (property, value) { + const sketch = getSketch() + if (!sketch) { return } + sketch.set(property, value) + drawInteraction.overlay_.changed() + }, + destroy () { + liveStroke.destroy() + liveDrawChecks.destroy() + manager.off(STYLES_CHANGED_EVENT, onStylesChanged) + // Emit the final interfaceType so crosshair visibility is correct on exit. + manager.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: input.getInterfaceType() }) + input.destroy() + map.removeInteraction(drawInteraction) + clearSketch() + } +}) + +// Build the touch/keyboard draw input for the interaction. +const buildDrawInput = ({ drawInteraction, options, geometryType, getSketch, updateVertexCount, emitUndoValidation, canPlaceVertex }) => + createDrawInput({ + drawInteraction, + options: { + container: options.container, + interfaceType: options.interfaceType, + addVertexButtonId: options.addVertexButtonId, + mapProvider: options.mapProvider, + snap: options.snap, + onUndo: () => { drawInteraction.removeLastPoint(); updateVertexCount(); emitUndoValidation() }, + canFinish: () => canFinish(geometryType, getSketch()), + canPlace: canPlaceVertex + } + }) + +export const createDrawMode = ({ map, manager, options }) => { + const { geometryType, featureId, properties = {} } = options + + let sketchFeature = null + let invalid = false + let currentSketchStyle = manager.styles.createSketchStyle(geometryType) + const { updateVertexCount, resetCount, emitUndoValidation } = createVertexTracker(manager, () => sketchFeature) + + const canPlaceVertex = buildCanPlaceVertex({ manager, geometryType, getSketch: () => sketchFeature }) + + const drawInteraction = new Draw({ + type: geometryType, + style: (feature) => currentSketchStyle(feature), + stopClick: true, + snapTolerance: TOLERANCES.snapRadius, + condition: buildCondition(map, geometryType, () => sketchFeature, canPlaceVertex), + // Block finish gestures (double-click / click-to-close) while the shape is invalid. + finishCondition: () => manager._geometryValid !== false + }) + map.addInteraction(drawInteraction) + + const setSketchInvalid = (next) => { + invalid = next + currentSketchStyle = manager.styles.createSketchStyle(geometryType, invalid) + drawInteraction.overlay_.changed() + } + // liveStroke stays a real createLiveStroke instance (not just a set() sink) + // since edit mode drives its own separate instance the same way — kept here + // for consistency with that pattern, even though draw mode only ever calls + // set() on it (see liveDrawChecks.js, which is flip-guarded itself). There's + // no equivalent cross-mode instance for Add-point, so that verdict emits directly. + const liveStroke = createLiveStroke({ onChange: setSketchInvalid }) + // Computes both verdicts from a SINGLE throttled call to the user's callback + // per sketch change (see liveDrawChecks.js for why that matters). + const liveDrawChecks = createLiveDrawChecks({ + onStrokeChange: (nextInvalid, reason) => liveStroke.set(nextInvalid, reason), + onPlaceChange: (vetoed, reason) => manager.emit(ADAPTER_EVENTS.CAN_PLACE_CHANGE, { canPlace: !vetoed, reason }) + }) + const liveMode = MODE_BY_GEOMETRY[geometryType] + const updateLiveValidity = () => { + if (!sketchFeature) { return } + const { feature, numVertices } = displayedSketch(geometryType, sketchFeature) + liveDrawChecks.update({ feature, context: { mode: liveMode }, numVertices, onGeometryChange: manager._geometryValidator }) + } + + // Update sketch style when map style changes + const onStylesChanged = () => { + currentSketchStyle = manager.styles.createSketchStyle(geometryType, invalid) + drawInteraction.overlay_.changed() + } + manager.on(STYLES_CHANGED_EVENT, onStylesChanged) + // OL internal: overlay_ is the private VectorLayer used for the sketch geometry. + // updateWhileAnimating_ forces per-frame redraws during view animations (keyboard pan). + // Without this, geom.setCoordinates() calls are ignored while the ANIMATING hint is set. + // Check ol/interaction/Draw.js and ol/layer/BaseVector.js if this breaks after an OL upgrade. + drawInteraction.overlay_.updateWhileAnimating_ = true + + attachDrawListeners(drawInteraction, { + manager, + featureId, + properties, + onStart: (f) => { sketchFeature = f; resetCount() }, + onSketchChange: () => { updateVertexCount(); updateLiveValidity() } + }) + + const input = buildDrawInput({ + drawInteraction, + options, + geometryType, + getSketch: () => sketchFeature, + updateVertexCount, + emitUndoValidation, + canPlaceVertex + }) + + return buildDrawModeApi({ + map, + manager, + drawInteraction, + input, + geometryType, + getSketch: () => sketchFeature, + updateVertexCount, + emitUndoValidation, + onStylesChanged, + clearSketch: () => { sketchFeature = null }, + // External writes go through the controller so its cache mirrors the style. + setInvalid: (next) => liveStroke.set(next), + liveStroke, + liveDrawChecks + }) +} diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js new file mode 100644 index 000000000..50a3b06df --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -0,0 +1,431 @@ +import { createDrawMode, buildCondition, buildCanPlaceVertex } from './DrawMode.js' +import { createDrawInput } from './drawInput.js' +import { createFeatureStore } from '../core/featureStore.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' +import { createFakeMap, createFakeManager, polygonFeature, lineFeature } from '../__helpers__/harness.js' + +jest.mock('./drawInput.js', () => ({ + createDrawInput: jest.fn(() => ({ + getInterfaceType: jest.fn(() => 'keyboard'), + setInterfaceType: jest.fn(), + destroy: jest.fn() + })) +})) + +const setup = (geometryType = 'Polygon') => { + const map = createFakeMap() + const manager = createFakeManager() + manager.store = createFeatureStore() + const emitted = () => manager.emit.mock.calls.map(([type, payload]) => ({ type, payload })) + const mode = createDrawMode({ + map, + manager, + options: { geometryType, featureId: 'shape-1', properties: { label: 'field' }, container: null, snap: null } + }) + const interaction = map.interactions[0] + const input = createDrawInput.mock.results.at(-1).value + const inputOptions = createDrawInput.mock.calls.at(-1)[0].options + return { map, manager, emitted, mode, interaction, input, inputOptions } +} + +afterEach(() => jest.clearAllMocks()) + +describe('buildCondition (duplicate-click suppression)', () => { + const map = createFakeMap() + const click = (x, y, originalEvent = {}) => ({ pixel: [x, y], originalEvent }) + const conditionFor = (sketch) => buildCondition(map, 'LineString', () => sketch, () => true) + + test('modifier-key clicks never draw', () => { + expect(conditionFor(null)(click(0, 0, { shiftKey: true }))).toBe(false) + }) + + test('clicks always draw before the sketch exists or once the shape is finishable', () => { + expect(conditionFor(null)(click(0, 0))).toBe(true) + const finishable = lineFeature([[0, 0], [10, 0], [50, 50]]) // 2 placed ≥ line minimum + expect(conditionFor(finishable)(click(10, 1))).toBe(true) + }) + + test('while unfinishable, clicks on the last placed vertex are suppressed', () => { + const oneVertex = lineFeature([[10, 10], [50, 50]]) // 1 placed + rubber + expect(conditionFor(oneVertex)(click(11, 11))).toBe(false) + expect(conditionFor(oneVertex)(click(20, 20))).toBe(true) + }) + + test('degenerate sketches or unprojectable vertices do not block drawing', () => { + expect(conditionFor(lineFeature([[10, 10]]))(click(10, 10))).toBe(true) // nothing placed yet + const blindMap = { getPixelFromCoordinate: () => null } + const condition = buildCondition(blindMap, 'LineString', () => lineFeature([[10, 10], [50, 50]]), () => true) + expect(condition(click(10, 10))).toBe(true) + }) +}) + +describe('buildCondition (placement gate, polygon)', () => { + const map = createFakeMap() + // Polygon sketch: 3 placed vertices + 2 trailing (rubber-band + closing) coords. + const polyClick = (coordinate) => ({ pixel: [0, 0], originalEvent: {}, coordinate }) + const condition = (ring, manager = createFakeManager()) => { + const getSketch = () => polygonFeature(ring) + const gate = buildCanPlaceVertex({ manager, geometryType: 'Polygon', getSketch }) + return buildCondition(map, 'Polygon', getSketch, gate) + } + + test('rejects a click that would make the drawn path cross itself', () => { + const sketch = [[0, 0], [2, 2], [2, 0], [9, 9], [0, 0]] // placed: (0,0)(2,2)(2,0) + expect(condition(sketch)(polyClick([0, 2]))).toBe(false) + }) + + test('allows a click that keeps the path simple', () => { + const sketch = [[0, 0], [2, 0], [2, 2], [9, 9], [0, 0]] // placed: (0,0)(2,0)(2,2) + expect(condition(sketch)(polyClick([0, 2]))).toBe(true) + }) +}) + +describe('buildCanPlaceVertex', () => { + const gateFor = (ring, manager) => + buildCanPlaceVertex({ manager, geometryType: 'Polygon', getSketch: () => (ring ? polygonFeature(ring) : null) }) + + test('a hard-rule veto emits PLACEMENT_BLOCKED with the candidate and reason', () => { + const manager = createFakeManager() + const gate = gateFor([[0, 0], [2, 2], [2, 0], [9, 9], [0, 0]], manager) + expect(gate([0, 2])).toBe(false) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.PLACEMENT_BLOCKED, expect.objectContaining({ + phase: 'place', + mode: 'draw_polygon', + vertexIndex: 3, + reason: expect.any(String), + feature: expect.objectContaining({ type: 'Feature' }) + })) + }) + + test('the user callback can veto a placement (and receives phase "place")', () => { + const manager = createFakeManager() + manager._geometryValidator = jest.fn((event) => + event.phase === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) + const gate = gateFor([[0, 0], [2, 0], [9, 9], [0, 0]], manager) + expect(gate([5, 5])).toBe(false) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.PLACEMENT_BLOCKED, + expect.objectContaining({ reason: 'outside region', phase: 'place' })) + }) + + test('the user callback can veto the very first vertex (no sketch yet)', () => { + const manager = createFakeManager() + manager._geometryValidator = () => ({ valid: false, reason: 'outside region' }) + const gate = gateFor(null, manager) + expect(gate([5, 5])).toBe(false) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.PLACEMENT_BLOCKED, + expect.objectContaining({ vertexIndex: 0 })) + }) + + test('a valid placement passes and emits nothing', () => { + const manager = createFakeManager() + manager._geometryValidator = jest.fn(() => true) + const gate = gateFor([[0, 0], [2, 0], [9, 9], [0, 0]], manager) + expect(gate([2, 2])).toBe(true) + expect(manager.emit).not.toHaveBeenCalled() + }) +}) + +describe('drawing lifecycle', () => { + test('the sketch reports its placed vertex count on every geometry change', () => { + const { emitted, interaction } = setup() + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [5, 5], [0, 0]]]) + expect(emitted().at(-1)).toEqual({ type: ADAPTER_EVENTS.VERTEX_CHANGE, payload: { numVertices: 2 } }) + }) + + test('finishCondition blocks finishing while the geometry is invalid', () => { + const { manager, interaction } = setup() + manager._geometryValid = false + expect(interaction.finishCondition_()).toBe(false) + manager._geometryValid = true + expect(interaction.finishCondition_()).toBe(true) + }) + + test('the live check turns the sketch stroke dashed while the displayed ring self-intersects', () => { + const { manager, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + // Placed (0,0)(10,10)(10,0) + rubber-band (0,10) + closing — a bowtie. + sketch.getGeometry().setCoordinates([[[0, 0], [10, 10], [10, 0], [0, 10], [0, 0]]]) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', true) + // Rubber-band moves back to a simple ring → solid again. + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]]) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', false) + }) + + test('the live check only restyles when the invalid state flips, not on every move', () => { + const { manager, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 10], [10, 0], [0, 10], [0, 0]]]) + const styleCalls = manager.styles.createSketchStyle.mock.calls.length + sketch.getGeometry().setCoordinates([[[0, 0], [10, 10], [10, 0], [1, 10], [0, 0]]]) // still crossing + expect(manager.styles.createSketchStyle.mock.calls.length).toBe(styleCalls) + }) + + test('placing a valid vertex keeps the stroke solid while the rubber band still sits on it', () => { + const { manager, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + // 3 placed + rubber band duplicating the just-placed vertex + closing coord. + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [10, 10], [10, 10], [0, 0]]]) + expect(manager.styles.createSketchStyle).not.toHaveBeenCalledWith('Polygon', true) + }) + + test('2 placed vertices + cursor never go dashed (below the 4-point threshold)', () => { + const { manager, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 10], [5, 0], [0, 0]]]) // 2 placed + rubber + expect(manager.styles.createSketchStyle).not.toHaveBeenCalledWith('Polygon', true) + }) + + test('drawInput receives the placement gate for touch/keyboard placements', () => { + const { inputOptions } = setup('Polygon') + expect(typeof inputOptions.canPlace).toBe('function') + }) + + test('a placement-vetoing path disables Add point; a legal one re-enables it', () => { + const { emitted, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + // Open drawn path crosses itself → placing at the crosshair would be vetoed. + sketch.getGeometry().setCoordinates([[[0, 0], [2, 2], [2, 0], [0, 2], [0, 0]]]) + expect(emitted().find((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE)?.payload) + .toEqual(expect.objectContaining({ canPlace: false, reason: expect.any(String) })) + sketch.getGeometry().setCoordinates([[[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]]) + expect(emitted().filter((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE).pop()?.payload) + .toEqual(expect.objectContaining({ canPlace: true })) + }) + + test('a red stroke via the closing edge alone keeps Add point enabled', () => { + const { manager, emitted, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + // Only the implicit closing edge crosses: stroke dashed, but the placement is legal. + sketch.getGeometry().setCoordinates([[[0, 0], [2, 0], [0, 2], [2, 2], [0, 0]]]) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', true) + expect(emitted().find((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE)).toBeUndefined() + }) + + test('the user callback runs ONCE per sketch change, with phase preview, driving both the stroke and Add point from that single call', () => { + jest.useFakeTimers() + const { manager, emitted, interaction } = setup('Polygon') + manager._geometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]]) + expect(manager._geometryValidator).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(manager._geometryValidator).toHaveBeenCalledTimes(1) // not once per gate + expect(manager._geometryValidator).toHaveBeenCalledWith(expect.objectContaining({ phase: 'preview' })) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', true) + expect(emitted().find((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE)?.payload) + .toEqual(expect.objectContaining({ canPlace: false, reason: 'outside region' })) + jest.useRealTimers() + }) + + test('the user callback runs even before any vertex is placed, gating Add point from the very first candidate point', () => { + jest.useFakeTimers() + const { manager, emitted, interaction } = setup('Polygon') + manager._geometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const sketch = polygonFeature([[5, 5], [5, 5]]) // no committed vertices yet — rubber-band + closing dupe only + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[5, 5], [5, 5]]]) + jest.runAllTimers() + expect(manager._geometryValidator).toHaveBeenCalledWith(expect.objectContaining({ numVertices: 0, phase: 'preview' })) + expect(emitted().find((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE)?.payload) + .toEqual(expect.objectContaining({ canPlace: false, reason: 'outside region' })) + jest.useRealTimers() + }) + + test('setInvalid rebuilds the sketch style in the invalid variant and re-renders', () => { + const { mode, manager, interaction } = setup('Polygon') + const changed = jest.spyOn(interaction.overlay_, 'changed') + mode.setInvalid(true) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', true) + expect(changed).toHaveBeenCalled() + }) + + test('setInterfaceType forwards to the draw input (e.g. DrawInit syncing MoveControl-driven interface changes mid-session)', () => { + const { mode, input } = setup('Polygon') + mode.setInterfaceType('touch') + expect(input.setInterfaceType).toHaveBeenCalledWith('touch') + }) + + test('setDrawingPreviewProperty tags the sketch feature and re-renders; safe with no sketch yet', () => { + const { mode, interaction } = setup('LineString') + const changed = jest.spyOn(interaction.overlay_, 'changed') + expect(() => mode.setDrawingPreviewProperty('splitter', 'valid')).not.toThrow() + expect(changed).not.toHaveBeenCalled() + + const sketch = lineFeature([[0, 0], [1, 1]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + mode.setDrawingPreviewProperty('splitter', 'valid') + expect(sketch.get('splitter')).toBe('valid') + expect(changed).toHaveBeenCalled() + }) + + test('undo re-validates the committed shape (deferred, phase commit-delete)', () => { + jest.useFakeTimers() + const { mode, manager, emitted, interaction } = setup() + const sketch = polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + jest.spyOn(interaction, 'removeLastPoint').mockImplementation(() => {}) + manager.emit.mockClear() + mode.undo() + jest.runAllTimers() + expect(emitted().filter((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE).pop()?.payload).toEqual( + expect.objectContaining({ phase: 'commit-delete', feature: expect.any(Object) })) + jest.useRealTimers() + }) + + test('placing a vertex emits a deferred commit-level geometrychange for validation', () => { + jest.useFakeTimers() + const { emitted, interaction } = setup() + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [5, 5], [0, 0]]]) + // Deferred a tick to avoid re-entrancy — not emitted synchronously. + expect(emitted().some((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE)).toBe(false) + jest.runAllTimers() + const geom = emitted().find((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE) + expect(geom.payload).toEqual(expect.objectContaining({ phase: 'commit-add' })) + jest.useRealTimers() + }) + + test('does not re-emit an add when the placed count is unchanged', () => { + jest.useFakeTimers() + const { emitted, interaction } = setup() + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [5, 5], [0, 0]]]) // placed grows to 2 → one add + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [7, 7], [0, 0]]]) // rubber moved, placed still 2 + jest.runAllTimers() + expect(emitted().filter((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE)).toHaveLength(1) + jest.useRealTimers() + }) + + test('emits the placed line vertices as a LineString geometrychange', () => { + jest.useFakeTimers() + const { emitted, interaction } = setup('LineString') + const sketch = lineFeature([[0, 0], [5, 5]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[0, 0], [10, 0], [5, 5]]) + jest.runAllTimers() + const geom = emitted().find((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE) + expect(geom.payload.feature.geometry.type).toBe('LineString') + jest.useRealTimers() + }) + + test('vertex tracking becomes inert once the sketch is cleared', () => { + jest.useFakeTimers() + const { emitted, interaction, mode } = setup() + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [5, 5], [0, 0]]]) // schedules a deferred add emit + mode.destroy() // clears the sketch reference + sketch.getGeometry().setCoordinates([[[0, 0], [12, 0], [6, 6], [0, 0]]]) // updateVertexCount now a no-op + jest.runAllTimers() // the earlier deferred emit sees no sketch + expect(emitted().some((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE)).toBe(false) + jest.useRealTimers() + }) + + test('finishing hands the feature over with the requested id and properties', () => { + const { manager, emitted, interaction } = setup() + interaction.dispatchEvent({ type: 'drawend', feature: polygonFeature([[0, 0], [10, 0], [10, 10], [0, 0]], null) }) + const created = emitted().find((e) => e.type === ADAPTER_EVENTS.CREATE) + expect(created.payload).toMatchObject({ id: 'shape-1', properties: { label: 'field' } }) + expect(manager.store.getOL('shape-1')).not.toBeNull() + }) + + test('aborting cancels', () => { + const { emitted, interaction } = setup() + interaction.dispatchEvent({ type: 'drawabort' }) + expect(emitted()).toContainEqual({ type: ADAPTER_EVENTS.CANCEL, payload: undefined }) + }) + + test('the interaction consults the duplicate-suppression condition against the live sketch', () => { + const { interaction } = setup('LineString') + expect(interaction.condition_({ pixel: [11, 11], originalEvent: {} })).toBe(true) // no sketch yet + interaction.dispatchEvent({ type: 'drawstart', feature: lineFeature([[11, 11], [50, 50]]) }) + expect(interaction.condition_({ pixel: [11, 11], originalEvent: {} })).toBe(false) // on last placed + }) + + test('done() finishes only when the shape has enough vertices', () => { + const { mode, interaction } = setup() + const finish = jest.spyOn(interaction, 'finishDrawing').mockImplementation(() => {}) + mode.done() // no sketch at all + interaction.dispatchEvent({ type: 'drawstart', feature: polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) }) + mode.done() // 2 placed < polygon minimum of 3 + expect(finish).not.toHaveBeenCalled() + + interaction.dispatchEvent({ type: 'drawstart', feature: polygonFeature([[0, 0], [10, 0], [10, 10], [5, 5], [0, 0]]) }) + mode.done() + expect(finish).toHaveBeenCalled() + }) + + test('cancel() aborts the sketch; undo() removes the last point and re-reports the count', () => { + const { mode, emitted, interaction } = setup() + const abort = jest.spyOn(interaction, 'abortDrawing').mockImplementation(() => {}) + const removeLast = jest.spyOn(interaction, 'removeLastPoint').mockImplementation(() => {}) + mode.cancel() + expect(abort).toHaveBeenCalled() + + interaction.dispatchEvent({ type: 'drawstart', feature: polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) }) + mode.undo() + expect(removeLast).toHaveBeenCalled() + expect(emitted().at(-1)).toEqual({ type: ADAPTER_EVENTS.VERTEX_CHANGE, payload: { numVertices: 2 } }) + }) + + test('undo before any sketch exists re-reports nothing', () => { + const { mode, emitted, interaction } = setup() + jest.spyOn(interaction, 'removeLastPoint').mockImplementation(() => {}) + mode.undo() // no sketch yet → updateVertexCount returns early + expect(emitted().some((e) => e.type === ADAPTER_EVENTS.VERTEX_CHANGE)).toBe(false) + }) +}) + +describe('wiring', () => { + test('the drawInput gets working undo and canFinish callbacks', () => { + const { interaction, inputOptions } = setup('LineString') + const removeLast = jest.spyOn(interaction, 'removeLastPoint').mockImplementation(() => {}) + expect(inputOptions.canFinish()).toBe(false) // no sketch yet + interaction.dispatchEvent({ type: 'drawstart', feature: lineFeature([[0, 0], [10, 0], [5, 5]]) }) + expect(inputOptions.canFinish()).toBe(true) + inputOptions.onUndo() + expect(removeLast).toHaveBeenCalled() + }) + + test('properties default to an empty object when omitted from options', () => { + const map = createFakeMap() + const manager = createFakeManager() + manager.store = createFeatureStore() + createDrawMode({ map, manager, options: { geometryType: 'Polygon', featureId: 'shape-2', container: null, snap: null } }) + map.interactions[0].dispatchEvent({ type: 'drawend', feature: polygonFeature([[0, 0], [10, 0], [10, 10], [0, 0]], null) }) + const created = manager.store.getOL('shape-2') + expect(created).not.toBeNull() + expect(created.get('label')).toBeUndefined() + }) + + test('map style changes rebuild the sketch style for the current geometry type', () => { + const { manager, interaction } = setup() + expect(manager.styles.createSketchStyle).toHaveBeenCalledWith('Polygon') + manager.emit(STYLES_CHANGED_EVENT, manager.styles) + expect(manager.styles.createSketchStyle).toHaveBeenCalledTimes(2) + expect(interaction.getOverlay().getStyleFunction()({ getGeometry: () => ({ getType: () => 'Point' }) })).toEqual([]) + }) + + test('destroy reports the final interface type, tears down input and interaction', () => { + const { map, mode, emitted, input, interaction } = setup() + mode.destroy() + expect(emitted()).toContainEqual({ + type: ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, + payload: { interfaceType: 'keyboard' } + }) + expect(input.destroy).toHaveBeenCalled() + expect(map.removeInteraction).toHaveBeenCalledWith(interaction) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/draw/drawInput.js b/plugins/draw/src/adapters/openlayers/draw/drawInput.js new file mode 100644 index 000000000..13abba36a --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/draw/drawInput.js @@ -0,0 +1,139 @@ +import { createVertexPlacement } from './vertexPlacement.js' + +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) + +const wireInputEvents = ({ + container, addVertexButtonId, olView, onUndo, + getInterfaceType, setInterfaceType, clearLastCoord, + updateRubberbanding, placeVertex +}) => { + const onCenterChange = () => { + if (getInterfaceType() !== 'mouse') { + updateRubberbanding() + } + } + olView?.on('change:center', onCenterChange) + + const onKeydown = (e) => { + if (!container.contains(document.activeElement)) { + return + } + if (ARROW_KEYS.has(e.key)) { + setInterfaceType('keyboard') + return + } + if (e.key === 'Enter') { + e.preventDefault() + setInterfaceType('keyboard') + placeVertex() + } + if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + onUndo?.() + } + } + + const onButtonClick = (e) => { + if (addVertexButtonId && e.target.closest(`#${addVertexButtonId}`)) { + placeVertex() + } + } + + const onPointerdown = (e) => { + if (e.pointerType !== 'touch') { + setInterfaceType('mouse') + clearLastCoord() + } + } + + const onTouchstart = () => { + setInterfaceType('touch') + } + + const onPointerMove = () => { + if (getInterfaceType() === 'mouse') { + return + } + updateRubberbanding() + } + + globalThis.addEventListener('keydown', onKeydown) + globalThis.addEventListener('click', onButtonClick) + container.addEventListener('pointerdown', onPointerdown) + container.addEventListener('touchstart', onTouchstart, { passive: true }) + container.addEventListener('pointermove', onPointerMove) + + return { + destroy () { + olView?.un('change:center', onCenterChange) + globalThis.removeEventListener('keydown', onKeydown) + globalThis.removeEventListener('click', onButtonClick) + container.removeEventListener('pointerdown', onPointerdown) + container.removeEventListener('touchstart', onTouchstart) + container.removeEventListener('pointermove', onPointerMove) + } + } +} + +/** + * Touch/keyboard input wiring for draw mode: crosshair vertex placement via the + * add-vertex button or Enter, keyboard undo, interface-type tracking, and + * rubber-band updates while the map pans under the crosshair. + * + * @returns {{ getInterfaceType: () => string, destroy: () => void }} + */ +export const createDrawInput = ({ drawInteraction, options }) => { + const { container, addVertexButtonId, mapProvider, snap, onUndo, canFinish, canPlace } = options + let interfaceType = options.interfaceType ?? 'mouse' + const getInterfaceType = () => interfaceType + + const placement = createVertexPlacement({ + drawInteraction, + mapProvider, + snap, + canFinish, + canPlace, + getInterfaceType + }) + + const map = drawInteraction.getMap() + const olView = map?.getView() + + const events = wireInputEvents({ + container, + addVertexButtonId, + olView, + onUndo, + getInterfaceType, + setInterfaceType: (t) => { interfaceType = t }, + clearLastCoord: placement.clearLastCoord, + updateRubberbanding: placement.updateRubberbanding, + placeVertex: placement.placeVertex + }) + + // change:center fires once when a keyboard pan animation starts; postrender tracks each frame. + const onMapRender = () => { + if (interfaceType !== 'mouse' && olView?.getAnimating()) { + placement.updateRubberbanding() + } + } + map?.on('postrender', onMapRender) + + return { + getInterfaceType, + // Called when the global interface type changes without any pointer/touch/key + // event landing on the map container (e.g. panning via MoveControl after + // switching to touch) — refresh the rubber band immediately rather than + // waiting for the next incidental change:center/postrender event. + setInterfaceType (type) { + interfaceType = type + if (type !== 'mouse') { + placement.updateRubberbanding() + } + }, + destroy () { + events.destroy() + map?.un('postrender', onMapRender) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js b/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js new file mode 100644 index 000000000..2f715dc55 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js @@ -0,0 +1,140 @@ +import { createDrawInput } from './drawInput.js' +import { createVertexPlacement } from './vertexPlacement.js' +import { createFakeMap, createContainer } from '../__helpers__/harness.js' + +jest.mock('./vertexPlacement.js', () => ({ + createVertexPlacement: jest.fn(() => ({ + placeVertex: jest.fn(), + updateRubberbanding: jest.fn(), + clearLastCoord: jest.fn() + })) +})) + +const setup = (interfaceType = 'mouse') => { + const map = createFakeMap() + const container = createContainer() + const button = document.createElement('button') + button.id = 'add-pt' + document.body.appendChild(button) + const onUndo = jest.fn() + const input = createDrawInput({ + drawInteraction: { getMap: () => map }, + options: { container, addVertexButtonId: 'add-pt', interfaceType, mapProvider: {}, snap: null, onUndo } + }) + const placement = createVertexPlacement.mock.results.at(-1).value + liveInputs.push(input) + return { map, view: map.getView(), container, button, input, placement, onUndo } +} + +const liveInputs = [] +afterEach(() => { + liveInputs.splice(0).forEach((i) => i.destroy()) + document.body.innerHTML = '' + jest.clearAllMocks() +}) + +const key = (props) => window.dispatchEvent(new KeyboardEvent('keydown', { cancelable: true, ...props })) + +test('Enter places a vertex and switches to keyboard — only while focus is inside the viewport', () => { + const { container, input, placement } = setup() + key({ key: 'Enter' }) + expect(placement.placeVertex).not.toHaveBeenCalled() + + container.focus() + key({ key: 'Enter' }) + expect(placement.placeVertex).toHaveBeenCalled() + expect(input.getInterfaceType()).toBe('keyboard') +}) + +test('arrow keys switch to the keyboard interface without placing', () => { + const { container, input, placement } = setup() + container.focus() + key({ key: 'ArrowUp' }) + expect(input.getInterfaceType()).toBe('keyboard') + expect(placement.placeVertex).not.toHaveBeenCalled() +}) + +test('ctrl/cmd+z triggers undo', () => { + const { container, onUndo } = setup() + container.focus() + key({ key: 'z', ctrlKey: true }) + expect(onUndo).toHaveBeenCalled() +}) + +test('the add-vertex button places a vertex; other clicks do not', () => { + const { button, placement } = setup() + button.click() + expect(placement.placeVertex).toHaveBeenCalledTimes(1) + document.body.click() + expect(placement.placeVertex).toHaveBeenCalledTimes(1) +}) + +test('pointer and touch input switch the interface type', () => { + const { container, input, placement } = setup('keyboard') + container.dispatchEvent(Object.assign(new Event('pointerdown'), { pointerType: 'mouse' })) + expect(input.getInterfaceType()).toBe('mouse') + expect(placement.clearLastCoord).toHaveBeenCalled() + + container.dispatchEvent(new Event('touchstart')) + expect(input.getInterfaceType()).toBe('touch') +}) + +test('a touch pointerdown is left for the touchstart handler to classify', () => { + const { container, input, placement } = setup('keyboard') + container.dispatchEvent(Object.assign(new Event('pointerdown'), { pointerType: 'touch' })) + expect(input.getInterfaceType()).toBe('keyboard') // onPointerdown ignored the touch pointer + expect(placement.clearLastCoord).not.toHaveBeenCalled() +}) + +test('the interface type defaults to mouse when none is supplied', () => { + const map = createFakeMap() + const container = createContainer() + const input = createDrawInput({ + drawInteraction: { getMap: () => map }, + options: { container, addVertexButtonId: 'add-pt', mapProvider: {}, snap: null, onUndo: jest.fn() } + }) + liveInputs.push(input) + expect(input.getInterfaceType()).toBe('mouse') +}) + +test('pointer moves and map pans update the rubber band except for the mouse interface', () => { + const { container, view, placement } = setup('touch') + container.dispatchEvent(new Event('pointermove')) + view.emit('change:center') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(2) + + const mouse = setup('mouse') + mouse.container.dispatchEvent(new Event('pointermove')) + mouse.view.emit('change:center') + expect(mouse.placement.updateRubberbanding).not.toHaveBeenCalled() +}) + +test('setInterfaceType updates the interface and refreshes the rubber band immediately for non-mouse types, e.g. switching to touch and panning via MoveControl mid-session', () => { + const { input, placement } = setup('mouse') + input.setInterfaceType('touch') + expect(input.getInterfaceType()).toBe('touch') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1) + + input.setInterfaceType('mouse') + expect(input.getInterfaceType()).toBe('mouse') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1) // not called again for mouse +}) + +test('keyboard pan animations keep the rubber band anchored via postrender', () => { + const { map, view, placement } = setup('keyboard') + view.getAnimating.mockReturnValue(true) + map.emit('postrender') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1) + view.getAnimating.mockReturnValue(false) + map.emit('postrender') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1) +}) + +test('destroy removes all listeners', () => { + const { container, button, input, placement } = setup() + input.destroy() + container.focus() + key({ key: 'Enter' }) + button.click() + expect(placement.placeVertex).not.toHaveBeenCalled() +}) diff --git a/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js new file mode 100644 index 000000000..4b90f2371 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js @@ -0,0 +1,144 @@ +import { coordToPixel, pixelDist } from '../utils/olCoords.js' +import { getLastPlacedSketchCoord } from '../utils/sketchHelpers.js' + +const SNAP_TOLERANCE = 12 // pixels +// Minimum ring length to allow snap-to-close (placed vertices + rubber-band) +const MIN_SKETCH_COORDS = { Polygon: 4, LineString: 3 } +const DUPLICATE_TOLERANCE_PX = 2 + +export const isCloseToFirstVertex = (map, coord, sketchCoords, geometryType) => { + if (geometryType !== 'Polygon' || sketchCoords.length < MIN_SKETCH_COORDS.Polygon) { + return false + } + const currentPixel = coordToPixel(map, coord) + const firstPixel = coordToPixel(map, sketchCoords[0]) + if (!currentPixel || !firstPixel) { + return false + } + return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE +} + +export const applyRubberbanding = (geom, centerCoord) => { + if (geom.getType() === 'LineString') { + const updated = [...geom.getCoordinates()] + updated[updated.length - 1] = centerCoord + geom.setCoordinates(updated) + } else if (geom.getType() === 'Polygon') { + const updated = geom.getCoordinates().map((ring, i) => { + if (i !== 0) { + return ring + } + const r = [...ring] + r[r.length - 1] = centerCoord + return r + }) + geom.setCoordinates(updated) + } else { + // No action + } +} + +/** + * Close/finish handling for a placed coordinate. Pure with respect to the + * lastPlacedCoord bookkeeping: returns the updated value alongside whether the + * coordinate was consumed as a close/finish attempt (caller must not append). + * + * @returns {{ handled: boolean, lastPlacedCoord: number[] | null }} + */ +const tryClose = ({ drawInteraction, canFinish, geom, sketchCoords, coord, lastPlacedCoord }) => { + if (lastPlacedCoord && lastPlacedCoord[0] === coord[0] && lastPlacedCoord[1] === coord[1]) { + // Same position as last placed: don't duplicate. Close only if enough real vertices exist. + if (canFinish?.()) { drawInteraction.finishDrawing() } + return { handled: true, lastPlacedCoord: null } + } + const map = drawInteraction.getMap() + if (isCloseToFirstVertex(map, coord, sketchCoords, geom.getType())) { + drawInteraction.finishDrawing() + return { handled: true, lastPlacedCoord } + } + // When the add-vertex button overlays the map (touch UI), OL's native pointer handler + // and this button click handler both fire for the same tap. Detect that OL already + // committed a vertex at coord's position and skip the duplicate appendCoordinates, + // but register coord as lastPlacedCoord so a second tap at the same position can close. + const lastCommitted = getLastPlacedSketchCoord(geom) + if (lastCommitted) { + const p1 = map.getPixelFromCoordinate(lastCommitted) + const p2 = map.getPixelFromCoordinate(coord) + if (p1 && p2) { + const dx = p1[0] - p2[0]; const dy = p1[1] - p2[1] + if (dx * dx + dy * dy < DUPLICATE_TOLERANCE_PX * DUPLICATE_TOLERANCE_PX) { + return { handled: true, lastPlacedCoord: coord } + } + } + } + return { handled: false, lastPlacedCoord } +} + +/** + * Crosshair-driven vertex placement for touch/keyboard drawing. + * + * Owns the sketch-feature and last-placed-coordinate bookkeeping via the Draw + * interaction's lifecycle events, and implements placing a vertex at the map + * center: rubber-band updates, duplicate suppression when OL already committed + * the same tap, close-on-repeat and close-near-first-vertex behaviour. + * + * @returns {{ placeVertex, updateRubberbanding, clearLastCoord }} + */ +export const createVertexPlacement = ({ drawInteraction, mapProvider, snap, canFinish, canPlace, getInterfaceType }) => { + let sketchFeature = null + let lastPlacedCoord = null + + const resetSketch = (feature = null) => { + sketchFeature = feature + lastPlacedCoord = null + } + drawInteraction.on('drawstart', (e) => resetSketch(e.feature)) + drawInteraction.on('drawend', () => resetSketch()) + drawInteraction.on('drawabort', () => resetSketch()) + + const snappedCenter = () => { + const raw = mapProvider.getCenter() + return (getInterfaceType() !== 'mouse' && snap) ? snap.apply(raw) : raw + } + + const updateRubberbanding = () => { + if (!sketchFeature) { + // No sketch yet — update snap indicator at crosshair position so targets are + // visible before the first vertex is placed (touch/keyboard only; mouse uses + // the OL snap interaction's pointermove handler instead). + if (getInterfaceType() !== 'mouse' && snap) { + snap.apply(mapProvider.getCenter()) + } + return + } + const geom = sketchFeature.getGeometry() + if (!geom.getCoordinates().length) { + return + } + applyRubberbanding(geom, snappedCenter()) + } + + const placeVertex = () => { + const coord = snappedCenter() + snap?.hideIndicator() + if (sketchFeature) { + const geom = sketchFeature.getGeometry() + const rawCoords = geom.getCoordinates() + const sketchCoords = geom.getType() === 'Polygon' ? (rawCoords[0] || []) : rawCoords + const result = tryClose({ drawInteraction, canFinish, geom, sketchCoords, coord, lastPlacedCoord }) + lastPlacedCoord = result.lastPlacedCoord + if (result.handled) { return } + } + // appendCoordinates bypasses the Draw interaction's condition, so the placement + // gate must run here explicitly — touch/keyboard parity with the mouse path. + if (canPlace && !canPlace(coord)) { return } + drawInteraction.appendCoordinates([coord]) + lastPlacedCoord = coord + } + + return { + placeVertex, + updateRubberbanding, + clearLastCoord () { lastPlacedCoord = null } + } +} diff --git a/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js new file mode 100644 index 000000000..3b67986fc --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js @@ -0,0 +1,196 @@ +import Point from 'ol/geom/Point.js' +import Polygon from 'ol/geom/Polygon.js' +import Feature from 'ol/Feature.js' +import { createVertexPlacement, applyRubberbanding, isCloseToFirstVertex } from './vertexPlacement.js' +import { createFakeMap, createEmitter, polygonFeature, lineFeature } from '../__helpers__/harness.js' + +const CENTER = [5, 5] + +const setup = ({ interfaceType = 'touch', snap = null, canFinish = () => true, canPlace } = {}) => { + const map = createFakeMap() + const bus = createEmitter() + const drawInteraction = { + on: bus.on, + emit: bus.emit, + getMap: () => map, + appendCoordinates: jest.fn(), + finishDrawing: jest.fn() + } + const placement = createVertexPlacement({ + drawInteraction, + mapProvider: { getCenter: () => CENTER }, + snap, + canFinish, + canPlace, + getInterfaceType: () => interfaceType + }) + const startSketch = (feature) => { bus.emit('drawstart', { feature }); return feature } + return { drawInteraction, placement, startSketch, bus } +} + +describe('placing vertices', () => { + test('appends the crosshair position, snapped when a snap manager is active', () => { + const { placement, drawInteraction } = setup() + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + + const snap = { apply: jest.fn(() => [9, 9]), hideIndicator: jest.fn() } + const snapped = setup({ snap }) + snapped.placement.placeVertex() + expect(snapped.drawInteraction.appendCoordinates).toHaveBeenCalledWith([[9, 9]]) + expect(snap.hideIndicator).toHaveBeenCalled() + }) + + test('a canPlace veto rejects the placement — the vertex is never appended', () => { + const canPlace = jest.fn(() => false) + const { placement, drawInteraction } = setup({ canPlace }) + placement.placeVertex() + expect(canPlace).toHaveBeenCalledWith(CENTER) + expect(drawInteraction.appendCoordinates).not.toHaveBeenCalled() + }) + + test('a canPlace veto does not block a finish tap (close runs before the gate)', () => { + let allow = true + const { placement, drawInteraction, startSketch } = setup({ canPlace: () => allow }) + startSketch(lineFeature([[0, 0], [50, 50]])) + placement.placeVertex() // placed at the crosshair + allow = false + placement.placeVertex() // same spot again → a finish attempt, not a placement + expect(drawInteraction.finishDrawing).toHaveBeenCalledTimes(1) + }) + + test('the mouse interface ignores snapping (the OL snap interaction covers it)', () => { + const snap = { apply: jest.fn(() => [9, 9]), hideIndicator: jest.fn() } + const { placement, drawInteraction } = setup({ interfaceType: 'mouse', snap }) + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + }) + + test('a second tap at the same spot finishes the shape instead of duplicating — when finishable', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(lineFeature([[0, 0], [50, 50]])) + placement.placeVertex() + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledTimes(1) + expect(drawInteraction.finishDrawing).toHaveBeenCalledTimes(1) + + const unfinishable = setup({ canFinish: () => false }) + unfinishable.startSketch(lineFeature([[0, 0], [50, 50]])) + unfinishable.placement.placeVertex() + unfinishable.placement.placeVertex() + unfinishable.placement.placeVertex() // duplicate reset the bookkeeping — placed again + expect(unfinishable.drawInteraction.finishDrawing).not.toHaveBeenCalled() + expect(unfinishable.drawInteraction.appendCoordinates).toHaveBeenCalledTimes(2) + }) + + test('placing near the first polygon vertex closes the ring', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(polygonFeature([[0, 0], [30, 0], [30, 30], [8, 8], [0, 0]])) // center [5,5] within 12px of [0,0] + placement.placeVertex() + expect(drawInteraction.finishDrawing).toHaveBeenCalled() + expect(drawInteraction.appendCoordinates).not.toHaveBeenCalled() + }) + + test('a vertex OL already committed at the same position is not appended twice', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(lineFeature([[0, 0], [5, 6], [50, 50]])) // last committed [5,6] within 2px of center + placement.placeVertex() + expect(drawInteraction.appendCoordinates).not.toHaveBeenCalled() + placement.placeVertex() // now registered as last placed — same spot again closes + expect(drawInteraction.finishDrawing).toHaveBeenCalled() + }) + + test('the first vertex of a fresh sketch appends — nothing is committed yet', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(lineFeature([[0, 0]])) // only the rubber-band coord, no committed vertex + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + }) + + test('an unprojectable committed vertex skips the duplicate-tolerance check', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(lineFeature([[0, 0], [5, 6], [50, 50]])) + drawInteraction.getMap().getPixelFromCoordinate = () => null // mid view transition + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + }) + + test('placing into an empty polygon sketch (no ring yet) appends without error', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(new Feature(new Polygon([]))) // ring not created yet + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + }) + + test('clearLastCoord and sketch end/abort reset the duplicate bookkeeping', () => { + const { placement, drawInteraction, startSketch, bus } = setup() + startSketch(lineFeature([[0, 0], [50, 50]])) + placement.placeVertex() + placement.clearLastCoord() + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledTimes(2) + + bus.emit('drawend') + placement.placeVertex() // no sketch — plain append + expect(drawInteraction.appendCoordinates).toHaveBeenCalledTimes(3) + bus.emit('drawabort') + }) +}) + +describe('rubber-banding', () => { + test('moves the trailing coordinate of a line or polygon ring to the crosshair', () => { + const { placement, startSketch } = setup() + const line = startSketch(lineFeature([[0, 0], [50, 50]])) + placement.updateRubberbanding() + expect(line.getGeometry().getCoordinates()).toEqual([[0, 0], CENTER]) + + const poly = setup() + const hole = [[10, 10], [12, 10], [12, 12], [10, 10]] + const feature = poly.startSketch(new Feature(new Polygon([[[0, 0], [30, 0], [50, 50], [0, 0]], hole]))) + poly.placement.updateRubberbanding() + expect(feature.getGeometry().getCoordinates()[0].at(-1)).toEqual(CENTER) + expect(feature.getGeometry().getCoordinates()[1]).toEqual(hole) // inner rings untouched + }) + + test('without a sketch it only refreshes the snap indicator at the crosshair (non-mouse)', () => { + const snap = { apply: jest.fn((c) => c), hideIndicator: jest.fn() } + const { placement } = setup({ snap }) + placement.updateRubberbanding() + expect(snap.apply).toHaveBeenCalledWith(CENTER) + + const mouse = setup({ interfaceType: 'mouse', snap }) + snap.apply.mockClear() + mouse.placement.updateRubberbanding() + expect(snap.apply).not.toHaveBeenCalled() + }) + + test('an empty sketch geometry is left alone', () => { + const { placement, startSketch } = setup() + const line = startSketch(lineFeature([])) + placement.updateRubberbanding() // must not throw + expect(line.getGeometry().getCoordinates()).toEqual([]) + }) + + test('applyRubberbanding ignores unsupported geometry types', () => { + const point = new Point([1, 1]) + applyRubberbanding(point, CENTER) + expect(point.getCoordinates()).toEqual([1, 1]) + }) +}) + +describe('isCloseToFirstVertex', () => { + const map = createFakeMap() + + test('only closes polygons with enough vertices, within the snap tolerance', () => { + const ring = [[0, 0], [30, 0], [30, 30], [8, 8], [0, 0]] + expect(isCloseToFirstVertex(map, [5, 5], ring, 'Polygon')).toBe(true) + expect(isCloseToFirstVertex(map, [20, 20], ring, 'Polygon')).toBe(false) + expect(isCloseToFirstVertex(map, [5, 5], ring, 'LineString')).toBe(false) + expect(isCloseToFirstVertex(map, [5, 5], [[0, 0], [30, 0]], 'Polygon')).toBe(false) + }) + + test('is false when a coordinate cannot be projected to a pixel', () => { + const nullMap = { getPixelFromCoordinate: () => null } + expect(isCloseToFirstVertex(nullMap, [5, 5], [[0, 0], [1, 1], [2, 2], [3, 3]], 'Polygon')).toBe(false) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.js new file mode 100644 index 000000000..efe47e879 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.js @@ -0,0 +1,385 @@ +import { createMidpointLayer } from './midpointLayer.js' +import { createVertexLayer } from './vertexLayer.js' +import { createActiveVertexLayer } from './activeVertexLayer.js' +import { createSelectionState } from './selectionState.js' +import { createModifyInteraction, deriveModifyOp } from './modifyInteraction.js' +import { createPointerHandlers } from './pointerHandlers.js' +import { createTouchHandler } from './touchHandler.js' +import { createKeyboardHandler } from './keyboardHandler.js' +import { deleteVertex, insertAtMidpoint } from './vertexOps.js' +import { applyUndo } from './undoOps.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' +import { createLiveStroke } from '../../../validation/liveStroke.js' + +const TOUCH_INTERFACE = 'touch' +const VERTEX_TYPE = 'vertex' +const MOVE_VERTEX = 'commit-move' +const INSERT_VERTEX = 'commit-insert' +const DELETE_VERTEX = 'commit-delete' + +// Map an undo-op type onto the geometry-change `phase` consumed by validation. +const OP_PHASE = { + move_vertex: MOVE_VERTEX, + insert_vertex: INSERT_VERTEX, + delete_vertex: DELETE_VERTEX +} + +// Undoing an op commits the inverse change (undo of a delete re-inserts, etc.), +// so its re-validation reports the inverse phase. +const UNDO_INVERSE_PHASE = { + move_vertex: MOVE_VERTEX, + insert_vertex: DELETE_VERTEX, + delete_vertex: INSERT_VERTEX +} + +// Live invalid-stroke wiring: re-validate the displayed geometry on every geometry +// change (Modify drag, touch drag, keyboard nudge) via the shared controller — +// default rules synchronously, user callback throttled. All committed vertices are +// "placed", so the polygon count drops only OL's closing duplicate. +// +// The style is set ONCE to a function backed by `invalid`, rather than calling +// Feature#setStyle() on every flip: setStyle() fires a feature-level 'change' +// event, which OL's Modify interaction also listens for (to detect out-of-band +// edits and rebuild its internal drag/segment index) — guarded only while Modify +// itself is synchronously mutating the geometry. The built-in rules' flip stays +// inside that guarded window, but the user-callback flip is deliberately +// throttled to the next animation frame (liveStroke.js), landing outside it — +// so calling setStyle() there makes Modify treat its own flip as an external +// edit and rebuild mid-drag, killing the in-progress drag. A style function + +// map.render() repaints without ever touching the feature, so Modify's listener +// never fires from our own bookkeeping. +const wireLiveStroke = ({ map, manager, olFeature }) => { + let invalid = false + olFeature.setStyle(() => (invalid ? manager.styles.editFeatureStyleInvalid : manager.styles.editFeatureStyle)) + const liveStroke = createLiveStroke({ + onChange: (next, reason) => { + invalid = next + map.render() + // In edit mode the displayed shape is exactly what Done finishes, so live + // validity flips also gate the Done button (events.js dispatches them). + manager.emit(ADAPTER_EVENTS.VALIDITY_CHANGE, { valid: !next, reason }) + } + }) + const updateLiveValidity = () => { + const geom = olFeature.getGeometry() + const type = geom.getType() + const coordinates = geom.getCoordinates() + const numVertices = type === 'Polygon' + ? Math.max(0, (coordinates[0]?.length ?? 1) - 1) + : coordinates.length + liveStroke.update({ + feature: { type: 'Feature', geometry: { type, coordinates } }, + context: { mode: 'edit_vertex' }, + numVertices, + onGeometryChange: manager._geometryValidator + }) + } + olFeature.getGeometry().on('change', updateLiveValidity) + return { + liveStroke, + destroy () { + olFeature.getGeometry().un('change', updateLiveValidity) + liveStroke.destroy() + } + } +} + +/** + * Edit vertex mode — handles edit_vertex. + * + * createEditMode composes the OL pieces along their natural seams: + * - selectionState: shared mutable state + layer/event sync + * - modifyInteraction: OL Modify (pointer drag / midpoint insert) + undo-op derivation + * - pointerHandlers: mouse selection + delete-vertex button + * - touchHandler / keyboardHandler: touch drag and keyboard nudge input + */ + +// Delete-selected-vertex and undo operations, shared by pointer, touch and keyboard input +const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler }) => { + const { state, setState, syncGeom, emitGeometryValidation } = selection + + const doDeleteVertex = () => { + if (state.selectedVertexType !== VERTEX_TYPE || state.selectedVertexIndex < 0) { + return + } + const result = deleteVertex(olFeature, state.selectedVertexIndex) + if (!result) { + return + } + undoStack.push({ type: 'delete_vertex', vertexIndex: result.deletedIndex, deletedCoord: result.deletedCoord }) + syncGeom() + emitGeometryValidation(DELETE_VERTEX, result.deletedIndex) + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + } + + const doUndo = () => { + const op = undoStack.pop() + if (!op) { + return + } + const previousIndex = state.selectedVertexIndex + const restoredIndex = applyUndo(olFeature, op) + syncGeom() + // An undo commits the inverse change, so it must re-validate like any other + // commit — otherwise the invalid stroke and the Done gate go stale. + emitGeometryValidation(UNDO_INVERSE_PHASE[op.type], restoredIndex) + // Only re-select if a vertex was already active — undo must not create a new selection + const newIndex = previousIndex >= 0 ? restoredIndex : -1 + setState({ + selectedVertexIndex: newIndex, + selectedVertexType: newIndex >= 0 ? VERTEX_TYPE : null + }) + if (previousIndex >= 0 && newIndex >= 0 && state.interfaceType === TOUCH_INTERFACE) { + getTouchHandler().updateTargetPosition() + } + } + + return { doDeleteVertex, doUndo } +} + +const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, selection }) => { + const { state, getState, setState, syncGeom, emitGeometryValidation } = selection + const selectVertex = (index) => setState({ selectedVertexIndex: index, selectedVertexType: VERTEX_TYPE }) + + const touchHandler = createTouchHandler({ + map, + container, + getState, + setState, + colors: manager.colors, + snap, + onVertexMoved ({ vertexIndex, previousCoord }) { + undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) + syncGeom() + emitGeometryValidation(MOVE_VERTEX, vertexIndex) + selectVertex(vertexIndex) + touchHandler.updateTargetPosition() + }, + onTap (hit) { + if (!hit) { + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + return + } + if (hit.type === VERTEX_TYPE) { + selectVertex(hit.index) + touchHandler.updateTargetPosition() + return + } + // Only vertex and midpoint hits reach here, and the vertex case returned above + const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertices.length) + if (!result) { + return + } + undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex }) + syncGeom() + emitGeometryValidation(INSERT_VERTEX, result.insertedIndex) + selectVertex(result.insertedIndex) + touchHandler.updateTargetPosition() + } + }) + + selection.setHooks({ + onDeselect: () => touchHandler.hide(), + onUpdate () { + if (state.interfaceType === TOUCH_INTERFACE) { + touchHandler.updateTargetPosition() + } + } + }) + + return touchHandler +} + +const wireKeyboardHandler = ({ map, container, snap, undoStack, selection, touchHandler, actions }) => { + const { state, getState, setState, syncGeom, emitGeometryValidation } = selection + + return createKeyboardHandler({ + map, + getState, + setState, + snap, + onVertexMoved ({ vertexIndex, previousCoord }) { + undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) + syncGeom() + emitGeometryValidation(MOVE_VERTEX, vertexIndex) + setState({ selectedVertexIndex: vertexIndex, selectedVertexType: VERTEX_TYPE }) + }, + onInserted ({ insertedIndex }) { + undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex }) + syncGeom() + emitGeometryValidation(INSERT_VERTEX, insertedIndex) + }, + onDeleted: actions.doDeleteVertex, + onUndo: actions.doUndo, + onKeyboardActive () { + if (state.interfaceType === 'keyboard') { + return + } + state.interfaceType = 'keyboard' + touchHandler.hide() + container.focus({ preventScroll: true }) + } + }) +} + +// Style hot-swap on map style change + touch-target reposition on map resize +const wireMapSync = ({ map, manager, layers, selection, touchHandler, live }) => { + const { state } = selection + + const onStylesChanged = (styles) => { + // Re-assert through the live-stroke controller so an invalid (dashed) shape + // stays dashed across a map style change. + live.liveStroke.refresh() + layers.vertexLayer.updateStyle(styles.vertexStyle) + layers.midpointLayer.updateStyle(styles.midpointStyle) + layers.activeLayer.update(state) + touchHandler.updateColors(manager.colors) + } + manager.on(STYLES_CHANGED_EVENT, onStylesChanged) + + // Reposition the touch target after OL re-renders with the new size. + // change:size fires before the render, so we wait for postrender to get + // correct pixel coords from getPixelFromCoordinate. + const onMapSizeChange = () => { + if (state.interfaceType !== TOUCH_INTERFACE || state.selectedVertexIndex < 0) { + return + } + map.once('postrender', () => touchHandler.updateTargetPosition()) + } + map.on('change:size', onMapSizeChange) + + return { + destroy () { + manager.off(STYLES_CHANGED_EVENT, onStylesChanged) + map.un('change:size', onMapSizeChange) + } + } +} + +// The mode interface consumed by OLDrawManager +const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selection, actions, parts }) => { + const { state } = selection + const { touchHandler } = parts + + return { + setInterfaceType (type) { + if (type === state.interfaceType) { + return + } + state.interfaceType = type + if (type === TOUCH_INTERFACE) { + touchHandler.updateTargetPosition() + } else { + touchHandler.hide() + } + }, + + done () { + manager.emit(ADAPTER_EVENTS.EDIT_FINISH, store.toGeoJSON(olFeature)) + }, + + // Committed-verdict write (events.js): routed through the live-stroke + // controller so its cached state stays in sync with the rendered style. + setInvalid (invalid) { + parts.live.liveStroke.set(invalid) + }, + + // Nothing to restore here: the pre-edit feature is kept as tempFeature in the + // reducer and events.js re-adds it on cancel + cancel () {}, + + undo: actions.doUndo, + deleteVertex: actions.doDeleteVertex, + // MoveControl's D-pad, routed here via mapProvider.activeMoveTarget (see + // events.js) once a vertex is selected. + nudgeSelectedVertex: parts.keyboardHandler.nudgeByDelta, + + destroy () { + parts.live.destroy() + olFeature.setStyle(originalFeatureStyle) + selection.destroy() + parts.mapSync.destroy() + parts.pointerHandlers.destroy() + parts.modify.destroy() + parts.layers.activeLayer.remove() + parts.layers.midpointLayer.remove() + parts.layers.vertexLayer.remove() + touchHandler.destroy() + parts.keyboardHandler.destroy() + } + } +} + +/** + * @returns {{ setInterfaceType, done, cancel, undo, deleteVertex, nudgeSelectedVertex, destroy } | null} + */ +export const createEditMode = ({ map, manager, options }) => { + const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options + const { store, undoStack } = manager + + const olFeature = store.getOL(featureId) + if (!olFeature) { + return null + } + + const originalFeatureStyle = olFeature.getStyle() + + const midpointLayer = createMidpointLayer(map, manager.styles.midpointStyle) + const vertexLayer = createVertexLayer(map, manager.styles.vertexStyle) + const activeLayer = createActiveVertexLayer(map, () => manager.styles) + + const selection = createSelectionState({ + map, + manager, + store, + olFeature, + interfaceType, + layers: { vertexLayer, midpointLayer, activeLayer } + }) + const { state, getState, setState, syncGeom, emitGeometryValidation } = selection + + const modify = createModifyInteraction({ + map, + olFeature, + getState, + onModifyEnd (prevCoords) { + syncGeom() + const op = prevCoords && deriveModifyOp(prevCoords, state.vertices) + if (!op) { + return + } + undoStack.push(op) + emitGeometryValidation(OP_PHASE[op.type], op.vertexIndex) + setState({ selectedVertexIndex: op.vertexIndex, selectedVertexType: VERTEX_TYPE }) + } + }) + + syncGeom() // initial populate + + const layers = { vertexLayer, midpointLayer, activeLayer } + const live = wireLiveStroke({ map, manager, olFeature }) + const touchHandler = wireTouchHandler({ map, container, manager, snap, olFeature, undoStack, selection }) + const actions = createVertexActions({ olFeature, undoStack, selection, getTouchHandler: () => touchHandler }) + const keyboardHandler = wireKeyboardHandler({ map, container, snap, undoStack, selection, touchHandler, actions }) + const pointerHandlers = createPointerHandlers({ + map, + container, + getState, + setState, + touchHandler, + deleteVertexButtonId, + onDeleteVertex: actions.doDeleteVertex + }) + const mapSync = wireMapSync({ map, manager, layers, selection, touchHandler, live }) + + return buildModeApi({ + manager, + store, + olFeature, + originalFeatureStyle, + selection, + actions, + parts: { touchHandler, keyboardHandler, pointerHandlers, modify, mapSync, layers, live } + }) +} diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js new file mode 100644 index 000000000..e6d532059 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -0,0 +1,303 @@ +import { createEditMode } from './EditMode.js' +import { createFeatureStore } from '../core/featureStore.js' +import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { createFakeMap, createFakeManager, createContainer, domEvent } from '../__helpers__/harness.js' +import Style from 'ol/style/Style.js' +import Polygon from 'ol/geom/Polygon.js' + +const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] // square: 4 vertices, deletable + +// The feature style is a live function (see wireLiveStroke in EditMode.js — avoids +// Feature#setStyle() on every flip, which would break an in-progress Modify drag), +// not a static Style instance, so tests must invoke it to read the current style. +const currentStyle = (olFeature) => olFeature.getStyle()() + +const setup = (ring = RING) => { + const map = createFakeMap() + const manager = createFakeManager() + manager.store = createFeatureStore() + manager.store.add({ type: 'Feature', id: 'f1', properties: {}, geometry: { type: 'Polygon', coordinates: [ring] } }) + const container = createContainer() + map.getViewport().appendChild(container) // keyboard handler treats focus inside the viewport as map focus + const mode = createEditMode({ + map, + manager, + options: { featureId: 'f1', container, interfaceType: 'mouse', deleteVertexButtonId: 'del-v', snap: null } + }) + liveModes.push(mode) + const olFeature = manager.store.getOL('f1') + const tapAt = (x, y) => { + container.dispatchEvent(domEvent('touchstart', { touches: [{ clientX: x, clientY: y }] })) + container.dispatchEvent(domEvent('touchend', { changedTouches: [{ clientX: x, clientY: y }] })) + } + return { map, manager, container, mode, olFeature, ring: () => olFeature.getGeometry().getCoordinates()[0], tapAt } +} + +const liveModes = [] +afterEach(() => { + liveModes.splice(0).forEach((m) => m?.destroy()) + document.body.innerHTML = '' +}) + +const key = (type, props) => window.dispatchEvent(new KeyboardEvent(type, { cancelable: true, ...props })) + +test('returns null for an unknown feature id', () => { + const { map, manager } = setup() + expect(createEditMode({ map, manager, options: { featureId: 'nope', container: createContainer() } })).toBeNull() +}) + +test('entering edit mode swaps the feature style and reports the initial vertex state', () => { + const { manager, olFeature } = setup() + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: 4 }) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, expect.objectContaining({ id: 'f1' })) +}) + +test('setInvalid swaps between the solid and dashed edit styles', () => { + const { manager, mode, olFeature } = setup() + mode.setInvalid(true) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyleInvalid) + mode.setInvalid(false) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle) +}) + +test('a mid-drag crossing turns the stroke dashed live, and back when it clears', () => { + const { manager, olFeature } = setup() + // Geometry mutation (as during a Modify/touch drag) — no commit yet. + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyleInvalid) + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle) +}) + +test('a validity flip repaints the map without touching the feature — Modify tracks its own drag via feature #change, which setStyle() would trip', () => { + const { map, olFeature } = setup() + const renderCallsBefore = map.render.mock.calls.length + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) + expect(map.render.mock.calls.length).toBeGreaterThan(renderCallsBefore) +}) + +test('validity flips while editing also gate the Done button', () => { + const { manager, olFeature } = setup() + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VALIDITY_CHANGE, + expect.objectContaining({ valid: false, reason: expect.any(String) })) + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VALIDITY_CHANGE, + expect.objectContaining({ valid: true })) +}) + +test('the user callback runs throttled during an edit drag', () => { + jest.useFakeTimers() + const { manager, olFeature } = setup() + manager._geometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + olFeature.getGeometry().setCoordinates([[[0, 0], [110, 0], [100, 100], [0, 100], [0, 0]]]) + olFeature.getGeometry().setCoordinates([[[0, 0], [120, 0], [100, 100], [0, 100], [0, 0]]]) + expect(manager._geometryValidator).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(manager._geometryValidator).toHaveBeenCalledTimes(1) // trailing edge only + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyleInvalid) + jest.useRealTimers() +}) + +test('done() emits the edited feature; cancel() is a no-op', () => { + const { manager, mode } = setup() + mode.done() + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.EDIT_FINISH, expect.objectContaining({ id: 'f1' })) + expect(mode.cancel()).toBeUndefined() +}) + +test('select via pointer, delete the vertex, then undo restores it', () => { + const { container, manager, mode, ring } = setup() + container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 })) + + mode.deleteVertex() + expect(ring()).toHaveLength(4) // one vertex fewer (closed ring keeps closing coord) + expect(manager.undoStack.length).toBe(1) + + mode.undo() + expect(ring()).toHaveLength(5) + expect(ring()[1]).toEqual([100, 0]) + mode.undo() // empty stack — no-op +}) + +test('nudgeSelectedVertex (MoveControl D-pad) moves the selected vertex and is undoable', () => { + const { container, manager, mode, ring } = setup() + container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + mode.nudgeSelectedVertex(1, 0, true) + expect(ring()[1]).not.toEqual([100, 0]) + expect(manager.undoStack.length).toBe(1) + + mode.undo() + expect(ring()[1]).toEqual([100, 0]) +}) + +test('undo re-validates with the inverse change phase (undo of a delete re-inserts)', () => { + jest.useFakeTimers() + const { container, manager, mode } = setup() + container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + mode.deleteVertex() + jest.runAllTimers() + manager.emit.mockClear() + mode.undo() + jest.runAllTimers() + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, expect.objectContaining({ + phase: 'commit-insert', + vertexIndex: 1, + feature: expect.any(Object) + })) + jest.useRealTimers() +}) + +test('a Modify drag pushes a move op derived from the before/after vertices', () => { + const { map, manager, olFeature } = setup() + const interaction = map.interactions[0] + interaction.dispatchEvent({ type: 'modifystart' }) + olFeature.getGeometry().setCoordinates([[[0, 0], [120, 5], [100, 100], [0, 100], [0, 0]]]) + interaction.dispatchEvent({ type: 'modifyend' }) + expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [100, 0] }) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 })) + + interaction.dispatchEvent({ type: 'modifystart' }) + interaction.dispatchEvent({ type: 'modifyend' }) // no coordinate change — nothing pushed + expect(manager.undoStack.length).toBe(0) +}) + +test('deleting needs a selected vertex and respects the minimum ring size', () => { + const { mode, manager } = setup() + mode.deleteVertex() // nothing selected + expect(manager.undoStack.length).toBe(0) + + const triangle = setup([[0, 0], [100, 0], [100, 100], [0, 0]]) + triangle.container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + triangle.mode.deleteVertex() // 3 vertices is the minimum for a ring + expect(triangle.ring()).toHaveLength(4) + expect(triangle.manager.undoStack.length).toBe(0) +}) + +test('keyboard: arrows nudge the selected vertex and commit one undo op on keyup', () => { + const { container, manager, ring } = setup() + container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + key('keydown', { key: 'ArrowRight' }) + key('keyup', { key: 'ArrowRight' }) + expect(ring()[1]).toEqual([105, 0]) + expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [100, 0] }) +}) + +test('keyboard: Space selects the midpoint nearest the crosshair; nudging inserts it as a vertex', () => { + const { manager, ring } = setup() + key('keydown', { key: ' ' }) // nearest handle to center [50,50] is the midpoint [50,0]... all midpoints are at 50 + key('keydown', { key: 'ArrowRight' }) + key('keyup', { key: 'ArrowRight' }) + expect(ring()).toHaveLength(6) + expect(ring()[1]).toEqual([55, 0]) // inserted at the midpoint, nudged one step right + expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [50, 0] }) + expect(manager.undoStack.pop()).toEqual({ type: 'insert_vertex', vertexIndex: 1 }) +}) + +test('touch: dragging the offset target moves the vertex and records one move op', () => { + const { container, manager, mode, tapAt, ring } = setup() + tapAt(100, 0) + mode.setInterfaceType('touch') + // The finger lands on the SVG's inner circle, not the SVG element itself + const grip = container.querySelector('[data-im-draw-touch-target] circle') + grip.dispatchEvent(domEvent('touchstart', { touches: [{ clientX: 100, clientY: 0 }] })) + grip.dispatchEvent(domEvent('touchmove', { touches: [{ clientX: 120, clientY: 10 }] })) + grip.dispatchEvent(domEvent('touchend', { changedTouches: [{ clientX: 120, clientY: 10 }] })) + expect(ring()[1]).toEqual([120, 10]) + expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [100, 0] }) +}) + +test('touch: tapping a midpoint inserts a vertex there and selects it', () => { + const { manager, tapAt, ring } = setup() + tapAt(50, 0) // midpoint between [0,0] and [100,0] + expect(ring()).toHaveLength(6) + expect(ring()[1]).toEqual([50, 0]) + expect(manager.undoStack.pop()).toEqual({ type: 'insert_vertex', vertexIndex: 1 }) +}) + +test('touch: tapping a stale midpoint the geometry can no longer place is a no-op', () => { + const { manager, olFeature, ring, tapAt } = setup() + // Desync state from geometry: swap in a smaller geometry object so the + // selection-state change listener (bound to the previous geometry) never + // fires. state.midpoints still describes the 4-vertex square (4 midpoints), + // while the live geometry is a 3-vertex triangle that has only 3 midpoints. + olFeature.setGeometry(new Polygon([[[0, 0], [100, 0], [100, 100], [0, 0]]])) + tapAt(0, 50) // the stale square's left-edge midpoint (index 3) — the triangle can't place it + expect(manager.undoStack.length).toBe(0) + expect(ring()).toHaveLength(4) // triangle geometry left untouched +}) + +test('undo in touch mode repositions the offset target on the restored vertex', () => { + const { container, manager, mode, tapAt } = setup() + tapAt(100, 0) + key('keydown', { key: 'ArrowRight' }) // keyboard nudge (switches interface to keyboard) + key('keyup', { key: 'ArrowRight' }) + mode.setInterfaceType('touch') + mode.undo() + const target = container.querySelector('[data-im-draw-touch-target]') + expect(target.style.display).toBe('block') + expect(manager.undoStack.length).toBe(0) +}) + +test('touch tap selects a vertex and interface switching shows/hides the touch target', () => { + const { container, manager, mode, tapAt } = setup() + tapAt(100, 0) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 })) + + const target = container.querySelector('[data-im-draw-touch-target]') + mode.setInterfaceType('touch') + expect(target.style.display).toBe('block') + mode.setInterfaceType('touch') // same type — no change + mode.setInterfaceType('mouse') + expect(target.style.display).toBe('none') +}) + +test('a tap on empty map deselects', () => { + const { manager, tapAt } = setup() + tapAt(100, 0) + tapAt(500, 500) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: -1 })) +}) + +test('style changes re-style the feature and handles', () => { + const { manager, olFeature } = setup() + // The real manager swaps manager.styles before emitting (see setMapStyle). + const newStyles = { ...manager.styles, editFeatureStyle: new Style({}) } + manager.styles = newStyles + manager.emit(STYLES_CHANGED_EVENT, newStyles) + expect(currentStyle(olFeature)).toBe(newStyles.editFeatureStyle) +}) + +test('a style change while the shape is invalid keeps the dashed stroke', () => { + const { manager, olFeature } = setup() + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) // dashed + const newStyles = { + ...manager.styles, + editFeatureStyle: new Style({}), + editFeatureStyleInvalid: new Style({}) + } + manager.styles = newStyles + manager.emit(STYLES_CHANGED_EVENT, newStyles) + expect(currentStyle(olFeature)).toBe(newStyles.editFeatureStyleInvalid) +}) + +test('map resize repositions the touch target after the next render — touch with a selection only', () => { + const { map, mode, tapAt } = setup() + map.emit('change:size') // mouse interface — EditMode's own resize hook ignores it + tapAt(100, 0) + mode.setInterfaceType('touch') + map.emit('change:size') + map.emit('postrender') // once-listener fires; must not throw + expect(map.once).toHaveBeenCalledWith('postrender', expect.any(Function)) +}) + +test('destroy removes interactions, layers and listeners, and restores the feature style', () => { + const { map, mode, olFeature } = setup() + mode.destroy() + expect(map.interactions).toHaveLength(0) + expect(map.layers).toHaveLength(0) + expect(olFeature.getStyle()).toBeNull() +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js b/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js new file mode 100644 index 000000000..2c808d800 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js @@ -0,0 +1,55 @@ +import VectorSource from 'ol/source/Vector.js' +import VectorLayer from 'ol/layer/Vector.js' +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' + +// Above the vertex (102) and midpoint (101) handle layers +const ACTIVE_LAYER_Z_INDEX = 103 + +/** + * Overlay layer rendering the currently selected vertex or midpoint handle. + * The vertex/midpoint layers hide their own circle at the selected index and + * this layer draws the highlighted version on top. + * + * @param {import('ol/Map').default} map + * @param {() => object} getStyles - returns the manager's current style set + * (read at render time so style hot-swaps apply without rewiring) + * @returns {{ update: (state) => void, remove: () => void }} + */ +export const createActiveVertexLayer = (map, getStyles) => { + const source = new VectorSource() + const layer = new VectorLayer({ source, zIndex: ACTIVE_LAYER_Z_INDEX }) + map.addLayer(layer) + + const selectedCoordAndStyle = ({ selectedVertexIndex, selectedVertexType, vertices, midpoints }) => { + const styles = getStyles() + if (selectedVertexType === 'vertex') { + return { coord: vertices[selectedVertexIndex], style: styles.selectedVertexStyle } + } + if (selectedVertexType === 'midpoint') { + return { coord: midpoints[selectedVertexIndex - vertices.length], style: styles.selectedMidpointStyle } + } + return { coord: null, style: null } + } + + return { + update (state) { + source.clear() + if (state.selectedVertexIndex < 0) { + return + } + const { coord, style } = selectedCoordAndStyle(state) + if (!coord) { + return + } + const feature = new Feature({ geometry: new Point(coord) }) + feature.setStyle(style) + source.addFeature(feature) + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js b/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js new file mode 100644 index 000000000..b30f8bac7 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js @@ -0,0 +1,53 @@ +import { createActiveVertexLayer } from './activeVertexLayer.js' +import { createFakeMap, createFakeManager } from '../__helpers__/harness.js' + +const setup = () => { + const map = createFakeMap() + const manager = createFakeManager() + const activeLayer = createActiveVertexLayer(map, () => manager.styles) + const source = map.layers[0].getSource() + const stateWith = (overrides) => ({ + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [[0, 0], [10, 0], [10, 10]], + midpoints: [[5, 0], [10, 5], [5, 5]], + ...overrides + }) + return { map, manager, activeLayer, source, stateWith } +} + +test('renders the selected vertex with the selected-vertex style', () => { + const { activeLayer, source, manager, stateWith } = setup() + activeLayer.update(stateWith({ selectedVertexIndex: 1, selectedVertexType: 'vertex' })) + const features = source.getFeatures() + expect(features).toHaveLength(1) + expect(features[0].getGeometry().getCoordinates()).toEqual([10, 0]) + expect(features[0].getStyle()).toBe(manager.styles.selectedVertexStyle) +}) + +test('renders a selected midpoint by its local index with the midpoint style', () => { + const { activeLayer, source, manager, stateWith } = setup() + activeLayer.update(stateWith({ selectedVertexIndex: 4, selectedVertexType: 'midpoint' })) + const features = source.getFeatures() + expect(features[0].getGeometry().getCoordinates()).toEqual([10, 5]) + expect(features[0].getStyle()).toBe(manager.styles.selectedMidpointStyle) +}) + +test('renders nothing without a selection, for an unknown type, or for a missing coordinate', () => { + const { activeLayer, source, stateWith } = setup() + activeLayer.update(stateWith({ selectedVertexIndex: 1, selectedVertexType: 'vertex' })) + activeLayer.update(stateWith({})) + expect(source.getFeatures()).toHaveLength(0) + activeLayer.update(stateWith({ selectedVertexIndex: 0, selectedVertexType: 'segment' })) + expect(source.getFeatures()).toHaveLength(0) + activeLayer.update(stateWith({ selectedVertexIndex: 99, selectedVertexType: 'vertex' })) + expect(source.getFeatures()).toHaveLength(0) +}) + +test('remove clears the source and detaches the layer', () => { + const { map, activeLayer, source, stateWith } = setup() + activeLayer.update(stateWith({ selectedVertexIndex: 0, selectedVertexType: 'vertex' })) + activeLayer.remove() + expect(source.getFeatures()).toHaveLength(0) + expect(map.removeLayer).toHaveBeenCalled() +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js new file mode 100644 index 000000000..7c0bfa647 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js @@ -0,0 +1,157 @@ +import { coordToPixel } from '../utils/olCoords.js' +import { spatialNavigate } from '../../../utils/spatial.js' +import { wireNudge } from './nudge.js' + +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) +const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) + +const selectNearest = (map, getState, setState) => { + const { vertices, midpoints } = getState() + if (!vertices.length) { + return + } + const centerPx = coordToPixel(map, map.getView().getCenter()) + if (!centerPx) { + return + } + const allPixels = [ + ...vertices.map(c => coordToPixel(map, c)), + ...midpoints.map(c => coordToPixel(map, c)) + ].filter(Boolean).map(p => [p.x, p.y]) + const idx = spatialNavigate([centerPx.x, centerPx.y], allPixels, undefined) + setState({ selectedVertexIndex: idx, selectedVertexType: idx < vertices.length ? 'vertex' : 'midpoint' }) +} + +const navigateTo = (direction, map, getState, setState) => { + const { selectedVertexIndex, vertices, midpoints } = getState() + if (!vertices.length) { + return + } + const allCoords = [...vertices, ...midpoints] + const allPixels = allCoords.map(c => coordToPixel(map, c)).filter(Boolean).map(p => [p.x, p.y]) + const startPx = selectedVertexIndex >= 0 + ? allPixels[selectedVertexIndex] + : (() => { + const c = coordToPixel(map, map.getView().getCenter()) + return c ? [c.x, c.y] : null + })() + if (!startPx) { + return + } + const idx = spatialNavigate(startPx, allPixels, direction) + setState({ selectedVertexIndex: idx, selectedVertexType: idx < vertices.length ? 'vertex' : 'midpoint' }) +} + +const isInteractiveElementFocused = (appViewport) => { + const el = document.activeElement + if (!el || el === document.body) { + return false + } + if (appViewport.contains(el)) { + return false + } + return INTERACTIVE_TAGS.has(el.tagName) || el.isContentEditable || el.hasAttribute('tabindex') +} + +const buildKeydownHandler = ({ map, getState, setState, nudge, keyMove, onUndo, onKeyboardActive, isFocused }) => { + const handleArrowKey = (e) => { + if (e.altKey) { + e.preventDefault() + e.stopPropagation() + navigateTo(e.key, map, getState, setState) + } else if (getState().selectedVertexIndex >= 0) { + e.preventDefault() + e.stopPropagation() + nudge(e) + } else { + // No action: arrow with no selection and no alt modifier + } + } + + const handleKey = (e) => { + onKeyboardActive?.() + if (e.key === ' ') { + e.preventDefault() + if (getState().selectedVertexIndex < 0) { + selectNearest(map, getState, setState) + } + } else if (ARROW_KEYS.has(e.key)) { + handleArrowKey(e) + } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + const tag = document.activeElement?.tagName + if (!INTERACTIVE_TAGS.has(tag)) { + e.preventDefault() + e.stopPropagation() + onUndo() + } + } else { + // No action + } + } + + return (e) => { + if (isFocused()) { + return + } + if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) { + e.preventDefault() + keyMove.start = null + keyMove.index = null + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + } else { + handleKey(e) + } + } +} + +const buildKeyupHandler = ({ snap, keyMove, onVertexMoved, onDeleted, isFocused }) => (e) => { + if (isFocused()) { + return + } + if (ARROW_KEYS.has(e.key) && keyMove.start && keyMove.index != null) { + snap?.hideIndicator() + onVertexMoved({ vertexIndex: keyMove.index, previousCoord: keyMove.start }) + keyMove.start = null + keyMove.index = null + } + if (e.key === 'Delete') { + onDeleted() + } +} + +/** + * Keyboard handler for edit mode. + * + * Space — select nearest vertex or midpoint to crosshair (only when nothing selected) + * Alt+Arrow — navigate to next vertex/midpoint in that direction (requires selection) + * Arrow — move selected vertex; if midpoint selected, inserts it as a vertex and moves it + * Shift+Arrow — same but fine nudge (1px vs 5px) + * Delete — delete selected vertex (no-op on midpoints) + * Ctrl/Cmd+Z — undo + * + * Midpoints remain midpoints until moved — navigating to a midpoint (Space/Alt+Arrow) does not + * convert it. Only pressing a plain/Shift arrow converts it. + * + * @param {{ map, getState, setState, snap, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive }} options + * @returns {{ nudgeByDelta: (dx: number, dy: number, isLargeStep: boolean) => void, destroy: () => void }} + */ +export const createKeyboardHandler = (options) => { + const { map, snap, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive } = options + const { nudge, keyMove, nudgeByDelta } = wireNudge({ map, snap, getState, setState, onInserted, onVertexMoved }) + const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport() + const isFocused = () => isInteractiveElementFocused(appViewport) + + const onKeydown = buildKeydownHandler({ map, getState, setState, nudge, keyMove, onUndo, onKeyboardActive, isFocused }) + const onKeyup = buildKeyupHandler({ snap, keyMove, onVertexMoved, onDeleted, isFocused }) + + globalThis.addEventListener('keydown', onKeydown, { capture: true }) + globalThis.addEventListener('keyup', onKeyup, { capture: true }) + + return { + nudgeByDelta, + destroy () { + globalThis.removeEventListener('keydown', onKeydown, { capture: true }) + globalThis.removeEventListener('keyup', onKeyup, { capture: true }) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js new file mode 100644 index 000000000..7d0011e6c --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js @@ -0,0 +1,195 @@ +import { createKeyboardHandler } from './keyboardHandler.js' +import { createFakeMap, polygonFeature } from '../__helpers__/harness.js' + +const RING = [[0, 0], [100, 0], [100, 100], [0, 0]] + +const setup = () => { + const map = createFakeMap({ center: [98, 98] }) // crosshair near vertex [100, 100] + const state = { + olFeature: polygonFeature(RING), + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [[0, 0], [100, 0], [100, 100]], + midpoints: [[50, 0], [100, 50], [50, 50]] + } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const callbacks = { + onVertexMoved: jest.fn(), + onInserted: jest.fn(), + onDeleted: jest.fn(), + onUndo: jest.fn(), + onKeyboardActive: jest.fn() + } + const handler = createKeyboardHandler({ map, getState: () => state, setState, snap: null, ...callbacks }) + liveHandlers.push(handler) + return { map, state, setState, handler, ...callbacks } +} + +const liveHandlers = [] +afterEach(() => { + liveHandlers.splice(0).forEach((h) => h.destroy()) + document.body.innerHTML = '' +}) + +const key = (type, props) => window.dispatchEvent(new KeyboardEvent(type, { cancelable: true, ...props })) + +test('nudgeByDelta (exposed for MoveControl) moves the selected vertex and reports it via onVertexMoved', () => { + const { state, handler, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + handler.nudgeByDelta(1, 0, true) + expect(state.vertices[1]).not.toEqual([100, 0]) + expect(onVertexMoved).toHaveBeenCalledWith(expect.objectContaining({ vertexIndex: 1 })) +}) + +test('Space selects the vertex or midpoint nearest the crosshair, only when nothing is selected', () => { + const { state, setState, onKeyboardActive } = setup() + key('keydown', { key: ' ' }) + expect(state.selectedVertexIndex).toBe(2) // [100, 100] is nearest [98, 98] + expect(state.selectedVertexType).toBe('vertex') + expect(onKeyboardActive).toHaveBeenCalled() + setState.mockClear() + key('keydown', { key: ' ' }) + expect(setState).not.toHaveBeenCalled() +}) + +test('Space selects a midpoint when it is the handle nearest the crosshair', () => { + const map = createFakeMap({ center: [100, 48] }) // crosshair beside midpoint [100, 50] + const state = { + olFeature: polygonFeature(RING), + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [[0, 0], [100, 0], [100, 100]], + midpoints: [[50, 0], [100, 50], [50, 50]] + } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const handler = createKeyboardHandler({ + map, + getState: () => state, + setState, + snap: null, + onVertexMoved: jest.fn(), + onInserted: jest.fn(), + onDeleted: jest.fn(), + onUndo: jest.fn(), + onKeyboardActive: jest.fn() + }) + liveHandlers.push(handler) + key('keydown', { key: ' ' }) + expect(state.selectedVertexIndex).toBe(4) // midpoint [100, 50] + expect(state.selectedVertexType).toBe('midpoint') +}) + +test('Alt+Arrow navigates the selection to the nearest handle in that direction', () => { + const { state } = setup() + Object.assign(state, { selectedVertexIndex: 2, selectedVertexType: 'vertex' }) + key('keydown', { key: 'ArrowUp', altKey: true }) + expect(state.selectedVertexIndex).toBe(4) // midpoint [100, 50] is nearest above [100, 100] + expect(state.selectedVertexType).toBe('midpoint') +}) + +test('Alt+Arrow with nothing selected navigates relative to the crosshair', () => { + const { state } = setup() + key('keydown', { key: 'ArrowUp', altKey: true }) // no selection → start from crosshair [98, 98] + expect(state.selectedVertexIndex).toBe(4) // midpoint [100, 50] above the crosshair + expect(state.selectedVertexType).toBe('midpoint') +}) + +test('Alt+Arrow can land the selection on a vertex', () => { + const { state } = setup() + Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) // midpoint [100, 50] + key('keydown', { key: 'ArrowUp', altKey: true }) + expect(state.selectedVertexIndex).toBe(1) // vertex [100, 0] directly above + expect(state.selectedVertexType).toBe('vertex') +}) + +test('a plain arrow nudges the selected vertex, and keyup commits a single move op', () => { + const { state, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + key('keydown', { key: 'ArrowRight' }) + key('keydown', { key: 'ArrowRight' }) + expect(state.vertices[1]).toEqual([110, 0]) + + key('keyup', { key: 'ArrowRight' }) + expect(onVertexMoved).toHaveBeenCalledWith({ vertexIndex: 1, previousCoord: [100, 0] }) + key('keyup', { key: 'ArrowRight' }) // no pending move — not committed twice + expect(onVertexMoved).toHaveBeenCalledTimes(1) +}) + +test('arrows without a selection do nothing', () => { + const { setState } = setup() + key('keydown', { key: 'ArrowRight' }) + expect(setState).not.toHaveBeenCalled() +}) + +test('Escape clears the selection and any pending nudge', () => { + const { state, setState, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + key('keydown', { key: 'ArrowRight' }) + key('keydown', { key: 'Escape' }) + expect(setState).toHaveBeenLastCalledWith({ selectedVertexIndex: -1, selectedVertexType: null }) + key('keyup', { key: 'ArrowRight' }) + expect(onVertexMoved).not.toHaveBeenCalled() +}) + +test('Delete deletes, ctrl/cmd+z undoes — but not while typing in an input inside the viewport', () => { + const { map, onDeleted, onUndo } = setup() + key('keyup', { key: 'Delete' }) + expect(onDeleted).toHaveBeenCalled() + key('keydown', { key: 'z', ctrlKey: true }) + expect(onUndo).toHaveBeenCalledTimes(1) + + const input = document.createElement('input') + map.getViewport().appendChild(input) + input.focus() + key('keydown', { key: 'z', ctrlKey: true }) + expect(onUndo).toHaveBeenCalledTimes(1) +}) + +test('keys are ignored while an interactive element outside the viewport has focus', () => { + const { onDeleted, onKeyboardActive } = setup() + const button = document.createElement('button') + document.body.appendChild(button) + button.focus() + key('keyup', { key: 'Delete' }) + key('keydown', { key: ' ' }) + expect(onDeleted).not.toHaveBeenCalled() + expect(onKeyboardActive).not.toHaveBeenCalled() +}) + +test('keys are ignored while a tabindex-focusable non-interactive element outside the viewport has focus', () => { + const { onDeleted } = setup() + const div = document.createElement('div') + div.tabIndex = 0 // focusable via tabindex, not one of the interactive tags + document.body.appendChild(div) + div.focus() + key('keyup', { key: 'Delete' }) + expect(onDeleted).not.toHaveBeenCalled() +}) + +test('unhandled keys do nothing', () => { + const { setState } = setup() + key('keydown', { key: 'a' }) + expect(setState).not.toHaveBeenCalled() +}) + +test('destroy removes the window listeners', () => { + const { handler, onDeleted } = setup() + handler.destroy() + key('keyup', { key: 'Delete' }) + expect(onDeleted).not.toHaveBeenCalled() +}) + +test('selection and navigation are no-ops without handles or without projectable pixels', () => { + const { state, setState } = setup() + state.vertices = [] + key('keydown', { key: ' ' }) + key('keydown', { key: 'ArrowUp', altKey: true }) + expect(setState).not.toHaveBeenCalled() + + const { state: s2, setState: set2, map } = setup() + map.getPixelFromCoordinate = () => null // e.g. mid view transition + key('keydown', { key: ' ' }) + key('keydown', { key: 'ArrowUp', altKey: true }) // no selection → start px unprojectable + expect(set2).not.toHaveBeenCalled() + expect(s2.selectedVertexIndex).toBe(-1) +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js b/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js new file mode 100644 index 000000000..34f2358a3 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js @@ -0,0 +1,57 @@ +import VectorSource from 'ol/source/Vector.js' +import VectorLayer from 'ol/layer/Vector.js' +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' +import { getMidpoints } from '../utils/geometryHelpers.js' + +/** + * Manages a dedicated overlay layer for midpoint handles in edit mode. + * Midpoints are always visible (unlike OL Modify's native midpoints which + * only appear when the pointer is near a segment). The selected midpoint is + * rendered by the separate active-selection layer in EditMode (zIndex 103). + */ +export const createMidpointLayer = (map, midpointStyle) => { + let currentStyle = midpointStyle + let selectedIndex = -1 + const source = new VectorSource() + const layer = new VectorLayer({ + source, + style: (feature) => feature.get('midpointIndex') === selectedIndex ? null : [currentStyle], + zIndex: 101 + }) + map.addLayer(layer) + + return { + update (geom) { + source.clear() + const midpoints = getMidpoints(geom) + const features = midpoints.map((coord, i) => { + const f = new Feature({ geometry: new Point(coord) }) + f.set('midpointIndex', i) + return f + }) + source.addFeatures(features) + }, + + setSelected (index) { + selectedIndex = index + source.changed() + }, + + updateStyle (newMidpointStyle) { + currentStyle = newMidpointStyle + source.changed() + }, + + getCoords () { + return source.getFeatures() + .sort((a, b) => a.get('midpointIndex') - b.get('midpointIndex')) + .map(f => f.getGeometry().getCoordinates()) + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js b/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js new file mode 100644 index 000000000..34c60b445 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js @@ -0,0 +1,42 @@ +import Style from 'ol/style/Style.js' +import { createMidpointLayer } from './midpointLayer.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const GEOM = { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 0]]] } + +const setup = () => { + const map = createFakeMap() + const style = new Style({}) + const handles = createMidpointLayer(map, style) + const layer = map.layers[0] + return { map, style, handles, layer, source: layer.getSource() } +} + +test('update renders one handle per segment midpoint, retrievable in index order', () => { + const { handles, source } = setup() + handles.update(GEOM) + expect(source.getFeatures()).toHaveLength(3) + expect(handles.getCoords()).toEqual([[5, 0], [10, 5], [5, 5]]) +}) + +test('the selected midpoint is hidden here and styles hot-swap', () => { + const { handles, layer, style, source } = setup() + handles.update(GEOM) + handles.setSelected(0) + const styleFn = layer.getStyle() + const featureAt = (i) => source.getFeatures().find(f => f.get('midpointIndex') === i) + expect(styleFn(featureAt(0))).toBeNull() + expect(styleFn(featureAt(1))).toEqual([style]) + + const newStyle = new Style({}) + handles.updateStyle(newStyle) + expect(styleFn(featureAt(1))).toEqual([newStyle]) +}) + +test('remove clears and detaches the layer', () => { + const { map, handles, source } = setup() + handles.update(GEOM) + handles.remove() + expect(source.getFeatures()).toHaveLength(0) + expect(map.layers).toHaveLength(0) +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js b/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js new file mode 100644 index 000000000..0c7af489d --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js @@ -0,0 +1,83 @@ +import Modify from 'ol/interaction/Modify.js' +import Collection from 'ol/Collection.js' +import { findNearest, PIXEL_TOLERANCE } from './vertexHitTest.js' + +/** + * Derive the undo operation from the vertex arrays before and after an OL + * Modify drag. A grown array means a midpoint drag inserted a vertex; an + * equal-length array with a changed coordinate means a vertex move. + * + * @param {number[][]} prevCoords - vertices snapshotted at modifystart + * @param {number[][]} newCoords - vertices after modifyend + * @returns {{ type: string, vertexIndex: number, previousCoord?: number[] } | null} + */ +export const deriveModifyOp = (prevCoords, newCoords) => { + if (newCoords.length > prevCoords.length) { + const insertedIdx = newCoords.findIndex((c, i) => c[0] !== prevCoords[i]?.[0]) + return { type: 'insert_vertex', vertexIndex: Math.max(0, insertedIdx) } + } + if (newCoords.length === prevCoords.length) { + const movedIdx = newCoords.findIndex((c, i) => c[0] !== prevCoords[i][0] || c[1] !== prevCoords[i][1]) + if (movedIdx >= 0) { + return { type: 'move_vertex', vertexIndex: movedIdx, previousCoord: prevCoords[movedIdx] } + } + } + return null +} + +// Only activate Modify when clicking on a vertex/midpoint handle, never for the +// touch interface (touchHandler covers touch drags via the SVG offset target) +export const buildModifyCondition = ({ map, getState }) => (mapBrowserEvent) => { + const { interfaceType, vertices, midpoints } = getState() + if (interfaceType === 'touch') { + return false + } + const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent) + return findNearest(map, vertices, midpoints, { x: olPixel[0], y: olPixel[1] }) !== null +} + +/** + * OL Modify interaction wiring for edit mode. + * + * Modify handles pointer vertex dragging and midpoint insertion natively; this + * wrapper restricts it to clicks on a handle (buildModifyCondition) and reports + * each completed drag via `onModifyEnd(prevCoords)` with the vertices + * snapshotted at drag start. + * + * @returns {{ destroy: () => void }} + */ +export const createModifyInteraction = ({ map, olFeature, getState, onModifyEnd }) => { + const condition = buildModifyCondition({ map, getState }) + + const modifyInteraction = new Modify({ + features: new Collection([olFeature]), + style: () => [], // vertex circles rendered by vertexLayer instead + pixelTolerance: PIXEL_TOLERANCE, + condition + }) + map.addInteraction(modifyInteraction) + + let startCoords = null + + modifyInteraction.on('modifystart', () => { + if (getState().interfaceType === 'touch') { + return + } + startCoords = getState().vertices.map(c => [...c]) + }) + + modifyInteraction.on('modifyend', () => { + if (getState().interfaceType === 'touch') { + return + } + const prevCoords = startCoords + startCoords = null + onModifyEnd(prevCoords) + }) + + return { + destroy () { + map.removeInteraction(modifyInteraction) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js b/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js new file mode 100644 index 000000000..7a4f675f1 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js @@ -0,0 +1,80 @@ +import { deriveModifyOp, buildModifyCondition, createModifyInteraction } from './modifyInteraction.js' +import { createFakeMap, polygonFeature } from '../__helpers__/harness.js' + +const RING = [[0, 0], [10, 0], [10, 10], [0, 0]] + +describe('deriveModifyOp', () => { + const prev = [[0, 0], [10, 0], [10, 10]] + + test('a moved vertex becomes a move_vertex op with its previous coordinate', () => { + expect(deriveModifyOp(prev, [[0, 0], [12, 2], [10, 10]])) + .toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [10, 0] }) + }) + + test('a grown array becomes an insert_vertex op at the first differing index', () => { + expect(deriveModifyOp(prev, [[0, 0], [5, 5], [10, 0], [10, 10]])) + .toEqual({ type: 'insert_vertex', vertexIndex: 1 }) + expect(deriveModifyOp(prev, [...prev, [9, 9]])) + .toEqual({ type: 'insert_vertex', vertexIndex: 3 }) + }) + + test('no coordinate change or a shrunk array yields no op', () => { + expect(deriveModifyOp(prev, prev.map(c => [...c]))).toBeNull() + expect(deriveModifyOp(prev, prev.slice(0, 2))).toBeNull() + }) +}) + +describe('buildModifyCondition', () => { + const event = (x, y) => ({ originalEvent: { clientX: x, clientY: y } }) + const conditionFor = (interfaceType) => buildModifyCondition({ + map: createFakeMap(), + getState: () => ({ interfaceType, vertices: [[10, 10]], midpoints: [[50, 50]] }) + }) + + test('activates on a vertex or midpoint handle but not on empty map', () => { + const condition = conditionFor('mouse') + expect(condition(event(11, 11))).toBe(true) + expect(condition(event(51, 49))).toBe(true) + expect(condition(event(200, 200))).toBe(false) + }) + + test('never activates for the touch interface', () => { + expect(conditionFor('touch')(event(11, 11))).toBe(false) + }) +}) + +describe('createModifyInteraction', () => { + const setup = (interfaceType = 'mouse') => { + const map = createFakeMap() + const state = { interfaceType, vertices: [[0, 0], [10, 0]], midpoints: [] } + const onModifyEnd = jest.fn() + const modify = createModifyInteraction({ map, olFeature: polygonFeature(RING), getState: () => state, onModifyEnd }) + return { map, state, onModifyEnd, modify, interaction: map.interactions[0] } + } + + test('reports a completed drag with the vertices snapshotted at drag start', () => { + const { state, onModifyEnd, interaction } = setup() + interaction.dispatchEvent({ type: 'modifystart' }) + state.vertices = [[5, 5], [10, 0]] // drag mutates state via geometry change + interaction.dispatchEvent({ type: 'modifyend' }) + expect(onModifyEnd).toHaveBeenCalledWith([[0, 0], [10, 0]]) + }) + + test('touch drags are ignored entirely', () => { + const { onModifyEnd, interaction } = setup('touch') + interaction.dispatchEvent({ type: 'modifystart' }) + interaction.dispatchEvent({ type: 'modifyend' }) + expect(onModifyEnd).not.toHaveBeenCalled() + }) + + test('vertex handles are rendered by the vertex layer, not by Modify itself', () => { + const { interaction } = setup() + expect(interaction.getOverlay().getStyleFunction()()).toEqual([]) + }) + + test('destroy removes the interaction from the map', () => { + const { map, modify, interaction } = setup() + modify.destroy() + expect(map.removeInteraction).toHaveBeenCalledWith(interaction) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/nudge.js b/plugins/draw/src/adapters/openlayers/edit/nudge.js new file mode 100644 index 000000000..9db9e2786 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/nudge.js @@ -0,0 +1,118 @@ +import { nudgeCoord } from '../utils/olCoords.js' +import { moveVertex, insertAtMidpoint } from './vertexOps.js' +import { KEYBOARD } from '../defaults.js' + +const SNAP_PROGRESS_RATIO = 0.5 + +/** + * Returns snappedCoord, but escapes snap if it prevents sufficient progress in the + * nudge direction. Covers vertex-stuck (snap holds position) and edge-hugging + * (vertex slides along edge). + */ +export const resolveSnappedCoord = (snap, map, current, nudgedCoord, snappedCoord, dx, dy) => { + if (!snap) { + return snappedCoord + } + const nudgeVec = [nudgedCoord[0] - current[0], nudgedCoord[1] - current[1]] + const actualVec = [snappedCoord[0] - current[0], snappedCoord[1] - current[1]] + const nudgeLenSq = nudgeVec[0] ** 2 + nudgeVec[1] ** 2 + const dot = actualVec[0] * nudgeVec[0] + actualVec[1] * nudgeVec[1] + if (nudgeLenSq > 0 && dot / nudgeLenSq < SNAP_PROGRESS_RATIO) { + const escapePx = snap.snapRadius + 1 + return nudgeCoord(map, current, dx === 0 ? 0 : Math.sign(dx) * escapePx, dy === 0 ? 0 : Math.sign(dy) * escapePx) + } + return snappedCoord +} + +/** + * Arrow-key vertex nudging for edit mode. + * + * `keyMove` tracks the coordinate at the start of a nudge sequence so a single + * move_vertex undo op can be pushed on keyup (see keyboardHandler). + * + * @returns {{ nudge: (e: KeyboardEvent) => void, keyMove: { start, index }, nudgeByDelta: (dx: number, dy: number, isLargeStep: boolean) => void }} + */ +export const wireNudge = ({ map, snap, getState, setState, onInserted, onVertexMoved }) => { + const keyMove = { start: null, index: null } + + // Shared core: moves the selected vertex by an already pixel-scaled delta, + // applying snap. Returns the previous coordinate (for undo) and vertex index, + // or null if there's no valid vertex selection to move. + const moveSelectedVertexBy = (dx, dy) => { + const { selectedVertexIndex, vertices, olFeature } = getState() + if (!olFeature || selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) { + return null + } + const current = vertices[selectedVertexIndex] + const nudgedCoord = nudgeCoord(map, current, dx, dy) + const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord + snap?.hideIndicator() + const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy) + moveVertex(olFeature, selectedVertexIndex, newCoord) + setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) + return { previousCoord: current, vertexIndex: selectedVertexIndex } + } + + // Insert the midpoint as a vertex then move it — midpoints stay as midpoints until actually moved. + const nudgeMidpoint = (olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) => { + const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertices.length) + if (!result) { + return + } + onInserted({ insertedIndex: result.insertedIndex }) // pushes insert_vertex undo + syncGeom + const updatedVertices = getState().vertices + const insertedCoord = updatedVertices[result.insertedIndex] + if (!insertedCoord) { + return + } + keyMove.start = [...insertedCoord] + keyMove.index = result.insertedIndex + const nudgedCoord = nudgeCoord(map, insertedCoord, dx, dy) + const movedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord + moveVertex(olFeature, result.insertedIndex, movedCoord) + setState({ + selectedVertexIndex: result.insertedIndex, + selectedVertexType: 'vertex', + vertices: updatedVertices.map((c, i) => i === result.insertedIndex ? movedCoord : c) + }) + } + + const nudge = (e) => { + const { selectedVertexIndex, selectedVertexType, vertices, midpoints, olFeature } = getState() + if (!olFeature) { + return + } + const step = e.shiftKey ? KEYBOARD.nudgeAmount : KEYBOARD.stepAmount + const offsets = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] } + const [dx, dy] = offsets[e.key] + if (selectedVertexType === 'midpoint') { + nudgeMidpoint(olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) + return + } + if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) { + return + } + if (!keyMove.start) { + keyMove.start = [...vertices[selectedVertexIndex]] + keyMove.index = selectedVertexIndex + } + moveSelectedVertexBy(dx, dy) + } + + // Explicit-delta counterpart to nudge(e) — the entry point for MoveControl's + // D-pad (see mapProvider.activeMoveTarget in events.js). Unlike nudge(e)'s + // held-key sequencing (undo pushed on keyup via keyMove, so repeated keydowns + // while held batch into one undo step), each call here is one complete, + // undoable action — a button click has no "held" state to batch. Midpoints + // aren't handled here: MoveControl only claims the D-pad once a real vertex is + // selected (see buildVertexMoveTarget in events.js). + const nudgeByDelta = (dx, dy, isLargeStep) => { + const step = isLargeStep ? KEYBOARD.stepAmount : KEYBOARD.nudgeAmount + const result = moveSelectedVertexBy(dx * step, dy * step) + if (result) { + onVertexMoved({ vertexIndex: result.vertexIndex, previousCoord: result.previousCoord }) + } + } + + return { nudge, keyMove, nudgeByDelta } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/nudge.test.js b/plugins/draw/src/adapters/openlayers/edit/nudge.test.js new file mode 100644 index 000000000..e8bce53fb --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/nudge.test.js @@ -0,0 +1,165 @@ +import { wireNudge, resolveSnappedCoord } from './nudge.js' +import { getCoords } from '../utils/geometryHelpers.js' +import { createFakeMap, polygonFeature } from '../__helpers__/harness.js' + +const RING = [[0, 0], [10, 0], [10, 10], [0, 0]] + +const setup = ({ snap = null } = {}) => { + const map = createFakeMap() + const olFeature = polygonFeature(RING) + const state = { + olFeature, + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [[0, 0], [10, 0], [10, 10]], + midpoints: [[5, 0], [10, 5], [5, 5]] + } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const onInserted = jest.fn(() => { + // EditMode's onInserted runs syncGeom, refreshing vertices from the geometry + state.vertices = getCoords({ type: 'Polygon', coordinates: olFeature.getGeometry().getCoordinates() }) + }) + const onVertexMoved = jest.fn() + const { nudge, keyMove, nudgeByDelta } = wireNudge({ map, snap, getState: () => state, setState, onInserted, onVertexMoved }) + return { olFeature, state, setState, onInserted, onVertexMoved, nudge, keyMove, nudgeByDelta } +} + +const ring = (olFeature) => olFeature.getGeometry().getCoordinates()[0] + +describe('nudging a vertex', () => { + test('a plain arrow moves the selected vertex by the step amount, shift by the fine amount', () => { + const { state, nudge, olFeature } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudge({ key: 'ArrowRight', shiftKey: false }) + expect(ring(olFeature)[1]).toEqual([15, 0]) + nudge({ key: 'ArrowDown', shiftKey: true }) + expect(ring(olFeature)[1]).toEqual([15, 1]) + }) + + test('the start coordinate is captured once for the whole nudge sequence (single undo op)', () => { + const { state, nudge, keyMove } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudge({ key: 'ArrowRight' }) + nudge({ key: 'ArrowRight' }) + expect(keyMove).toEqual({ start: [10, 0], index: 1 }) + }) + + test('does nothing without a feature or a valid selection', () => { + const { state, nudge, setState } = setup() + nudge({ key: 'ArrowRight' }) // nothing selected + state.olFeature = null + nudge({ key: 'ArrowRight' }) + expect(setState).not.toHaveBeenCalled() + }) +}) + +describe('nudgeByDelta (MoveControl D-pad)', () => { + test('moves the selected vertex by an explicit direction, scaled by isLargeStep, and reports the move for undo', () => { + const { state, nudgeByDelta, olFeature, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudgeByDelta(1, 0, true) + expect(ring(olFeature)[1]).toEqual([15, 0]) + expect(onVertexMoved).toHaveBeenCalledWith({ vertexIndex: 1, previousCoord: [10, 0] }) + + onVertexMoved.mockClear() + nudgeByDelta(0, 1, false) + expect(ring(olFeature)[1]).toEqual([15, 1]) + expect(onVertexMoved).toHaveBeenCalledWith({ vertexIndex: 1, previousCoord: [15, 0] }) + }) + + test('each call is its own undo-able action, unlike nudge(e)\'s held-key batching', () => { + const { state, nudgeByDelta, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudgeByDelta(1, 0, true) + nudgeByDelta(1, 0, true) + expect(onVertexMoved).toHaveBeenCalledTimes(2) + }) + + test('does nothing without a feature or a valid vertex selection, and never touches midpoints', () => { + const { state, nudgeByDelta, onVertexMoved } = setup() + nudgeByDelta(1, 0, true) // nothing selected + Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) + nudgeByDelta(1, 0, true) // midpoint selected — MoveControl only claims a real vertex + state.olFeature = null + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudgeByDelta(1, 0, true) + expect(onVertexMoved).not.toHaveBeenCalled() + }) +}) + +describe('nudging a midpoint', () => { + test('inserts the midpoint as a vertex, then moves and selects it', () => { + const { state, nudge, onInserted, olFeature, setState } = setup() + Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) // midpoint [10, 5] + nudge({ key: 'ArrowRight', shiftKey: false }) + expect(onInserted).toHaveBeenCalledWith({ insertedIndex: 2 }) + expect(ring(olFeature)[2]).toEqual([15, 5]) + expect(setState).toHaveBeenLastCalledWith(expect.objectContaining({ + selectedVertexIndex: 2, selectedVertexType: 'vertex' + })) + }) + + test('an invalid midpoint index is a no-op', () => { + const { state, nudge, onInserted } = setup() + Object.assign(state, { selectedVertexIndex: 99, selectedVertexType: 'midpoint' }) + nudge({ key: 'ArrowRight' }) + expect(onInserted).not.toHaveBeenCalled() + }) + + test('snaps the inserted-then-moved coordinate when a snap manager is active', () => { + const snap = { apply: jest.fn((c) => [c[0] + 2, c[1]]), hideIndicator: jest.fn(), snapRadius: 12 } + const { state, nudge, olFeature } = setup({ snap }) + Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) // midpoint [10, 5] + nudge({ key: 'ArrowRight', shiftKey: false }) + expect(snap.apply).toHaveBeenCalled() + expect(ring(olFeature)[2]).toEqual([17, 5]) // inserted [10,5] nudged +5 -> [15,5], snapped +2 -> [17,5] + }) + + test('stops safely if the inserted vertex is not reflected in state', () => { + const { state, nudge, onInserted, setState } = setup() + onInserted.mockImplementation(() => {}) // consumer failed to sync vertices + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'midpoint', vertices: [] }) + nudge({ key: 'ArrowRight' }) // insert succeeds but the inserted coord is missing from state + expect(onInserted).toHaveBeenCalled() + expect(setState).not.toHaveBeenCalled() + }) +}) + +describe('snapping while nudging', () => { + const snapReturning = (impl) => ({ apply: jest.fn(impl), hideIndicator: jest.fn(), snapRadius: 12 }) + + test('the snapped coordinate is used when it makes progress in the nudge direction', () => { + const snap = snapReturning((c) => [c[0] + 1, c[1]]) + const { state, nudge, olFeature } = setup({ snap }) + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudge({ key: 'ArrowRight' }) + expect(ring(olFeature)[1]).toEqual([16, 0]) + expect(snap.hideIndicator).toHaveBeenCalled() + }) + + test('escapes the snap radius when snapping blocks the nudge', () => { + const snap = snapReturning(() => [10, 0]) // snap holds the vertex in place + const { state, nudge, olFeature } = setup({ snap }) + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudge({ key: 'ArrowRight' }) + expect(ring(olFeature)[1]).toEqual([23, 0]) // snapRadius + 1 past the original position + }) +}) + +describe('resolveSnappedCoord', () => { + test('returns the snapped coordinate without a snap manager or when progress is sufficient', () => { + const map = createFakeMap() + expect(resolveSnappedCoord(null, map, [0, 0], [5, 0], [4, 0], 5, 0)).toEqual([4, 0]) + expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [5, 0], [4, 0], 5, 0)).toEqual([4, 0]) + }) + + test('escapes along both axes of a diagonal nudge when blocked', () => { + const map = createFakeMap() + expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [5, 5], [0, 0], 5, 5)).toEqual([13, 13]) + }) + + test('escapes only the vertical axis when the horizontal delta is zero', () => { + const map = createFakeMap() + expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [0, 5], [0, 0], 0, 5)).toEqual([0, 13]) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js b/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js new file mode 100644 index 000000000..e882e668f --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js @@ -0,0 +1,79 @@ +import { findNearest } from './vertexHitTest.js' + +/** + * Pointer/mouse selection handlers for edit mode, plus the global + * delete-vertex button listener. + * + * Interface-type flips here mutate state directly (not via setState) because + * switching device must not touch the current selection. + * + * @returns {{ destroy: () => void }} + */ +export const createPointerHandlers = ({ map, container, getState, setState, touchHandler, deleteVertexButtonId, onDeleteVertex }) => { + const hitAt = (e) => { + const { vertices, midpoints } = getState() + const olPixel = map.getEventPixel(e) + return findNearest(map, vertices, midpoints, { x: olPixel[0], y: olPixel[1] }) + } + + const onPointerdown = (e) => { + const state = getState() + if (e.pointerType === 'touch') { + state.interfaceType = 'touch' + touchHandler.updateTargetPosition() + return + } + state.interfaceType = 'mouse' + const hit = hitAt(e) + if (hit?.type === 'vertex') { + setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) + } + } + + // click fires after OL Modify finishes, so state.vertices reflects any insertions/moves + const onContainerClick = (e) => { + if (getState().interfaceType === 'touch') { + return + } + const hit = hitAt(e) + if (hit?.type === 'vertex') { + setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) + } else if (hit?.type === 'midpoint') { + // modifyend already selected the inserted vertex — nothing to do here + } else { + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + } + } + + // Switch to pointer mode and hide the touch target as soon as the mouse moves + const onPointerMove = (e) => { + const state = getState() + if (e.pointerType !== 'mouse' || state.interfaceType === 'mouse') { + return + } + state.interfaceType = 'mouse' + touchHandler.hide() + } + + const onButtonClick = (e) => { + if (deleteVertexButtonId && e.target.closest(`#${deleteVertexButtonId}`)) { + onDeleteVertex() + } + } + + container.addEventListener('pointerdown', onPointerdown) + container.addEventListener('pointerenter', onPointerMove) + container.addEventListener('pointermove', onPointerMove) + container.addEventListener('click', onContainerClick) + globalThis.addEventListener('click', onButtonClick) + + return { + destroy () { + container.removeEventListener('pointerdown', onPointerdown) + container.removeEventListener('pointerenter', onPointerMove) + container.removeEventListener('pointermove', onPointerMove) + container.removeEventListener('click', onContainerClick) + globalThis.removeEventListener('click', onButtonClick) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js b/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js new file mode 100644 index 000000000..215519826 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js @@ -0,0 +1,78 @@ +import { createPointerHandlers } from './pointerHandlers.js' +import { createFakeMap, createContainer, domEvent } from '../__helpers__/harness.js' + +const setup = (interfaceType = 'mouse') => { + const map = createFakeMap() + const container = createContainer() + const button = document.createElement('button') + button.id = 'delete-vertex' + document.body.appendChild(button) + const state = { interfaceType, vertices: [[10, 10]], midpoints: [[50, 50]] } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const touchHandler = { updateTargetPosition: jest.fn(), hide: jest.fn() } + const onDeleteVertex = jest.fn() + const handlers = createPointerHandlers({ + map, container, getState: () => state, setState, touchHandler, deleteVertexButtonId: 'delete-vertex', onDeleteVertex + }) + return { container, button, state, setState, touchHandler, onDeleteVertex, handlers } +} + +afterEach(() => { document.body.innerHTML = '' }) + +const pointer = (type, pointerType, x = 0, y = 0) => domEvent(type, { pointerType, clientX: x, clientY: y }) + +test('touch pointerdown switches to touch interface and repositions the touch target', () => { + const { container, state, touchHandler, setState } = setup() + container.dispatchEvent(pointer('pointerdown', 'touch')) + expect(state.interfaceType).toBe('touch') + expect(touchHandler.updateTargetPosition).toHaveBeenCalled() + expect(setState).not.toHaveBeenCalled() // device switch must not touch the selection +}) + +test('mouse pointerdown selects a vertex under the pointer, but nothing elsewhere', () => { + const { container, state, setState } = setup('touch') + container.dispatchEvent(pointer('pointerdown', 'mouse', 11, 11)) + expect(state.interfaceType).toBe('mouse') + expect(setState).toHaveBeenCalledWith({ selectedVertexIndex: 0, selectedVertexType: 'vertex' }) + setState.mockClear() + container.dispatchEvent(pointer('pointerdown', 'mouse', 200, 200)) + expect(setState).not.toHaveBeenCalled() +}) + +test('click selects a vertex, deselects on empty map, leaves midpoints to Modify, and ignores touch', () => { + const { container, state, setState } = setup() + container.dispatchEvent(pointer('click', 'mouse', 11, 11)) + expect(setState).toHaveBeenCalledWith({ selectedVertexIndex: 0, selectedVertexType: 'vertex' }) + container.dispatchEvent(pointer('click', 'mouse', 51, 49)) + expect(setState).toHaveBeenCalledTimes(1) // midpoint click: modifyend already handled selection + container.dispatchEvent(pointer('click', 'mouse', 200, 200)) + expect(setState).toHaveBeenLastCalledWith({ selectedVertexIndex: -1, selectedVertexType: null }) + + state.interfaceType = 'touch' + setState.mockClear() + container.dispatchEvent(pointer('click', 'mouse', 11, 11)) + expect(setState).not.toHaveBeenCalled() +}) + +test('mouse movement switches the interface back to mouse and hides the touch target once', () => { + const { container, state, touchHandler } = setup('touch') + container.dispatchEvent(pointer('pointermove', 'pen')) + expect(state.interfaceType).toBe('touch') + container.dispatchEvent(pointer('pointermove', 'mouse')) + expect(state.interfaceType).toBe('mouse') + container.dispatchEvent(pointer('pointermove', 'mouse')) + expect(touchHandler.hide).toHaveBeenCalledTimes(1) +}) + +test('the delete-vertex button triggers deletion; other clicks and destroy() do not', () => { + const { button, container, onDeleteVertex, handlers } = setup() + button.dispatchEvent(domEvent('click', {})) + expect(onDeleteVertex).toHaveBeenCalledTimes(1) + document.body.dispatchEvent(domEvent('click', {})) + expect(onDeleteVertex).toHaveBeenCalledTimes(1) + + handlers.destroy() + button.dispatchEvent(domEvent('click', {})) + container.dispatchEvent(pointer('pointerdown', 'touch')) + expect(onDeleteVertex).toHaveBeenCalledTimes(1) +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/selectionState.js b/plugins/draw/src/adapters/openlayers/edit/selectionState.js new file mode 100644 index 000000000..53d1a10fa --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/selectionState.js @@ -0,0 +1,116 @@ +import { getCoords, getMidpoints } from '../utils/geometryHelpers.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' + +// Deferred commit-level geometrychange emitter (feature + change phase + vertex index) +// consumed by the validation layer. Deferred a tick so that a rejection's revert runs +// after the current mutation's undo bookkeeping has settled. +const createGeometryValidationEmitter = (manager, store, olFeature) => (phase, vertexIndex) => { + if (!phase) { return } + setTimeout(() => { + manager.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: store.toGeoJSON(olFeature), phase, vertexIndex }) + }, 0) +} + +// Reflect the current selection onto the handle layers and emit VERTEX_SELECTION. +const applySelectionChange = (state, { vertexLayer, midpointLayer, activeLayer, manager, hooks }) => { + vertexLayer.setSelected(state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1) + midpointLayer.setSelected( + state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1 + ) + if (state.selectedVertexIndex < 0) { + hooks.onDeselect?.() + } + activeLayer.update(state) + manager.emit(ADAPTER_EVENTS.VERTEX_SELECTION, { + index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, + numVertices: state.vertices.length + }) +} + +/** + * Mutable edit-mode state shared by the Modify interaction, pointer, touch and + * keyboard handlers, plus the setState/sync helpers that keep the handle + * layers, the active-selection overlay and the adapter events in step with it. + * + * Hooks (`onDeselect`, `onUpdate`) are bound late via `setHooks` because the + * touch handler both consumes this state and needs to react to its changes. + * + * @returns {{ state, getState, setState, syncGeom, updateLayersFromGeom, setHooks, destroy }} + */ +export const createSelectionState = ({ map, manager, store, olFeature, interfaceType, layers }) => { + const { vertexLayer, midpointLayer, activeLayer } = layers + + const state = { + olFeature, + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [], + midpoints: [], + interfaceType: interfaceType ?? 'mouse' + } + + const hooks = { onDeselect: null, onUpdate: null } + const setHooks = ({ onDeselect, onUpdate }) => Object.assign(hooks, { onDeselect, onUpdate }) + + const plainGeom = () => { + const geom = olFeature.getGeometry() + return { type: geom.getType(), coordinates: geom.getCoordinates() } + } + + const applySelectionChangeLocal = () => applySelectionChange(state, { vertexLayer, midpointLayer, activeLayer, manager, hooks }) + + const applyVertexChange = () => { + const geom = plainGeom() + midpointLayer.update(geom) + vertexLayer.update(geom) + state.midpoints = midpointLayer.getCoords() + activeLayer.update(state) + hooks.onUpdate?.() + map.render() + } + + const setState = (updates) => { + Object.assign(state, updates) + if (updates.selectedVertexIndex !== undefined) { + applySelectionChangeLocal() + } + if (updates.vertices !== undefined) { + applyVertexChange() + } + } + + // Lightweight per-frame update during drag — refreshes layers without emitting events + const updateLayersFromGeom = () => { + const geom = plainGeom() + state.vertices = getCoords(geom) + state.midpoints = getMidpoints(geom) + midpointLayer.update(geom) + vertexLayer.update(geom) + activeLayer.update(state) + } + + const syncGeom = () => { + updateLayersFromGeom() + manager.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: state.vertices.length }) + manager.emit(ADAPTER_EVENTS.UPDATE, store.toGeoJSON(olFeature)) + } + + const emitGeometryValidation = createGeometryValidationEmitter(manager, store, olFeature) + + // Keep overlay layers in sync on every geometry change (e.g. during pointer drag) + const onGeometryChange = () => updateLayersFromGeom() + olFeature.getGeometry().on('change', onGeometryChange) + + return { + state, + getState: () => state, + setState, + syncGeom, + emitGeometryValidation, + updateLayersFromGeom, + setHooks, + destroy () { + olFeature.getGeometry().un('change', onGeometryChange) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js b/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js new file mode 100644 index 000000000..be85b8fea --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js @@ -0,0 +1,109 @@ +import { createSelectionState } from './selectionState.js' +import { createFakeManager, polygonFeature } from '../__helpers__/harness.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' + +const RING = [[0, 0], [10, 0], [10, 10], [0, 0]] + +const setup = (interfaceType) => { + const manager = createFakeManager() + const olFeature = polygonFeature(RING) + const layers = { + vertexLayer: { update: jest.fn(), setSelected: jest.fn() }, + midpointLayer: { update: jest.fn(), setSelected: jest.fn(), getCoords: jest.fn(() => [[5, 0], [10, 5], [5, 5]]) }, + activeLayer: { update: jest.fn() } + } + const map = { render: jest.fn() } + const store = { toGeoJSON: jest.fn(() => ({ id: 'f1' })) } + const selection = createSelectionState({ map, manager, store, olFeature, interfaceType, layers }) + return { manager, olFeature, layers, map, store, selection } +} + +test('defaults to mouse interface when none is given', () => { + expect(setup().selection.state.interfaceType).toBe('mouse') + expect(setup('touch').selection.state.interfaceType).toBe('touch') +}) + +test('selecting a vertex highlights it, clears midpoint selection and reports the selection', () => { + const { selection, layers, manager } = setup() + selection.state.vertices = [[0, 0], [10, 0], [10, 10]] + selection.setState({ selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + expect(layers.vertexLayer.setSelected).toHaveBeenCalledWith(1) + expect(layers.midpointLayer.setSelected).toHaveBeenCalledWith(-1) + expect(layers.activeLayer.update).toHaveBeenCalledWith(selection.state) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, { index: 1, numVertices: 3 }) +}) + +test('selecting a midpoint highlights it by local index and reports index -1', () => { + const { selection, layers, manager } = setup() + selection.state.vertices = [[0, 0], [10, 0], [10, 10]] + selection.setState({ selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) + expect(layers.midpointLayer.setSelected).toHaveBeenCalledWith(1) + expect(layers.vertexLayer.setSelected).toHaveBeenCalledWith(-1) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, { index: -1, numVertices: 3 }) +}) + +test('deselecting fires the onDeselect hook', () => { + const { selection } = setup() + const onDeselect = jest.fn() + selection.setHooks({ onDeselect }) + selection.setState({ selectedVertexIndex: -1, selectedVertexType: null }) + expect(onDeselect).toHaveBeenCalled() +}) + +test('a vertices update refreshes the handle layers, midpoints and the map', () => { + const { selection, layers, map } = setup() + const onUpdate = jest.fn() + selection.setHooks({ onUpdate }) + selection.setState({ vertices: [[0, 0], [10, 0], [10, 10]] }) + expect(layers.vertexLayer.update).toHaveBeenCalledWith({ type: 'Polygon', coordinates: [RING] }) + expect(selection.state.midpoints).toEqual([[5, 0], [10, 5], [5, 5]]) + expect(onUpdate).toHaveBeenCalled() + expect(map.render).toHaveBeenCalled() +}) + +test('syncGeom derives state from the geometry and emits vertexchange + update', () => { + const { selection, manager, store } = setup() + selection.syncGeom() + expect(selection.state.vertices).toEqual([[0, 0], [10, 0], [10, 10]]) + expect(selection.state.midpoints).toHaveLength(3) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: 3 }) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, store.toGeoJSON()) +}) + +test('emitGeometryValidation emits a deferred commit-level geometrychange with the change phase', () => { + jest.useFakeTimers() + const { selection, manager, store } = setup() + manager.emit.mockClear() + + selection.emitGeometryValidation('commit-move', 2) + // Deferred a tick to avoid re-entrancy — nothing emitted synchronously. + expect(manager.emit).not.toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, expect.anything()) + + jest.runAllTimers() + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, { + feature: store.toGeoJSON(), + phase: 'commit-move', + vertexIndex: 2 + }) + jest.useRealTimers() +}) + +test('emitGeometryValidation is a no-op without a change phase', () => { + jest.useFakeTimers() + const { selection, manager } = setup() + manager.emit.mockClear() + selection.emitGeometryValidation(undefined, 0) + jest.runAllTimers() + expect(manager.emit).not.toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, expect.anything()) + jest.useRealTimers() +}) + +test('geometry changes refresh the layers until destroy unbinds the listener', () => { + const { selection, olFeature, layers } = setup() + olFeature.getGeometry().setCoordinates([[[0, 0], [20, 0], [20, 20], [0, 0]]]) + expect(selection.state.vertices).toEqual([[0, 0], [20, 0], [20, 20]]) + const calls = layers.vertexLayer.update.mock.calls.length + selection.destroy() + olFeature.getGeometry().setCoordinates([[[0, 0], [30, 0], [30, 30], [0, 0]]]) + expect(layers.vertexLayer.update.mock.calls.length).toBe(calls) +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/touchHandler.js b/plugins/draw/src/adapters/openlayers/edit/touchHandler.js new file mode 100644 index 000000000..d362dd80a --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/touchHandler.js @@ -0,0 +1,184 @@ +import { coordToPixel, pixelToCoord } from '../utils/olCoords.js' +import { createTouchTarget, applyTouchTargetColors, showTouchTarget, hideTouchTarget, isOnTouchTarget } from '../utils/touchTarget.js' +import { moveVertex } from './vertexOps.js' +import { findNearest } from './vertexHitTest.js' + +const TAP_MOVE_THRESHOLD = 10 +const TAP_TIME_THRESHOLD = 400 +const TOUCH_TOLERANCE = 24 + +const handleTouchStart = (e, { map, targetEl, cssToOl, getState, drag }) => { + const touch = e.touches[0] + const onTarget = isOnTouchTarget(e.target) + drag.tapStart = { x: touch.clientX, y: touch.clientY, time: Date.now(), onTarget } + if (!onTarget) { + return + } + const { selectedVertexIndex, vertices } = getState() + const vertex = vertices[selectedVertexIndex] + if (!vertex) { + return + } + const tOl = map.getEventPixel({ clientX: touch.clientX, clientY: touch.clientY }) + const vertexPx = coordToPixel(map, vertex) + const style = getComputedStyle(targetEl) + const svgOlPx = cssToOl({ x: Number.parseFloat(style.left), y: Number.parseFloat(style.top) }) + drag.dragStartCoord = [...vertex] + drag.dragStartIndex = selectedVertexIndex + drag.vertexTouchDelta = { x: tOl[0] - vertexPx.x, y: tOl[1] - vertexPx.y } + drag.targetTouchDelta = { x: tOl[0] - svgOlPx.x, y: tOl[1] - svgOlPx.y } + e.preventDefault() +} + +const handleTouchMove = (e, { map, targetEl, olToCSS, getState, setState, snap, drag }) => { + if (!isOnTouchTarget(e.target) || drag.dragStartIndex == null) { + return + } + e.preventDefault() + const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }) + const rawCoord = pixelToCoord(map, { x: tOl[0] - drag.vertexTouchDelta.x, y: tOl[1] - drag.vertexTouchDelta.y }) + const newCoord = snap ? snap.apply(rawCoord) : rawCoord + snap?.hideIndicator() + const { olFeature, vertices } = getState() + if (!olFeature) { + return + } + moveVertex(olFeature, drag.dragStartIndex, newCoord) + setState({ vertices: vertices.map((c, i) => i === drag.dragStartIndex ? newCoord : c) }) + showTouchTarget(targetEl, olToCSS({ x: tOl[0] - drag.targetTouchDelta.x, y: tOl[1] - drag.targetTouchDelta.y })) +} + +const handleTap = (e, { map, getState, onTap, drag }) => { + if (!drag.tapStart || drag.tapStart.onTarget || e.changedTouches.length === 0) { + return + } + const t = e.changedTouches[0] + const dt = Date.now() - drag.tapStart.time + if (Math.hypot(t.clientX - drag.tapStart.x, t.clientY - drag.tapStart.y) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { + const tOl = map.getEventPixel({ clientX: t.clientX, clientY: t.clientY }) + const tapState = getState() + onTap?.(findNearest(map, tapState.vertices, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE)) + e.preventDefault() + } +} + +const handleTouchEnd = (e, ctx) => { + const { getState, onVertexMoved, snap, drag } = ctx + if (drag.dragStartIndex == null) { + handleTap(e, ctx) + drag.tapStart = null + return + } + drag.tapStart = null + const { vertices } = getState() + if (vertices[drag.dragStartIndex] && drag.dragStartCoord) { + onVertexMoved({ vertexIndex: drag.dragStartIndex, previousCoord: drag.dragStartCoord }) + } + snap?.hideIndicator() + drag.dragStartCoord = null; drag.dragStartIndex = null; drag.vertexTouchDelta = null; drag.targetTouchDelta = null + e.preventDefault() +} + +const wireTouchEvents = (deps) => { + const { container } = deps + const drag = { + dragStartCoord: null, + dragStartIndex: null, + vertexTouchDelta: null, + targetTouchDelta: null, + tapStart: null + } + const ctx = { ...deps, drag } + + const onTouchstart = (e) => handleTouchStart(e, ctx) + const onTouchmove = (e) => handleTouchMove(e, ctx) + const onTouchend = (e) => handleTouchEnd(e, ctx) + + container.addEventListener('touchstart', onTouchstart, { passive: false }) + container.addEventListener('touchmove', onTouchmove, { passive: false }) + container.addEventListener('touchend', onTouchend, { passive: false }) + + return { + isDragging: () => drag.dragStartIndex != null, + destroy () { + container.removeEventListener('touchstart', onTouchstart) + container.removeEventListener('touchmove', onTouchmove) + container.removeEventListener('touchend', onTouchend) + } + } +} + +/** + * Touch vertex drag handler for edit mode. + * Shows an SVG offset target below the finger so the vertex can be repositioned + * without finger occlusion. Tap on a vertex or midpoint selects it via onTap. + * + * @param {{ map, container, getState, setState, onVertexMoved, onTap, colors }} options + * @returns {{ updateTargetPosition, updateColors, hide, destroy }} + */ +export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap, colors, snap }) => { + const targetEl = createTouchTarget(container) + applyTouchTargetColors(targetEl, colors) + + // OL pixel space is relative to ol-viewport at its pre-scale CSS size. + // Container CSS space is larger when a CSS transform scales up ol-viewport + // (e.g. scale(1.5) at medium map size makes OL pixels 1.5× smaller than CSS pixels). + const cssTx = { scale: 1, ox: 0, oy: 0 } + const olToCSS = (p) => ({ x: p.x * cssTx.scale + cssTx.ox, y: p.y * cssTx.scale + cssTx.oy }) + const cssToOl = (p) => ({ x: (p.x - cssTx.ox) / cssTx.scale, y: (p.y - cssTx.oy) / cssTx.scale }) + + const syncCssTx = () => { + const vpEl = map.getViewport() + const vpRect = vpEl.getBoundingClientRect() + const cRect = container.getBoundingClientRect() + const vpScale = vpEl.offsetWidth > 0 ? vpRect.width / vpEl.offsetWidth : 1 + const cScale = container.offsetWidth > 0 ? cRect.width / container.offsetWidth : 1 + Object.assign(cssTx, { + scale: vpScale / cScale, + ox: (vpRect.left - cRect.left) / cScale, + oy: (vpRect.top - cRect.top) / cScale + }) + } + + const touchEvents = wireTouchEvents({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap }) + + const updateTargetPosition = () => { + const { selectedVertexIndex, vertices, interfaceType } = getState() + if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex] || interfaceType !== 'touch') { + hideTouchTarget(targetEl) + return + } + const px = coordToPixel(map, vertices[selectedVertexIndex]) + if (!px) { + hideTouchTarget(targetEl) + return + } + showTouchTarget(targetEl, olToCSS(px)) + } + + // Reposition on every render — keeps target anchored during pinch-zoom and pan. + // Skipped during drag since touchmove handles position directly. + const onPostrender = () => { + const { selectedVertexIndex, interfaceType } = getState() + if (selectedVertexIndex >= 0 && !touchEvents.isDragging() && interfaceType === 'touch') { + updateTargetPosition() + } + } + map.on('postrender', onPostrender) + + const onSizeChange = () => { syncCssTx(); map.once('postrender', updateTargetPosition) } + map.on('change:size', onSizeChange) + syncCssTx() + + return { + updateTargetPosition, + updateColors (newColors) { applyTouchTargetColors(targetEl, newColors) }, + hide () { hideTouchTarget(targetEl) }, + destroy () { + map.un('change:size', onSizeChange) + map.un('postrender', onPostrender) + touchEvents.destroy() + hideTouchTarget(targetEl) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js b/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js new file mode 100644 index 000000000..c5d61b9fa --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js @@ -0,0 +1,133 @@ +import { createTouchHandler } from './touchHandler.js' +import { createFakeMap, createContainer, polygonFeature, domEvent } from '../__helpers__/harness.js' + +const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] + +// Guard-path tests only — the happy touch-drag and tap flows are covered through EditMode +const setup = (stateOverrides = {}, { snap = null } = {}) => { + const map = createFakeMap() + const container = createContainer() + const state = { + olFeature: polygonFeature(RING), + selectedVertexIndex: 1, + selectedVertexType: 'vertex', + vertices: [[0, 0], [100, 0], [100, 100], [0, 100]], + midpoints: [], + interfaceType: 'touch', + ...stateOverrides + } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const onVertexMoved = jest.fn() + const onTap = jest.fn() + const handler = createTouchHandler({ map, container, getState: () => state, setState, onVertexMoved, onTap, colors: {}, snap }) + const grip = container.querySelector('[data-im-draw-touch-target] circle') + const touch = (type, x, y) => grip.dispatchEvent(domEvent(type, { + touches: [{ clientX: x, clientY: y }], + changedTouches: [{ clientX: x, clientY: y }] + })) + return { map, container, state, handler, onVertexMoved, onTap, touch, grip } +} + +afterEach(() => { document.body.innerHTML = '' }) + +test('touching the target with nothing selected starts neither a drag nor a tap', () => { + const { state, handler, touch, onVertexMoved, onTap } = setup({ selectedVertexIndex: -1 }) + touch('touchstart', 100, 0) + touch('touchmove', 120, 10) + touch('touchend', 120, 10) + expect(onVertexMoved).not.toHaveBeenCalled() + expect(onTap).not.toHaveBeenCalled() // target touches never count as taps + expect(state.olFeature.getGeometry().getCoordinates()[0]).toEqual(RING) + handler.destroy() +}) + +test('a stray touchmove without an active drag is ignored', () => { + const { state, handler, touch } = setup() + touch('touchmove', 120, 10) + expect(state.olFeature.getGeometry().getCoordinates()[0]).toEqual(RING) + handler.destroy() +}) + +test('a drag halts safely if the feature disappears mid-gesture', () => { + const { state, handler, touch } = setup() + handler.updateTargetPosition() // target visible over vertex 1 + touch('touchstart', 100, 0) + const feature = state.olFeature + state.olFeature = null + touch('touchmove', 120, 10) + expect(feature.getGeometry().getCoordinates()[0]).toEqual(RING) + handler.destroy() +}) + +test('the offset target hides when its vertex cannot be projected', () => { + const { map, container, handler } = setup() + handler.updateTargetPosition() + const target = container.querySelector('[data-im-draw-touch-target]') + expect(target.style.display).toBe('block') + map.getPixelFromCoordinate = () => null // e.g. mid view transition + handler.updateTargetPosition() + expect(target.style.display).toBe('none') + handler.destroy() +}) + +test('a drag applies snapping to the moved coordinate when a snap manager is active', () => { + const snap = { apply: jest.fn((c) => [c[0] + 5, c[1]]), hideIndicator: jest.fn() } + const { state, handler, touch } = setup({}, { snap }) + handler.updateTargetPosition() + touch('touchstart', 100, 0) + touch('touchmove', 120, 10) + expect(snap.apply).toHaveBeenCalled() + expect(snap.hideIndicator).toHaveBeenCalled() + expect(state.vertices[1]).toEqual([125, 10]) // dragged to [120,10] then snapped +5 on x + handler.destroy() +}) + +test('a touch that drifts too far before lifting is not treated as a tap', () => { + const { container, handler, onTap } = setup({ selectedVertexIndex: -1 }) + container.dispatchEvent(domEvent('touchstart', { touches: [{ clientX: 10, clientY: 10 }] })) + container.dispatchEvent(domEvent('touchend', { changedTouches: [{ clientX: 60, clientY: 60 }] })) // moved past the tap threshold + expect(onTap).not.toHaveBeenCalled() + handler.destroy() +}) + +test('a drag that ends after its vertex has vanished commits nothing', () => { + const { state, handler, touch, onVertexMoved } = setup() + handler.updateTargetPosition() + touch('touchstart', 100, 0) + touch('touchmove', 120, 10) + state.vertices = [] // the vertex is gone by the time the finger lifts + touch('touchend', 120, 10) + expect(onVertexMoved).not.toHaveBeenCalled() + handler.destroy() +}) + +test('a postrender with nothing selected leaves the target hidden', () => { + const { map, container, handler } = setup({ selectedVertexIndex: -1 }) + const target = container.querySelector('[data-im-draw-touch-target]') + map.emit('postrender') + expect(target.style.display).toBe('none') + handler.destroy() +}) + +test('the CSS transform reflects a scaled viewport when element widths are measurable', () => { + const map = createFakeMap() + const container = createContainer() + const vp = map.getViewport() + Object.defineProperty(vp, 'offsetWidth', { value: 200, configurable: true }) + Object.defineProperty(container, 'offsetWidth', { value: 100, configurable: true }) + vp.getBoundingClientRect = () => ({ width: 300, height: 300, left: 0, top: 0 }) + container.getBoundingClientRect = () => ({ width: 100, height: 100, left: 0, top: 0 }) + const state = { + olFeature: polygonFeature(RING), + selectedVertexIndex: 1, + selectedVertexType: 'vertex', + vertices: [[0, 0], [100, 0], [100, 100], [0, 100]], + midpoints: [], + interfaceType: 'touch' + } + const handler = createTouchHandler({ map, container, getState: () => state, setState: jest.fn(), onVertexMoved: jest.fn(), onTap: jest.fn(), colors: {}, snap: null }) + handler.updateTargetPosition() // vertex [100,0] -> pixel {100,0} -> scale 1.5 -> css left 150px + const target = container.querySelector('[data-im-draw-touch-target]') + expect(target.style.left).toBe('150px') + handler.destroy() +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/undoOps.js b/plugins/draw/src/adapters/openlayers/edit/undoOps.js new file mode 100644 index 000000000..a9e59d0c1 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/undoOps.js @@ -0,0 +1,94 @@ +import { + getRingSegments, + getSegmentForIndex, + getModifiableCoords +} from '../utils/geometryHelpers.js' + +/** + * Undo handlers for edit mode. + * Each operation receives the OL feature and the saved op payload, + * mutates the geometry, and returns the vertex index to re-select (or -1). + */ + +export const undoMoveVertex = (olFeature, op) => { + const { vertexIndex, previousCoord } = op + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + const result = getSegmentForIndex(segments, vertexIndex) + if (!result) { + return -1 + } + + const ring = getModifiableCoords(geojsonGeom, result.segment.path) + ring[result.localIdx] = [...previousCoord] + if (result.segment.closed && result.localIdx === 0) { + ring[ring.length - 1] = [...previousCoord] + } + geom.setCoordinates(geojsonGeom.coordinates) + return vertexIndex +} + +export const undoInsertVertex = (olFeature, op) => { + const { vertexIndex } = op + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + const result = getSegmentForIndex(segments, vertexIndex) + if (result) { + const ring = getModifiableCoords(geojsonGeom, result.segment.path) + ring.splice(result.localIdx, 1) + if (result.segment.closed) { + ring[ring.length - 1] = [...ring[0]] + } + geom.setCoordinates(geojsonGeom.coordinates) + } + // Undoing an insert removes the vertex, so there is never one to re-select + return -1 +} + +export const undoDeleteVertex = (olFeature, op) => { + const { vertexIndex, deletedCoord } = op + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + + let result = getSegmentForIndex(segments, vertexIndex) + // Vertex might be at a segment boundary after deletion shifted indices + if (!result) { + for (const seg of segments) { + if (vertexIndex === seg.start + seg.length) { + result = { segment: seg, localIdx: seg.length } + break + } + } + } + if (!result) { + return -1 + } + + const ring = getModifiableCoords(geojsonGeom, result.segment.path) + ring.splice(result.localIdx, 0, [...deletedCoord]) + if (result.segment.closed) { + ring[ring.length - 1] = [...ring[0]] + } + geom.setCoordinates(geojsonGeom.coordinates) + return vertexIndex +} + +/** + * Dispatch the correct undo handler based on operation type. + * @returns {number} vertex index to re-select after undo, or -1 for none + */ +export const applyUndo = (olFeature, op) => { + switch (op.type) { + case 'move_vertex': + return undoMoveVertex(olFeature, op) + case 'insert_vertex': + return undoInsertVertex(olFeature, op) + case 'delete_vertex': + return undoDeleteVertex(olFeature, op) + default: + return -1 + } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js b/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js new file mode 100644 index 000000000..9f95afae9 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js @@ -0,0 +1,64 @@ +import { applyUndo, undoMoveVertex, undoInsertVertex, undoDeleteVertex } from './undoOps.js' +import { polygonFeature, lineFeature } from '../__helpers__/harness.js' + +const SQUARE = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] +const ring = (feature) => feature.getGeometry().getCoordinates()[0] + +describe('undoMoveVertex', () => { + test('restores the previous coordinate, syncing the closing coord for ring vertex 0', () => { + const feature = polygonFeature(SQUARE) + expect(undoMoveVertex(feature, { vertexIndex: 0, previousCoord: [5, 5] })).toBe(0) + expect(ring(feature)[0]).toEqual([5, 5]) + expect(ring(feature).at(-1)).toEqual([5, 5]) + }) + + test('returns -1 for an index outside the geometry', () => { + expect(undoMoveVertex(polygonFeature(SQUARE), { vertexIndex: 99, previousCoord: [0, 0] })).toBe(-1) + }) +}) + +describe('undoInsertVertex', () => { + test('removes the inserted vertex and keeps the ring closed', () => { + const feature = polygonFeature([[0, 0], [50, 0], [100, 0], [100, 100], [0, 100], [0, 0]]) + expect(undoInsertVertex(feature, { vertexIndex: 1 })).toBe(-1) + expect(ring(feature)).toEqual(SQUARE) + }) + + test('returns -1 for an index outside the geometry', () => { + expect(undoInsertVertex(polygonFeature(SQUARE), { vertexIndex: 99 })).toBe(-1) + }) + + test('removes the inserted vertex from an open line without a closing-coordinate sync', () => { + const feature = lineFeature([[0, 0], [50, 0], [100, 0]]) + expect(undoInsertVertex(feature, { vertexIndex: 1 })).toBe(-1) + expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [100, 0]]) + }) +}) + +describe('undoDeleteVertex', () => { + test('re-inserts the deleted coordinate and re-selects it', () => { + const feature = polygonFeature([[0, 0], [100, 0], [0, 100], [0, 0]]) // square minus [100,100] + expect(undoDeleteVertex(feature, { vertexIndex: 2, deletedCoord: [100, 100] })).toBe(2) + expect(ring(feature)).toEqual([[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]) + }) + + test('re-inserts at a segment boundary (the deleted vertex was the last of its part)', () => { + const feature = lineFeature([[0, 0], [10, 0]]) // [20,0] was deleted from the end + expect(undoDeleteVertex(feature, { vertexIndex: 2, deletedCoord: [20, 0] })).toBe(2) + expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [10, 0], [20, 0]]) + }) + + test('returns -1 when the index fits no segment at all', () => { + expect(undoDeleteVertex(lineFeature([[0, 0], [10, 0]]), { vertexIndex: 99, deletedCoord: [0, 0] })).toBe(-1) + }) +}) + +describe('applyUndo dispatch', () => { + test('routes each operation type and ignores unknown ones', () => { + const feature = polygonFeature(SQUARE) + expect(applyUndo(feature, { type: 'move_vertex', vertexIndex: 1, previousCoord: [90, 0] })).toBe(1) + expect(applyUndo(feature, { type: 'delete_vertex', vertexIndex: 1, deletedCoord: [95, 0] })).toBe(1) + expect(applyUndo(feature, { type: 'insert_vertex', vertexIndex: 1 })).toBe(-1) + expect(applyUndo(feature, { type: 'resize' })).toBe(-1) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js b/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js new file mode 100644 index 000000000..a1780cf7f --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js @@ -0,0 +1,74 @@ +import { coordToPixel, pixelDist } from '../utils/olCoords.js' + +// Also used as the Modify interaction's pixelTolerance so pointer hit +// detection and OL's drag activation agree on what "on a handle" means +export const PIXEL_TOLERANCE = 12 + +/** + * Find the nearest vertex to a screen pixel within tolerance. + * + * @param {import('ol/Map').default} map + * @param {number[][]} vertices - flat coordinate array [[e,n], ...] + * @param {{ x: number, y: number }} pixel + * @param {number} [tolerance] + * @returns {{ index: number, type: 'vertex' } | null} + */ +export const findNearestVertex = (map, vertices, pixel, tolerance = PIXEL_TOLERANCE) => { + let bestIdx = -1 + let bestDist = tolerance + + vertices.forEach((coord, i) => { + const px = coordToPixel(map, coord) + if (!px) { + return + } + const d = pixelDist(px, pixel) + if (d < bestDist) { + bestDist = d + bestIdx = i + } + }) + + return bestIdx >= 0 ? { index: bestIdx, type: 'vertex' } : null +} + +/** + * Find the nearest midpoint to a screen pixel within tolerance. + * + * @param {import('ol/Map').default} map + * @param {number[][]} midpoints - midpoint coordinate array + * @param {{ x: number, y: number }} pixel + * @param {number} vertexCount - number of actual vertices (midpoint index offset) + * @param {number} [tolerance] + * @returns {{ index: number, type: 'midpoint' } | null} + */ +export const findNearestMidpoint = (map, midpoints, pixel, vertexCount, tolerance = PIXEL_TOLERANCE) => { + let bestIdx = -1 + let bestDist = tolerance + + midpoints.forEach((coord, i) => { + const px = coordToPixel(map, coord) + if (!px) { + return + } + const d = pixelDist(px, pixel) + if (d < bestDist) { + bestDist = d + bestIdx = i + } + }) + + return bestIdx >= 0 ? { index: vertexCount + bestIdx, type: 'midpoint' } : null +} + +/** + * Find the nearest vertex or midpoint to a pixel. + * Vertices take priority when equidistant. + * + * @param {number} [tolerance] + * @returns {{ index: number, type: 'vertex'|'midpoint' } | null} + */ +export const findNearest = (map, vertices, midpoints, pixel, tolerance = PIXEL_TOLERANCE) => { + return findNearestVertex(map, vertices, pixel, tolerance) ?? + findNearestMidpoint(map, midpoints, pixel, vertices.length, tolerance) +} diff --git a/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js b/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js new file mode 100644 index 000000000..9585d63f0 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js @@ -0,0 +1,28 @@ +import { findNearest, findNearestVertex, findNearestMidpoint } from './vertexHitTest.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const map = createFakeMap() +const vertices = [[0, 0], [100, 0]] +const midpoints = [[50, 0]] + +test('finds the nearest vertex within the pixel tolerance', () => { + expect(findNearestVertex(map, vertices, { x: 98, y: 5 })).toEqual({ index: 1, type: 'vertex' }) + expect(findNearestVertex(map, vertices, { x: 50, y: 50 })).toBeNull() +}) + +test('midpoint hits are offset by the vertex count', () => { + expect(findNearestMidpoint(map, midpoints, { x: 51, y: 2 }, 2)).toEqual({ index: 2, type: 'midpoint' }) + expect(findNearestMidpoint(map, midpoints, { x: 200, y: 0 }, 2)).toBeNull() +}) + +test('vertices take priority over midpoints; nothing in range gives null', () => { + expect(findNearest(map, [[50, 4]], midpoints, { x: 50, y: 2 })).toEqual({ index: 0, type: 'vertex' }) + expect(findNearest(map, vertices, midpoints, { x: 51, y: 2 })).toEqual({ index: 2, type: 'midpoint' }) + expect(findNearest(map, vertices, midpoints, { x: 500, y: 500 })).toBeNull() +}) + +test('coordinates that cannot be projected are skipped', () => { + const blindMap = { getPixelFromCoordinate: () => null } + expect(findNearestVertex(blindMap, vertices, { x: 0, y: 0 })).toBeNull() + expect(findNearestMidpoint(blindMap, midpoints, { x: 50, y: 0 }, 2)).toBeNull() +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js b/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js new file mode 100644 index 000000000..057acbb02 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js @@ -0,0 +1,49 @@ +import VectorSource from 'ol/source/Vector.js' +import VectorLayer from 'ol/layer/Vector.js' +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' +import { getCoords } from '../utils/geometryHelpers.js' + +/** + * Always-visible vertex handle layer for edit mode. + * OL Modify's built-in vertex handles only appear on hover; this layer + * keeps circles visible at all times. The selected vertex is rendered by + * the separate active-selection layer in EditMode (zIndex 103). + */ +export const createVertexLayer = (map, vertexStyle) => { + let currentStyle = vertexStyle + let selectedIndex = -1 + const source = new VectorSource() + const layer = new VectorLayer({ + source, + style: (feature) => feature.get('vertexIndex') === selectedIndex ? null : [currentStyle], + zIndex: 102 + }) + map.addLayer(layer) + + return { + update (geom) { + source.clear() + getCoords(geom).forEach((coord, i) => { + const f = new Feature({ geometry: new Point(coord) }) + f.set('vertexIndex', i) + source.addFeature(f) + }) + }, + + setSelected (index) { + selectedIndex = index + source.changed() + }, + + updateStyle (newVertexStyle) { + currentStyle = newVertexStyle + source.changed() + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js b/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js new file mode 100644 index 000000000..124610d56 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js @@ -0,0 +1,42 @@ +import Style from 'ol/style/Style.js' +import { createVertexLayer } from './vertexLayer.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const GEOM = { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 0]]] } + +const setup = () => { + const map = createFakeMap() + const style = new Style({}) + const handles = createVertexLayer(map, style) + const layer = map.layers[0] + return { map, style, handles, layer, source: layer.getSource() } +} + +test('update renders one handle per editable vertex, indexed', () => { + const { handles, source } = setup() + handles.update(GEOM) + expect(source.getFeatures()).toHaveLength(3) + expect(source.getFeatures().map(f => f.get('vertexIndex')).sort()).toEqual([0, 1, 2]) +}) + +test('the selected vertex is hidden here (the active layer draws it) and styles hot-swap', () => { + const { handles, layer, style, source } = setup() + handles.update(GEOM) + handles.setSelected(1) + const styleFn = layer.getStyle() + const featureAt = (i) => source.getFeatures().find(f => f.get('vertexIndex') === i) + expect(styleFn(featureAt(1))).toBeNull() + expect(styleFn(featureAt(0))).toEqual([style]) + + const newStyle = new Style({}) + handles.updateStyle(newStyle) + expect(styleFn(featureAt(0))).toEqual([newStyle]) +}) + +test('remove clears and detaches the layer', () => { + const { map, handles, source } = setup() + handles.update(GEOM) + handles.remove() + expect(source.getFeatures()).toHaveLength(0) + expect(map.layers).toHaveLength(0) +}) diff --git a/plugins/draw/src/adapters/openlayers/edit/vertexOps.js b/plugins/draw/src/adapters/openlayers/edit/vertexOps.js new file mode 100644 index 000000000..4882f2a65 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/vertexOps.js @@ -0,0 +1,100 @@ +import { + getCoords, + getRingSegments, + getSegmentForIndex, + getModifiableCoords +} from '../utils/geometryHelpers.js' +import { MIN_VERTICES } from '../../../validation/rules.js' + +/** + * Delete the vertex at `selectedIndex` from the OL feature's geometry. + * Respects minimum vertex counts (MIN_VERTICES — validation/rules.js). + * + * @returns {{ deletedIndex: number, deletedCoord: number[] } | null} undo payload, or null if not deleted + */ +export const deleteVertex = (olFeature, selectedIndex) => { + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const coords = getCoords(geojsonGeom) + const segments = getRingSegments(geojsonGeom) + const result = getSegmentForIndex(segments, selectedIndex) + if (!result) { + return null + } + + const { segment } = result + const minVertices = segment.closed ? MIN_VERTICES.Polygon : MIN_VERTICES.LineString + if (segment.length <= minVertices) { + return null + } + + const deletedCoord = [...coords[selectedIndex]] + const ring = getModifiableCoords(geojsonGeom, segment.path) + ring.splice(result.localIdx, 1) + if (segment.closed) { + ring[ring.length - 1] = [...ring[0]] + } + + geom.setCoordinates(geojsonGeom.coordinates) + return { deletedIndex: selectedIndex, deletedCoord } +} + +/** + * Insert a vertex after `afterIndex` in the OL feature's geometry. + * Used when the user activates a midpoint (touch tap or keyboard insert). + * + * @param {number[][]} midpoints - current midpoint array (from midpointLayer) + * @param {number} midpointFlatIndex - flat index (vertexCount + midpointOffset) + * @param {number} vertexCount - number of actual vertices + * @param {number[][]} vertices - current vertex array + * @returns {{ insertedIndex: number } | null} + */ +export const insertAtMidpoint = (olFeature, midpoints, midpointFlatIndex, vertexCount) => { + const midpointLocalIdx = midpointFlatIndex - vertexCount + const midCoord = midpoints[midpointLocalIdx] + if (!midCoord) { + return null + } + + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + + // Map midpoint local index to insertion position in the coordinate array + let midpointCounter = 0 + for (const seg of segments) { + const segMidpoints = seg.closed ? seg.length : seg.length - 1 + if (midpointLocalIdx < midpointCounter + segMidpoints) { + const localMidIdx = midpointLocalIdx - midpointCounter + const insertLocalIdx = localMidIdx + 1 + const insertGlobalIdx = seg.start + insertLocalIdx + const ring = getModifiableCoords(geojsonGeom, seg.path) + ring.splice(insertLocalIdx, 0, [...midCoord]) + geom.setCoordinates(geojsonGeom.coordinates) + return { insertedIndex: insertGlobalIdx } + } + midpointCounter += segMidpoints + } + + return null +} + +/** + * Move vertex at `index` to `newCoord` in the OL feature's geometry. + */ +export const moveVertex = (olFeature, index, newCoord) => { + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + const result = getSegmentForIndex(segments, index) + if (!result) { + return + } + + const ring = getModifiableCoords(geojsonGeom, result.segment.path) + ring[result.localIdx] = [...newCoord] + if (result.segment.closed && result.localIdx === 0) { + ring[ring.length - 1] = [...newCoord] + } + geom.setCoordinates(geojsonGeom.coordinates) +} diff --git a/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js b/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js new file mode 100644 index 000000000..c090653a1 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js @@ -0,0 +1,80 @@ +import Feature from 'ol/Feature.js' +import Polygon from 'ol/geom/Polygon.js' +import { deleteVertex, insertAtMidpoint, moveVertex } from './vertexOps.js' +import { polygonFeature, lineFeature } from '../__helpers__/harness.js' + +const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]] +const HOLE = [[2, 2], [4, 2], [4, 4], [2, 2]] +const ring = (feature, i = 0) => feature.getGeometry().getCoordinates()[i] + +describe('deleteVertex', () => { + test('removes the vertex and keeps the ring closed', () => { + const feature = polygonFeature(SQUARE) + expect(deleteVertex(feature, 1)).toEqual({ deletedIndex: 1, deletedCoord: [10, 0] }) + expect(ring(feature)).toEqual([[0, 0], [10, 10], [0, 10], [0, 0]]) + }) + + test('deleting ring vertex 0 syncs the closing coordinate', () => { + const feature = polygonFeature(SQUARE) + deleteVertex(feature, 0) + expect(ring(feature)).toEqual([[10, 0], [10, 10], [0, 10], [10, 0]]) + }) + + test('deleting from an open line needs no closing-coordinate sync', () => { + const feature = lineFeature([[0, 0], [10, 0], [20, 0]]) + expect(deleteVertex(feature, 1)).toEqual({ deletedIndex: 1, deletedCoord: [10, 0] }) + expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [20, 0]]) + }) + + test('refuses to shrink below the minimum (3 for rings, 2 for lines) or for bad indices', () => { + expect(deleteVertex(polygonFeature([[0, 0], [10, 0], [10, 10], [0, 0]]), 1)).toBeNull() + expect(deleteVertex(lineFeature([[0, 0], [10, 0]]), 0)).toBeNull() + expect(deleteVertex(polygonFeature(SQUARE), 99)).toBeNull() + }) +}) + +describe('insertAtMidpoint', () => { + test('inserts after the midpoint position in the first ring', () => { + const feature = polygonFeature(SQUARE) // 4 vertices; midpoint 0 sits between v0 and v1 + expect(insertAtMidpoint(feature, [[5, 0]], 4, 4)).toEqual({ insertedIndex: 1 }) + expect(ring(feature)[1]).toEqual([5, 0]) + }) + + test('maps midpoints in later rings to their global insert position', () => { + const feature = new Feature(new Polygon([SQUARE, HOLE])) + // 7 vertices; outer ring owns midpoints 0-3, hole owns 4-6. Hole midpoint 1 = between hole v1 and v2. + const midpoints = [[5, 0], [10, 5], [5, 10], [0, 5], [3, 2], [4, 3], [3, 3]] + expect(insertAtMidpoint(feature, midpoints, 7 + 5, 7)).toEqual({ insertedIndex: 6 }) + expect(ring(feature, 1)[2]).toEqual([4, 3]) + }) + + test('unknown midpoints are a no-op', () => { + expect(insertAtMidpoint(polygonFeature(SQUARE), [[5, 0]], 99, 4)).toBeNull() + // A midpoint index past every segment's midpoints (stale midpoint array) is also refused + const extra = [[5, 0], [10, 5], [5, 10], [0, 5], [9, 9]] + expect(insertAtMidpoint(polygonFeature(SQUARE), extra, 4 + 4, 4)).toBeNull() + }) + + test('inserts a midpoint into an open line segment', () => { + const feature = lineFeature([[0, 0], [10, 0]]) // 2 vertices, 1 midpoint at flat index 2 + expect(insertAtMidpoint(feature, [[5, 0]], 2, 2)).toEqual({ insertedIndex: 1 }) + expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [5, 0], [10, 0]]) + }) +}) + +describe('moveVertex', () => { + test('moves the vertex, syncing the closing coordinate for ring vertex 0', () => { + const feature = polygonFeature(SQUARE) + moveVertex(feature, 0, [1, 1]) + expect(ring(feature)[0]).toEqual([1, 1]) + expect(ring(feature).at(-1)).toEqual([1, 1]) + moveVertex(feature, 2, [20, 20]) + expect(ring(feature)[2]).toEqual([20, 20]) + }) + + test('an invalid index leaves the geometry untouched', () => { + const feature = polygonFeature(SQUARE) + moveVertex(feature, 99, [1, 1]) + expect(ring(feature)).toEqual(SQUARE) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/olDraw.js b/plugins/draw/src/adapters/openlayers/olDraw.js new file mode 100644 index 000000000..c43301a7c --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/olDraw.js @@ -0,0 +1,40 @@ +import { OLDrawManager } from './core/OLDrawManager.js' +import { MAP_SIZE_SCALES } from './defaults.js' + +/** + * Creates the OLDrawManager, attaches it to mapProvider, and wires + * app-level events (MAP_SET_SIZE for scale-aware touch targets, + * MAP_SET_STYLE for dynamic color updates). + * + * @returns {{ manager: OLDrawManager, remove: () => void }} + */ +export const createOLDraw = ({ mapProvider, events, eventBus, pluginConfig = {}, mapStyle = null }) => { + const { map } = mapProvider + const manager = new OLDrawManager(map, pluginConfig) + + if (mapStyle) { + manager.setMapStyle(mapStyle) + } + + mapProvider.draw = manager + + const handleSetMapSize = (size) => { + mapProvider.drawScale = MAP_SIZE_SCALES[size] ?? 1 + } + eventBus.on(events.MAP_SET_SIZE, handleSetMapSize) + + const handleSetMapStyle = (newMapStyle) => { + manager.setMapStyle(newMapStyle) + } + eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle) + + return { + manager, + remove () { + eventBus.off(events.MAP_SET_SIZE, handleSetMapSize) + eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle) + manager.remove() + mapProvider.draw = null + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/olDraw.test.js b/plugins/draw/src/adapters/openlayers/olDraw.test.js new file mode 100644 index 000000000..472f9bfd3 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/olDraw.test.js @@ -0,0 +1,70 @@ +import { createOLDraw } from './olDraw.js' +import { OLDrawManager } from './core/OLDrawManager.js' +import { MAP_SIZE_SCALES } from './defaults.js' + +jest.mock('./core/OLDrawManager.js', () => ({ + OLDrawManager: jest.fn(function () { + this.setMapStyle = jest.fn() + this.remove = jest.fn() + }) +})) + +const events = { MAP_SET_SIZE: 'app:size', MAP_SET_STYLE: 'app:style' } + +const setup = (mapStyle = null) => { + const listeners = {} + const eventBus = { + on: jest.fn((type, handler) => { listeners[type] = handler }), + off: jest.fn(), + emit: (type, payload) => listeners[type]?.(payload) + } + const mapProvider = { map: { id: 'ol-map' } } + const olDraw = createOLDraw({ mapProvider, events, eventBus, pluginConfig: { snapRadius: 5 }, mapStyle }) + const manager = OLDrawManager.mock.instances.at(-1) + return { eventBus, mapProvider, olDraw, manager } +} + +afterEach(() => jest.clearAllMocks()) + +test('creates the manager for the map, exposes it as mapProvider.draw and applies an initial style', () => { + const { mapProvider, manager } = setup({ id: 'dark' }) + expect(OLDrawManager).toHaveBeenCalledWith(mapProvider.map, { snapRadius: 5 }) + expect(mapProvider.draw).toBe(manager) + expect(manager.setMapStyle).toHaveBeenCalledWith({ id: 'dark' }) + + expect(setup().manager.setMapStyle).not.toHaveBeenCalled() // no initial style +}) + +test('map size changes update the draw UI scale, defaulting to 1 for unknown sizes', () => { + const { eventBus, mapProvider } = setup() + eventBus.emit(events.MAP_SET_SIZE, 'large') + expect(mapProvider.drawScale).toBe(MAP_SIZE_SCALES.large) + eventBus.emit(events.MAP_SET_SIZE, 'enormous') + expect(mapProvider.drawScale).toBe(1) +}) + +test('pluginConfig and mapStyle are optional, defaulting to {} and no initial style', () => { + const eventBus = { on: jest.fn(), off: jest.fn() } + const mapProvider = { map: { id: 'ol-map' } } + const olDraw = createOLDraw({ mapProvider, events, eventBus }) // no pluginConfig, no mapStyle + const manager = OLDrawManager.mock.instances.at(-1) + expect(OLDrawManager).toHaveBeenCalledWith(mapProvider.map, {}) + expect(manager.setMapStyle).not.toHaveBeenCalled() + expect(mapProvider.draw).toBe(manager) + olDraw.remove() +}) + +test('map style changes are forwarded to the manager', () => { + const { eventBus, manager } = setup() + eventBus.emit(events.MAP_SET_STYLE, { id: 'dark' }) + expect(manager.setMapStyle).toHaveBeenCalledWith({ id: 'dark' }) +}) + +test('remove unsubscribes, destroys the manager and clears mapProvider.draw', () => { + const { eventBus, mapProvider, olDraw, manager } = setup() + olDraw.remove() + expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_SIZE, expect.any(Function)) + expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_STYLE, expect.any(Function)) + expect(manager.remove).toHaveBeenCalled() + expect(mapProvider.draw).toBeNull() +}) diff --git a/plugins/draw/src/adapters/openlayers/snap/snapEngine.js b/plugins/draw/src/adapters/openlayers/snap/snapEngine.js new file mode 100644 index 000000000..a6a058e4e --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapEngine.js @@ -0,0 +1,220 @@ +import { transform as projTransform } from 'ol/proj.js' +import VectorLayer from 'ol/layer/Vector.js' +import VectorTileLayer from 'ol/layer/VectorTile.js' +import { testOLFeature, testRenderFeature, bestOf } from './snapGeometry.js' + +// VectorTile geometry is clipped to a rectangle (tile extent + buffer), producing fake +// axis-aligned segments — and fake vertices where the clip cut the ring — that don't +// exist in the real data and mustn't be snap targets. They are detected by orientation: +// clip segments are exactly axis-aligned in tile space AND sit near a tile edge, a +// combination real boundaries essentially never produce. This works without knowing +// the exact buffer size the tiler used. Standard MVT buffer is 128/4096 tile units. +const MVT_BUFFER_UNITS = 128 +const MVT_TILE_EXTENT = 4096 + +const buildTileBoundaryState = (layer, map) => { + const source = layer.getSource() + const tileGrid = source?.getTileGrid() + if (!tileGrid) { return null } + const viewProj = map.getView().getProjection() + const sourceProj = source.getProjection() ?? viewProj + const zoom = tileGrid.getZForResolution(map.getView().getResolution(), 0) + return { tileGrid, viewProj, sourceProj, zoom } +} + +// Per-candidate context: the candidate's tile extent plus the two tolerances, +// computed once so both adjacent segments of a vertex reuse it. +const getClipContext = (state, coord) => { + if (!state) { return null } + const { tileGrid, viewProj, sourceProj, zoom } = state + const toSource = (c) => c ? projTransform(c, viewProj, sourceProj) : null + const anchor = toSource(coord) + const tileCoord = tileGrid.getTileCoordForCoordAndZ(anchor, zoom) + const extent = tileGrid.getTileCoordExtent(tileCoord) + const tileWidth = extent[2] - extent[0] + return { + toSource, + anchor, + extent, + // Coarse gate: within twice the standard MVT buffer of a tile edge. Generous on + // purpose (the axis-alignment test does the real discrimination) so tilers with + // a non-standard buffer size are still covered. Relative to tile width, so it is + // projection-unit independent. + band: tileWidth * (2 * MVT_BUFFER_UNITS / MVT_TILE_EXTENT), + // MVT coordinates are quantized to 1/4096 of the tile extent; two quantization + // steps of tolerance decides whether a segment is exactly axis-aligned. + eps: 2 * (tileWidth / MVT_TILE_EXTENT) + } +} + +const isArtefactSegment = (ctx, pa, pb) => { + if (!pa || !pb) { return false } + const [minX, minY, maxX, maxY] = ctx.extent + const nearEdge = (v, lo, hi) => Math.min(Math.abs(v - lo), Math.abs(v - hi)) <= ctx.band + const vertical = Math.abs(pa[0] - pb[0]) <= ctx.eps && nearEdge(pa[0], minX, maxX) + const horizontal = Math.abs(pa[1] - pb[1]) <= ctx.eps && nearEdge(pa[1], minY, maxY) + return vertical || horizontal +} + +// Edges: artefact when the snapped segment itself is an axis-aligned boundary segment. +// Vertices: fake vertices are the points where clipping cut the ring, i.e. endpoints +// of artefact segments — test both adjacent segments. +const isClipArtefact = (state, candidate) => { + const ctx = getClipContext(state, candidate.coord) + if (!ctx) { return false } + if (candidate.type === 'edge') { + const [a, b] = candidate.seg + return isArtefactSegment(ctx, ctx.toSource(a), ctx.toSource(b)) + } + const [prev, next] = candidate.adjacent ?? [] + return isArtefactSegment(ctx, ctx.anchor, ctx.toSource(prev)) || + isArtefactSegment(ctx, ctx.anchor, ctx.toSource(next)) +} + +// Read at call time so it can be toggled from the console at any point: window.DEBUG_SNAP_VISIBILITY = true +const isDebugEnabled = () => globalThis.DEBUG_SNAP_VISIBILITY === true + +// Two same-style fill polygons share an invisible boundary — snapping to it is confusing. +// Detect this by projecting a test point slightly past the snap position (away from the cursor) +// and checking whether the same fill layer covers that side too. +// If it does → same-style invisible boundary → skip. +// If not (empty space, road, different layer) → visible outer edge → include. +const INVISIBLE_FILL_MIN_DIST_SQ = 1 // (1m)² — below this, cursor is on the edge, skip direction check + +const isInvisibleFillBoundary = (edgeCoord, cursorCoord, layerId, map, vtLayers, resolution) => { + const dx = edgeCoord[0] - cursorCoord[0] + const dy = edgeCoord[1] - cursorCoord[1] + const distSq = dx * dx + dy * dy + if (distSq < INVISIBLE_FILL_MIN_DIST_SQ) { return false } + const dist = Math.sqrt(distSq) + const step = resolution * 4 + const testCoord = [edgeCoord[0] + (dx / dist) * step, edgeCoord[1] + (dy / dist) * step] + const testPixel = map.getPixelFromCoordinate(testCoord) + if (!testPixel) { return false } + return !!map.forEachFeatureAtPixel( + testPixel, + (f) => f.get('mapbox-layer')?.id === layerId, + { hitTolerance: 2, layerFilter: (l) => vtLayers.includes(l) } + ) +} + +const logCandidate = (candidate, mapboxLayer) => { + if (!isDebugEnabled()) { return } + console.log('[snap-candidate-found]', { + type: candidate.type, + layerId: mapboxLayer?.id, + layerType: mapboxLayer?.type, + coord: [candidate.coord[0].toFixed(2), candidate.coord[1].toFixed(2)], + distSq: candidate.distSq.toFixed(2) + }) +} + +// Returns true when the candidate is a rendering artefact rather than a visible +// snap target: either a tile clip artefact, or an invisible same-fill boundary. +const shouldFilterCandidate = ({ candidate, cursorCoord, mapboxLayer, boundaryState, map, vtLayers, resolution }) => { + if (isClipArtefact(boundaryState, candidate)) { + if (isDebugEnabled()) { console.log('[snap-filtered] tile clip artefact', candidate.type) } + return true + } + if (candidate.type === 'edge' && mapboxLayer?.type === 'fill' && + isInvisibleFillBoundary(candidate.coord, cursorCoord, mapboxLayer.id, map, vtLayers, resolution)) { + if (isDebugEnabled()) { console.log('[snap-filtered] invisible fill boundary') } + return true + } + return false +} + +// Folds a feature's candidates into the current best, skipping rendering artefacts +const pickVisibleCandidates = (best, candidates, context) => { + let result = best + for (const candidate of candidates) { + logCandidate(candidate, context.mapboxLayer) + if (!shouldFilterCandidate({ candidate, ...context })) { + result = bestOf(result, candidate) + } + } + return result +} + +// Collected on each query — VectorTileLayers are replaced when the map style changes +const getVTLayers = (map) => { + const layers = [] + map.getLayers().forEach(l => { + if (l instanceof VectorTileLayer) { layers.push(l) } + }) + return layers +} + +export const createSnapEngine = (map, snapLayers = []) => { + let vtLayerNames = new Set() + let olLayers = [] + + const setLayers = (layers) => { + vtLayerNames = new Set() + olLayers = [] + for (const entry of layers ?? []) { + if (typeof entry === 'string') { + vtLayerNames.add(entry) + } else if (entry instanceof VectorLayer) { + olLayers.push(entry) + } else { + // unsupported layer type — skip + } + } + } + + setLayers(snapLayers) + + const query = (coord, radiusPx) => { + if (isDebugEnabled()) { + console.log('[snap-query]', { coord: [coord[0].toFixed(2), coord[1].toFixed(2)], radiusPx, vtLayerCount: vtLayerNames.size, olLayerCount: olLayers.length }) + } + const resolution = map.getView().getResolution() + if (!resolution) { return null } + const toleranceMapUnits = radiusPx * resolution + const toleranceSq = toleranceMapUnits * toleranceMapUnits + const ext = [ + coord[0] - toleranceMapUnits, + coord[1] - toleranceMapUnits, + coord[0] + toleranceMapUnits, + coord[1] + toleranceMapUnits + ] + + let best = null + + for (const layer of olLayers) { + const source = layer.getSource() + if (!source) { continue } + for (const feature of source.getFeaturesInExtent(ext)) { + best = bestOf(best, testOLFeature(feature, coord, toleranceSq)) + } + } + + if (vtLayerNames.size > 0) { + const vtLayers = getVTLayers(map) + const pixel = vtLayers.length > 0 ? map.getPixelFromCoordinate(coord) : null + if (pixel) { + const tileBoundaryStates = new Map() + map.forEachFeatureAtPixel( + pixel, + (feature, layer) => { + const mapboxLayer = feature.get('mapbox-layer') + if (!vtLayerNames.has(mapboxLayer?.id)) { return } + const candidates = testRenderFeature(feature, coord, toleranceSq) + if (!candidates.length) { return } + if (!tileBoundaryStates.has(layer)) { + tileBoundaryStates.set(layer, buildTileBoundaryState(layer, map)) + } + const boundaryState = tileBoundaryStates.get(layer) + best = pickVisibleCandidates(best, candidates, { cursorCoord: coord, mapboxLayer, boundaryState, map, vtLayers, resolution }) + }, + { hitTolerance: radiusPx, layerFilter: (l) => vtLayers.includes(l) } + ) + } + } + + return best ? { type: best.type, coord: best.coord } : null + } + + return { query, setLayers } +} diff --git a/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js b/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js new file mode 100644 index 000000000..0cb420ef8 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js @@ -0,0 +1,232 @@ +import VectorLayer from 'ol/layer/Vector.js' +import VectorTileLayer from 'ol/layer/VectorTile.js' +import VectorSource from 'ol/source/Vector.js' +import { createSnapEngine } from './snapEngine.js' +import { polygonFeature } from '../__helpers__/harness.js' + +// One tile covering [0,0]–[4096,4096]: band for clip artefacts = 256 units from an +// edge, axis-alignment tolerance = 2 units. Resolution 1 → 12px radius = 12 units. +const tileGrid = { + getZForResolution: jest.fn(() => 10), + getTileCoordForCoordAndZ: jest.fn(() => [10, 0, 0]), + getTileCoordExtent: jest.fn(() => [0, 0, 4096, 4096]) +} + +const createEngineMap = () => { + const layers = [] + return { + layers, + getLayers: () => ({ forEach: (cb) => layers.forEach(cb) }), + getView: () => ({ getResolution: () => 1, getProjection: () => 'EPSG:3857' }), + getPixelFromCoordinate: jest.fn((c) => [c[0], c[1]]), + forEachFeatureAtPixel: jest.fn() + } +} + +const renderFeature = (type, flat, ends = null, mapboxLayer = { id: 'boundaries', type: 'line' }) => ({ + getType: () => type, + getFlatCoordinates: () => flat, + getEnds: () => ends, + get: (key) => (key === 'mapbox-layer' ? mapboxLayer : undefined) +}) + +// map.forEachFeatureAtPixel serves two callers: candidate iteration (hitTolerance = +// snap radius) and the invisible-fill probe (hitTolerance 2, returns the callback result) +const setupVT = ({ features, withTileGrid = true, fillNeighbour = false }) => { + const map = createEngineMap() + const vtLayer = new VectorTileLayer({}) + if (withTileGrid) { + jest.spyOn(vtLayer, 'getSource').mockReturnValue({ getTileGrid: () => tileGrid, getProjection: () => null }) + } + map.layers.push(vtLayer) + map.forEachFeatureAtPixel.mockImplementation((pixel, visit, opts) => { + // Honour the layerFilter the way OL does — only tile layers are consulted + if (!opts.layerFilter(vtLayer)) { + return undefined + } + if (opts.hitTolerance === 2) { + return fillNeighbour ? visit({ get: () => ({ id: 'fills' }) }) : undefined + } + features.forEach((f) => visit(f, vtLayer)) + return undefined + }) + return { map, engine: createSnapEngine(map, ['boundaries', 'fills']) } +} + +afterEach(() => jest.clearAllMocks()) + +describe('plain vector layers', () => { + test('snaps to features near the query, skipping layers without a source', () => { + const map = createEngineMap() + const source = new VectorSource() + source.addFeature(polygonFeature([[0, 0], [100, 0], [100, 100], [0, 0]])) + const engine = createSnapEngine(map, [new VectorLayer({ source }), new VectorLayer({})]) + expect(engine.query([98, 2], 12)).toEqual({ type: 'vertex', coord: [100, 0] }) + expect(engine.query([500, 500], 12)).toBeNull() + }) + + test('setLayers swaps targets at runtime and ignores unsupported entries', () => { + const map = createEngineMap() + const source = new VectorSource() + source.addFeature(polygonFeature([[0, 0], [100, 0], [100, 100], [0, 0]])) + const engine = createSnapEngine(map, [new VectorLayer({ source }), 42]) + expect(engine.query([98, 2], 12)).not.toBeNull() + engine.setLayers([]) + expect(engine.query([98, 2], 12)).toBeNull() + }) + + test('no resolution (map not ready) means no snapping', () => { + const map = createEngineMap() + map.getView = () => ({ getResolution: () => undefined }) + expect(createSnapEngine(map, ['boundaries']).query([0, 0], 12)).toBeNull() + }) + + test('defaults to an empty snap-layer set when none are configured', () => { + const map = createEngineMap() + expect(createSnapEngine(map).query([0, 0], 12)).toBeNull() + }) + + test('setLayers tolerates a null layer list', () => { + const map = createEngineMap() + const source = new VectorSource() + source.addFeature(polygonFeature([[0, 0], [100, 0], [100, 100], [0, 0]])) + const engine = createSnapEngine(map, [new VectorLayer({ source })]) + expect(engine.query([98, 2], 12)).not.toBeNull() + engine.setLayers(null) + expect(engine.query([98, 2], 12)).toBeNull() + }) +}) + +describe('vector-tile layers', () => { + test('snaps to configured tile layers only, mid-tile axis alignment is fine', () => { + // Horizontal segment mid-tile: axis-aligned but nowhere near a tile edge → real geometry + const { engine } = setupVT({ + features: [ + renderFeature('LineString', [500, 2000, 1500, 2000]), + renderFeature('LineString', [590, 2000, 610, 2000], null, { id: 'not-configured', type: 'line' }) + ] + }) + expect(engine.query([600, 2005], 12)).toEqual({ type: 'edge', coord: [600, 2000] }) + }) + + test('without any tile layer on the map, tile snapping is skipped entirely', () => { + const map = createEngineMap() + const engine = createSnapEngine(map, ['boundaries']) + expect(engine.query([600, 2005], 12)).toBeNull() + expect(map.forEachFeatureAtPixel).not.toHaveBeenCalled() + }) + + test('clip artefacts are rejected: axis-aligned segments hugging a tile edge', () => { + // Vertical segment at x=1, well inside the 256-unit band of the tile's left edge + const { engine } = setupVT({ features: [renderFeature('LineString', [1, 500, 1, 1500])] }) + expect(engine.query([5, 1000], 12)).toBeNull() + }) + + test('clip-cut vertices are rejected while their real edges survive', () => { + // The vertex at [1,1000] ends a clip segment (vertical along x=1); the horizontal + // edge leaving it at y=1000 is real geometry. The vertex would normally beat the + // edge — filtering it lets the edge win. + const { engine } = setupVT({ features: [renderFeature('LineString', [1, 500, 1, 1000, 800, 1000])] }) + expect(engine.query([6, 1004], 12)).toMatchObject({ type: 'edge' }) + }) + + test('without a tile grid the clip filter degrades gracefully and keeps candidates', () => { + const { engine } = setupVT({ + features: [renderFeature('LineString', [1, 500, 1, 1500])], + withTileGrid: false + }) + expect(engine.query([5, 1000], 12)).toEqual({ type: 'edge', coord: [1, 1000] }) + }) + + test('the tile boundary state is computed once per layer per query', () => { + const { engine } = setupVT({ + features: [ + renderFeature('LineString', [500, 2000, 1500, 2000]), + renderFeature('LineString', [500, 2020, 1500, 2020]) + ] + }) + engine.query([600, 2005], 12) + expect(tileGrid.getZForResolution).toHaveBeenCalledTimes(1) + }) + + test('the boundary state is reused across multiple candidates from the same tile layer', () => { + const { engine } = setupVT({ + features: [ + renderFeature('LineString', [500, 2000, 1500, 2000]), + renderFeature('LineString', [500, 2005, 1500, 2005]) + ] + }) + expect(engine.query([600, 2002], 12)).not.toBeNull() // both features yield a candidate + expect(tileGrid.getZForResolution).toHaveBeenCalledTimes(1) + }) + + test('open-line endpoints test only the neighbour that exists', () => { + // Endpoint [2000,2000] has a null previous neighbour; the clip filter must skip it + const { engine } = setupVT({ features: [renderFeature('LineString', [2000, 2000, 3000, 2000])] }) + expect(engine.query([2002, 2001], 12)).toEqual({ type: 'vertex', coord: [2000, 2000] }) + }) + + test('a tile point candidate without adjacency data snaps as a vertex', () => { + const { engine } = setupVT({ features: [renderFeature('Point', [2000, 2000])] }) + expect(engine.query([2001, 2000], 12)).toEqual({ type: 'vertex', coord: [2000, 2000] }) + }) + + test('non-tile layers present on the map are ignored when gathering tile layers', () => { + const { engine, map } = setupVT({ features: [renderFeature('LineString', [500, 2000, 1500, 2000])] }) + map.layers.unshift(new VectorLayer({})) // a non-tile layer sits in the stack + expect(engine.query([600, 2005], 12)).toEqual({ type: 'edge', coord: [600, 2000] }) + }) +}) + +describe('invisible shared fill boundaries', () => { + const fillSquare = renderFeature( + 'Polygon', [1000, 1000, 3000, 1000, 3000, 3000, 1000, 3000], [8], { id: 'fills', type: 'fill' }) + + test('an edge with the same fill on both sides is skipped; a visible outer edge snaps', () => { + const invisible = setupVT({ features: [fillSquare], fillNeighbour: true }) + expect(invisible.engine.query([2000, 995], 12)).toBeNull() + + const visible = setupVT({ features: [fillSquare], fillNeighbour: false }) + expect(visible.engine.query([2000, 995], 12)).toEqual({ type: 'edge', coord: [2000, 1000] }) + }) + + test('a cursor already on the edge, or an unprojectable probe, skips the direction check', () => { + const onEdge = setupVT({ features: [fillSquare], fillNeighbour: true }) + expect(onEdge.engine.query([2000, 1000], 12)).toEqual({ type: 'edge', coord: [2000, 1000] }) + + const blindProbe = setupVT({ features: [fillSquare], fillNeighbour: true }) + blindProbe.map.getPixelFromCoordinate.mockReturnValueOnce([2000, 995]).mockReturnValueOnce(null) + expect(blindProbe.engine.query([2000, 995], 12)).toEqual({ type: 'edge', coord: [2000, 1000] }) + }) +}) + +test('debug logging traces queries, candidates and filtering when enabled', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => {}) + globalThis.DEBUG_SNAP_VISIBILITY = true + try { + const { engine } = setupVT({ features: [renderFeature('LineString', [1, 500, 1, 1500])] }) + engine.query([5, 1000], 12) + const topics = log.mock.calls.map(([topic]) => topic) + expect(topics).toContain('[snap-query]') + expect(topics).toContain('[snap-candidate-found]') + expect(topics).toContain('[snap-filtered] tile clip artefact') + } finally { + delete globalThis.DEBUG_SNAP_VISIBILITY + log.mockRestore() + } +}) + +test('debug logging traces invisible-fill filtering when enabled', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => {}) + globalThis.DEBUG_SNAP_VISIBILITY = true + try { + const fillSquare = renderFeature( + 'Polygon', [1000, 1000, 3000, 1000, 3000, 3000, 1000, 3000], [8], { id: 'fills', type: 'fill' }) + const { engine } = setupVT({ features: [fillSquare], fillNeighbour: true }) + engine.query([2000, 995], 12) + expect(log.mock.calls.map(([topic]) => topic)).toContain('[snap-filtered] invisible fill boundary') + } finally { + delete globalThis.DEBUG_SNAP_VISIBILITY + log.mockRestore() + } +}) diff --git a/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js b/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js new file mode 100644 index 000000000..272b2a3b5 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js @@ -0,0 +1,198 @@ +const dist2 = (a, b) => { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + return dx * dx + dy * dy +} + +const closestPointOnSegment = (p, a, b) => { + const dx = b[0] - a[0] + const dy = b[1] - a[1] + const lenSq = dx * dx + dy * dy + if (lenSq === 0) { + return [a[0], a[1]] + } + const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq)) + return [a[0] + t * dx, a[1] + t * dy] +} + +// Exported for the snap engine to merge candidates across features/layers +export const bestOf = (current, candidate) => better(current, candidate) ? candidate : current + +const better = (a, b) => { + if (!a) { + return !!b + } + if (!b) { + return false + } + // Vertex always beats edge — only compare distance within the same type + if (a.type === 'edge' && b.type === 'vertex') { + return true + } + if (a.type === 'vertex' && b.type === 'edge') { + return false + } + return b.distSq < a.distSq +} + +// OL Vector rings duplicate the first coord as last; VectorTile rings do not. +// isClosedRing=true: skip the duplicated last vertex, wrap closing edge back to first. +const testCoords = (coords, query, toleranceSq, isClosedRing) => { + let best = null + const n = isClosedRing && coords.length > 1 ? coords.length - 1 : coords.length + const edgeCount = isClosedRing ? n : n - 1 + + for (let i = 0; i < n; i++) { + const v = coords[i] + const dSq = dist2(query, v) + if (dSq <= toleranceSq) { + best = bestOf(best, { type: 'vertex', coord: [v[0], v[1]], distSq: dSq }) + } + } + + for (let i = 0; i < edgeCount; i++) { + const a = coords[i] + const b = coords[(i + 1) % n] + const pt = closestPointOnSegment(query, a, b) + const dSq = dist2(query, pt) + if (dSq <= toleranceSq) { + best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq }) + } + } + + return best +} + +// Edge candidates carry their segment endpoints (seg) and vertex candidates their +// neighbouring vertices (adjacent) so the snap engine can test segment orientation — +// used to recognise tile clip artefacts, which are always axis-aligned in tile space. +const getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => { + let best = null + for (let i = 0; i < edgeCount; i++) { + const ai = start + i * 2 + const bi = start + ((i + 1) % numPairs) * 2 + const a = [flat[ai], flat[ai + 1]] + const b = [flat[bi], flat[bi + 1]] + const pt = closestPointOnSegment(query, a, b) + const dSq = dist2(query, pt) + if (dSq <= toleranceSq) { + best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq, seg: [a, b] }) + } + } + return best +} + +// Neighbouring vertex indices; closed rings (edgeCount === numPairs) wrap around the ring +const adjacentIndices = (i, numPairs, wraps) => { + const prev = i > 0 ? i - 1 : numPairs - 1 + const next = i < numPairs - 1 ? i + 1 : 0 + return [ + (i > 0 || wraps) ? prev : null, + (i < numPairs - 1 || wraps) ? next : null + ] +} + +const getBestVertex = (flat, start, numPairs, edgeCount, query, toleranceSq) => { + const wraps = edgeCount === numPairs + const coordAt = (i) => i === null ? null : [flat[start + i * 2], flat[start + i * 2 + 1]] + let best = null + for (let i = 0; i < numPairs; i++) { + const v = coordAt(i) + const dSq = dist2(query, v) + if (dSq <= toleranceSq) { + const [prevIdx, nextIdx] = adjacentIndices(i, numPairs, wraps) + const adjacent = [coordAt(prevIdx), coordAt(nextIdx)] + best = bestOf(best, { type: 'vertex', coord: v, distSq: dSq, adjacent }) + } + } + return best +} + +// Returns the best vertex and best edge separately so the caller can filter one +// (e.g. a clip-artefact vertex) while still snapping to the other. +const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => { + const numPairs = (end - start) / 2 + const edgeCount = isClosedRing ? numPairs : numPairs - 1 + return { + vertex: getBestVertex(flat, start, numPairs, edgeCount, query, toleranceSq), + edge: getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq) + } +} + +const olGeomHandlers = { + point: (geom, query, toleranceSq) => { + const c = geom.getCoordinates() + const dSq = dist2(query, c) + return dSq <= toleranceSq ? { type: 'vertex', coord: [c[0], c[1]], distSq: dSq } : null + }, + lineString: (geom, query, toleranceSq) => { + return testCoords(geom.getCoordinates(), query, toleranceSq, false) + }, + linearRing: (geom, query, toleranceSq) => { + return testCoords(geom.getCoordinates(), query, toleranceSq, true) + }, + polygon: (geom, query, toleranceSq) => { + let best = null + for (const ring of geom.getCoordinates()) { + best = bestOf(best, testCoords(ring, query, toleranceSq, true)) + } + return best + }, + multiLineString: (geom, query, toleranceSq) => { + let best = null + for (const line of geom.getCoordinates()) { + best = bestOf(best, testCoords(line, query, toleranceSq, false)) + } + return best + }, + multiPolygon: (geom, query, toleranceSq) => { + let best = null + for (const polygon of geom.getCoordinates()) { + for (const ring of polygon) { + best = bestOf(best, testCoords(ring, query, toleranceSq, true)) + } + } + return best + } +} + +export const testOLFeature = (feature, query, toleranceSq) => { + const geom = feature.getGeometry() + if (!geom) { return null } + const rawType = geom.getType() + const handler = olGeomHandlers[rawType[0].toLowerCase() + rawType.slice(1)] + return handler ? handler(geom, query, toleranceSq) : null +} + +// Returns an array of candidates (best vertex and best edge, when within tolerance) +// rather than a single winner, so the snap engine can filter clip artefacts per +// candidate and still fall back to the other. +export const testRenderFeature = (feature, query, toleranceSq) => { + const type = feature.getType() + const flat = feature.getFlatCoordinates() + let bestVertex = null + let bestEdge = null + + if (type === 'Point') { + const dSq = dist2(query, flat) + if (dSq <= toleranceSq) { + bestVertex = { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq } + } + } else if (type === 'LineString') { + ({ vertex: bestVertex, edge: bestEdge } = testFlatCoords(flat, 0, flat.length, query, toleranceSq, false)) + } else if (type === 'Polygon' || type === 'MultiLineString') { + const ends = feature.getEnds() + let start = 0 + const isClosedRing = type === 'Polygon' + for (const end of ends) { + const pair = testFlatCoords(flat, start, end, query, toleranceSq, isClosedRing) + bestVertex = bestOf(bestVertex, pair.vertex) + bestEdge = bestOf(bestEdge, pair.edge) + start = end + } + } else { + // MultiPoint / unknown — no snap candidates + } + + return [bestVertex, bestEdge].filter(Boolean) +} diff --git a/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js b/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js new file mode 100644 index 000000000..63fe92f73 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js @@ -0,0 +1,136 @@ +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' +import LineString from 'ol/geom/LineString.js' +import LinearRing from 'ol/geom/LinearRing.js' +import Polygon from 'ol/geom/Polygon.js' +import MultiLineString from 'ol/geom/MultiLineString.js' +import MultiPolygon from 'ol/geom/MultiPolygon.js' +import MultiPoint from 'ol/geom/MultiPoint.js' +import { bestOf, testOLFeature, testRenderFeature } from './snapGeometry.js' + +const TOL = 100 // 10px tolerance, squared + +describe('bestOf candidate ranking', () => { + const vertex = (distSq) => ({ type: 'vertex', distSq }) + const edge = (distSq) => ({ type: 'edge', distSq }) + + test('a vertex always beats an edge, regardless of distance', () => { + expect(bestOf(edge(1), vertex(99))).toEqual(vertex(99)) + expect(bestOf(vertex(99), edge(1))).toEqual(vertex(99)) + }) + + test('within the same type the closer candidate wins', () => { + expect(bestOf(vertex(50), vertex(10))).toEqual(vertex(10)) + expect(bestOf(edge(10), edge(50))).toEqual(edge(10)) + }) + + test('null candidates never win', () => { + expect(bestOf(null, vertex(50))).toEqual(vertex(50)) + expect(bestOf(vertex(50), null)).toEqual(vertex(50)) + expect(bestOf(null, null)).toBeNull() + }) +}) + +describe('testOLFeature', () => { + test('points snap as vertices within tolerance only', () => { + const feature = new Feature(new Point([100, 100])) + expect(testOLFeature(feature, [103, 104], TOL)).toEqual({ type: 'vertex', coord: [100, 100], distSq: 25 }) + expect(testOLFeature(feature, [200, 200], TOL)).toBeNull() + }) + + test('lines snap to the nearest vertex, or to the closest point on an edge', () => { + const feature = new Feature(new LineString([[0, 0], [100, 0]])) + expect(testOLFeature(feature, [2, 2], TOL)).toMatchObject({ type: 'vertex', coord: [0, 0] }) + expect(testOLFeature(feature, [50, 5], TOL)).toMatchObject({ type: 'edge', coord: [50, 0] }) + }) + + test('a query beyond the segment end clamps to the endpoint', () => { + const feature = new Feature(new LineString([[0, 0], [100, 0]])) + expect(testOLFeature(feature, [106, 0], TOL)).toMatchObject({ coord: [100, 0] }) + }) + + test('polygons snap on the closing edge and skip the duplicated closing vertex', () => { + const feature = new Feature(new Polygon([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]])) + // Closing edge [0,100] → [0,0] + expect(testOLFeature(feature, [5, 50], TOL)).toMatchObject({ type: 'edge', coord: [0, 50] }) + // Exactly on the first/closing vertex — one vertex candidate, not two + expect(testOLFeature(feature, [0, 0], TOL)).toMatchObject({ type: 'vertex', coord: [0, 0], distSq: 0 }) + }) + + test('multi-part geometries test every part and ring', () => { + const multiLine = new Feature(new MultiLineString([[[0, 0], [10, 0]], [[200, 200], [300, 200]]])) + expect(testOLFeature(multiLine, [201, 201], TOL)).toMatchObject({ type: 'vertex', coord: [200, 200] }) + + const ring = new Feature(new LinearRing([[0, 0], [50, 0], [50, 50], [0, 0]])) + expect(testOLFeature(ring, [25, 25], TOL)).toMatchObject({ type: 'edge', coord: [25, 25] }) + + const multiPolygon = new Feature(new MultiPolygon([ + [[[0, 0], [10, 0], [10, 10], [0, 0]]], + [[[200, 200], [300, 200], [300, 300], [200, 200]]] + ])) + expect(testOLFeature(multiPolygon, [202, 201], TOL)).toMatchObject({ type: 'vertex', coord: [200, 200] }) + }) + + test('a zero-length segment degrades to its start point', () => { + const feature = new Feature(new LineString([[10, 10], [10, 10]])) + expect(testOLFeature(feature, [12, 10], TOL)).toMatchObject({ type: 'vertex', coord: [10, 10] }) + }) + + test('unsupported or missing geometries yield no candidate', () => { + expect(testOLFeature(new Feature(new MultiPoint([[0, 0]])), [0, 0], TOL)).toBeNull() + expect(testOLFeature(new Feature(), [0, 0], TOL)).toBeNull() + }) +}) + +describe('testRenderFeature (vector-tile render features)', () => { + const renderFeature = (type, flat, ends = null) => ({ + getType: () => type, + getFlatCoordinates: () => flat, + getEnds: () => ends + }) + + test('points return a single vertex candidate within tolerance', () => { + expect(testRenderFeature(renderFeature('Point', [10, 10]), [12, 10], TOL)) + .toEqual([{ type: 'vertex', coord: [10, 10], distSq: 4 }]) + expect(testRenderFeature(renderFeature('Point', [10, 10]), [100, 100], TOL)).toEqual([]) + }) + + test('lines return best vertex AND best edge, with adjacency/segment metadata', () => { + const line = renderFeature('LineString', [0, 0, 100, 0, 100, 100]) + const [vertex, edge] = testRenderFeature(line, [98, 4], TOL) + expect(vertex).toMatchObject({ type: 'vertex', coord: [100, 0], adjacent: [[0, 0], [100, 100]] }) + expect(edge).toMatchObject({ type: 'edge', seg: [[100, 0], [100, 100]] }) + }) + + test('open line ends have null adjacency on the open side', () => { + const line = renderFeature('LineString', [0, 0, 100, 0]) + const [vertex] = testRenderFeature(line, [1, 1], TOL) + expect(vertex).toMatchObject({ coord: [0, 0], adjacent: [null, [100, 0]] }) + }) + + test('the far end of an open line also reports a null neighbour on its open side', () => { + const line = renderFeature('LineString', [0, 0, 100, 0]) + const [vertex] = testRenderFeature(line, [99, 1], TOL) + expect(vertex).toMatchObject({ coord: [100, 0], adjacent: [[0, 0], null] }) + }) + + test('polygon rings (no duplicated closing coord in tiles) wrap edges and adjacency', () => { + const square = renderFeature('Polygon', [0, 0, 100, 0, 100, 100, 0, 100], [8]) + const candidates = testRenderFeature(square, [2, 52], TOL) + expect(candidates).toEqual([expect.objectContaining({ type: 'edge', coord: [0, 52], seg: [[0, 100], [0, 0]] })]) + + const [vertex] = testRenderFeature(square, [1, 1], TOL) + expect(vertex).toMatchObject({ coord: [0, 0], adjacent: [[0, 100], [100, 0]] }) // wraps to last vertex + }) + + test('multi-line render features honour their part boundaries', () => { + const twoParts = renderFeature('MultiLineString', [0, 0, 10, 0, 200, 200, 300, 200], [4, 8]) + // No phantom edge between [10,0] and [200,200] — a query midway finds nothing + expect(testRenderFeature(twoParts, [105, 100], TOL)).toEqual([]) + expect(testRenderFeature(twoParts, [201, 200], TOL)[0]).toMatchObject({ coord: [200, 200] }) + }) + + test('unsupported render feature types return no candidates', () => { + expect(testRenderFeature(renderFeature('MultiPoint', [0, 0]), [0, 0], TOL)).toEqual([]) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js b/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js new file mode 100644 index 000000000..de75e26ff --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js @@ -0,0 +1,81 @@ +/** + * Snap indicator — a single OL VectorLayer that shows a circle at the active + * snap candidate position. + * + * Vertex snap → orange semi-transparent circle (snapVertex color) + * Edge snap → blue semi-transparent circle (snapEdge color) + * + * Uses Style.renderer (single canvas call) so the circle renders correctly + * at fractional CSS scale factors. + */ + +import VectorLayer from 'ol/layer/Vector.js' +import VectorSource from 'ol/source/Vector.js' +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' +import { Style } from 'ol/style.js' + +const RADIUS_PX = 10 + +const makeRenderer = (color) => (coords, state) => { + const ctx = state.context + const [cx, cy] = coords + ctx.beginPath() + ctx.arc(cx, cy, RADIUS_PX * state.pixelRatio, 0, Math.PI * 2) + ctx.fillStyle = color + ctx.fill() +} + +const makeStyles = (colors) => ({ + vertex: new Style({ renderer: makeRenderer(colors.snapVertex) }), + edge: new Style({ renderer: makeRenderer(colors.snapEdge) }) +}) + +export const createSnapIndicator = (map, colors) => { + let styles = makeStyles(colors) + const source = new VectorSource() + const layer = new VectorLayer({ + source, + style: (f) => styles[f.get('snapType')] ?? null, + zIndex: 200, + updateWhileAnimating: true, + updateWhileInteracting: true + }) + map.addLayer(layer) + + const feature = new Feature() + let showing = false + + return { + show (coord, type) { + feature.setGeometry(new Point(coord)) + feature.set('snapType', type, true) + if (showing) { + source.changed() + } else { + source.addFeature(feature) + showing = true + } + }, + + hide () { + if (!showing) { + return + } + source.clear() + showing = false + }, + + updateColors (newColors) { + styles = makeStyles(newColors) + if (showing) { + source.changed() + } + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js b/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js new file mode 100644 index 000000000..4a75acf53 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js @@ -0,0 +1,72 @@ +import { createSnapIndicator } from './snapIndicator.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const colors = { snapVertex: '#sv', snapEdge: '#se' } + +const setup = () => { + const map = createFakeMap() + const indicator = createSnapIndicator(map, colors) + const layer = map.layers[0] + return { map, indicator, layer, source: layer.getSource() } +} + +const renderedColor = (layer, feature) => { + const ctx = { beginPath: jest.fn(), arc: jest.fn(), fill: jest.fn(), fillStyle: null } + layer.getStyle()(feature).getRenderer()([5, 6], { context: ctx, pixelRatio: 2 }) + return { color: ctx.fillStyle, arc: ctx.arc.mock.calls[0] } +} + +test('show places a single reused feature; repeated shows move it rather than duplicate', () => { + const { indicator, source } = setup() + indicator.show([10, 10], 'vertex') + indicator.show([20, 20], 'edge') + expect(source.getFeatures()).toHaveLength(1) + expect(source.getFeatures()[0].getGeometry().getCoordinates()).toEqual([20, 20]) + expect(source.getFeatures()[0].get('snapType')).toBe('edge') +}) + +test('vertex and edge snaps render circles in their own colours, scaled by pixelRatio', () => { + const { indicator, layer, source } = setup() + indicator.show([10, 10], 'vertex') + const feature = source.getFeatures()[0] + expect(renderedColor(layer, feature)).toEqual({ color: colors.snapVertex, arc: [5, 6, 20, 0, Math.PI * 2] }) + + feature.set('snapType', 'edge', true) + expect(renderedColor(layer, feature).color).toBe(colors.snapEdge) + + feature.set('snapType', 'mystery', true) + expect(layer.getStyle()(feature)).toBeNull() +}) + +test('hide clears the circle and is safe to call when nothing is showing', () => { + const { indicator, source } = setup() + indicator.hide() // nothing showing — no-op + indicator.show([10, 10], 'vertex') + indicator.hide() + expect(source.getFeatures()).toHaveLength(0) + indicator.show([1, 1], 'vertex') // can show again after hiding + expect(source.getFeatures()).toHaveLength(1) +}) + +test('colour updates apply to subsequent renders, refreshing a visible circle', () => { + const { indicator, layer, source } = setup() + indicator.show([10, 10], 'vertex') + indicator.updateColors({ snapVertex: '#new', snapEdge: '#se' }) + expect(renderedColor(layer, source.getFeatures()[0]).color).toBe('#new') + indicator.updateColors(colors) // also fine while hidden +}) + +test('colour updates while hidden skip the redraw', () => { + const { indicator, source } = setup() + const changed = jest.spyOn(source, 'changed') + indicator.updateColors({ snapVertex: '#x', snapEdge: '#y' }) // nothing showing yet + expect(changed).not.toHaveBeenCalled() +}) + +test('remove clears the source and detaches the layer', () => { + const { map, indicator, source } = setup() + indicator.show([10, 10], 'vertex') + indicator.remove() + expect(source.getFeatures()).toHaveLength(0) + expect(map.removeLayer).toHaveBeenCalled() +}) diff --git a/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js b/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js new file mode 100644 index 000000000..67279e5b5 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js @@ -0,0 +1,57 @@ +/** + * Custom OL Interaction that intercepts pointer events and rewrites + * mapBrowserEvent.coordinate to the nearest snap candidate before any draw + * or modify interaction sees it. + * + * Coordinate snapping applies to all pointer events (pointermove, pointerdown, + * pointerup, singleclick) so that both rubberbanding and vertex placement are snapped. + * The visual indicator is only updated on pointermove. + * + * Must be added to the map AFTER the Draw/Modify interaction so it is processed + * first (OL iterates interactions in reverse-add order). + * snapManager.reattach() handles this after each mode change. + */ + +import Interaction from 'ol/interaction/Interaction.js' + +const SNAP_EVENTS = new Set(['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click']) + +const processSnapEvent = (mapBrowserEvent, engine, indicator, snapRadius, isIndicatorActive) => { + const { type } = mapBrowserEvent + + if (type === 'pointerout' || type === 'pointerleave') { + indicator.hide() + return + } + + if (!SNAP_EVENTS.has(type)) { + return + } + + const result = engine.query(mapBrowserEvent.coordinate, snapRadius) + if (result) { + mapBrowserEvent.coordinate = result.coord.slice() + } + + // Only update indicator during free mouse movement — hide during drag, no-op for clicks + if (type === 'pointermove' && isIndicatorActive()) { + result ? indicator.show(result.coord, result.type) : indicator.hide() + } else if (type === 'pointerdrag') { + indicator.hide() + } else { + // no indicator update for click/down/up events + } +} + +export const createSnapInteraction = (engine, indicator, snapRadius, isIndicatorActive) => { + const interaction = new Interaction({ + handleEvent (mapBrowserEvent) { + if (interaction.getActive()) { + processSnapEvent(mapBrowserEvent, engine, indicator, snapRadius, isIndicatorActive) + } + return true + } + }) + + return interaction +} diff --git a/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js b/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js new file mode 100644 index 000000000..c0d83c9f0 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js @@ -0,0 +1,68 @@ +import { createSnapInteraction } from './snapInteraction.js' + +const setup = ({ indicatorActive = true } = {}) => { + const engine = { query: jest.fn(() => ({ type: 'vertex', coord: [9, 9] })) } + const indicator = { show: jest.fn(), hide: jest.fn() } + const interaction = createSnapInteraction(engine, indicator, 12, () => indicatorActive) + const fire = (type, coordinate = [1, 1]) => { + const event = { type, coordinate } + const result = interaction.handleEvent(event) + return { event, result } + } + return { engine, indicator, interaction, fire } +} + +test('always lets the event continue to other interactions', () => { + const { fire } = setup() + expect(fire('pointermove').result).toBe(true) + expect(fire('wheel').result).toBe(true) +}) + +test('does nothing while inactive', () => { + const { engine, interaction, fire } = setup() + interaction.setActive(false) + const { event } = fire('pointermove') + expect(engine.query).not.toHaveBeenCalled() + expect(event.coordinate).toEqual([1, 1]) +}) + +test('rewrites the event coordinate to a copy of the snap candidate for all pointer gestures', () => { + const { engine, fire } = setup() + for (const type of ['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click']) { + const { event } = fire(type) + expect(event.coordinate).toEqual([9, 9]) + expect(event.coordinate).not.toBe(engine.query.mock.results.at(-1).value.coord) // copy, not shared + } +}) + +test('the indicator follows free mouse movement only', () => { + const { engine, indicator, fire } = setup() + fire('pointermove') + expect(indicator.show).toHaveBeenCalledWith([9, 9], 'vertex') + + engine.query.mockReturnValue(null) + fire('pointermove') + expect(indicator.hide).toHaveBeenCalledTimes(1) + + engine.query.mockReturnValue({ type: 'edge', coord: [5, 5] }) + fire('pointerdrag') + expect(indicator.hide).toHaveBeenCalledTimes(2) // hidden during drags + fire('singleclick') + expect(indicator.show).toHaveBeenCalledTimes(1) // clicks never touch the indicator +}) + +test('the indicator stays untouched during pointermove when the indicator is gated off', () => { + const { indicator, fire } = setup({ indicatorActive: false }) + const { event } = fire('pointermove') + expect(event.coordinate).toEqual([9, 9]) // snapping still applies + expect(indicator.show).not.toHaveBeenCalled() + expect(indicator.hide).not.toHaveBeenCalled() +}) + +test('leaving the map hides the indicator without querying; unrelated events are ignored', () => { + const { engine, indicator, fire } = setup() + fire('pointerout') + expect(indicator.hide).toHaveBeenCalled() + fire('wheel') + expect(engine.query).not.toHaveBeenCalled() +}) diff --git a/plugins/draw/src/adapters/openlayers/snap/snapManager.js b/plugins/draw/src/adapters/openlayers/snap/snapManager.js new file mode 100644 index 000000000..ed5963478 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapManager.js @@ -0,0 +1,99 @@ +/** + * Snap manager — orchestrates the snap engine, indicator, and OL interaction. + * + * Returned as manager.snap; null when no snapLayers are configured. + * + * Interface used by draw and edit modes: + * snap.apply(coord) — query + show/hide indicator; returns snapped coord or original + * snap.hideIndicator() — explicit hide (e.g. on touch/keyboard commit) + * snap.setActive(bool) — enable / disable (wired to the UI snap toggle) + * snap.reattach() — re-add the OL interaction after a mode change so it stays + * last-added (= first to process pointermove events) + * snap.destroy() — full cleanup + */ + +import { createSnapEngine } from './snapEngine.js' +import { createSnapIndicator } from './snapIndicator.js' +import { createSnapInteraction } from './snapInteraction.js' + +export const createSnapManager = (map, snapLayers, colors, snapRadius) => { + if (!snapLayers?.length) { + return null + } + + const engine = createSnapEngine(map, snapLayers) + const indicator = createSnapIndicator(map, colors) + + let indicatorActive = false + const interaction = createSnapInteraction(engine, indicator, snapRadius, () => indicatorActive) + + map.addInteraction(interaction) + interaction.setActive(false) // matches reducer initial state: snap: false + + let active = false + + return { + /** + * Apply snap at coord. Updates the indicator and returns the snapped + * coordinate, or the original coordinate when no snap candidate is found. + * Returns original coord unchanged when snap is disabled. + */ + snapRadius, + + apply (coord) { + if (!active) { + return coord + } + const result = engine.query(coord, snapRadius) + if (result) { + indicator.show(result.coord, result.type) + return result.coord + } + indicator.hide() + return coord + }, + + hideIndicator () { + indicator.hide() + }, + + setIndicatorActive (value) { + indicatorActive = value + if (!value) { + indicator.hide() + } + }, + + setActive (value) { + active = value + interaction.setActive(value) + if (!value) { + indicator.hide() + } + }, + + /** + * Remove and re-add the OL interaction so it sits at the top of the + * interaction stack (last-added = first to handle pointermove). + * Call after each changeMode() so the interaction always runs before + * the newly added Draw or Modify interaction. + */ + setSnapLayers (layers) { + engine.setLayers(layers === null || layers === undefined ? snapLayers : layers) + }, + + reattach () { + map.removeInteraction(interaction) + map.addInteraction(interaction) + }, + + updateColors (newColors) { + indicator.updateColors(newColors) + }, + + destroy () { + map.removeInteraction(interaction) + indicator.remove() + } + } +} diff --git a/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js b/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js new file mode 100644 index 000000000..0367fa052 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js @@ -0,0 +1,101 @@ +import { createSnapManager } from './snapManager.js' +import { createSnapEngine } from './snapEngine.js' +import { createSnapIndicator } from './snapIndicator.js' +import { createSnapInteraction } from './snapInteraction.js' +import { createFakeMap } from '../__helpers__/harness.js' + +jest.mock('./snapEngine.js', () => ({ + createSnapEngine: jest.fn(() => ({ query: jest.fn(() => null), setLayers: jest.fn() })) +})) +jest.mock('./snapIndicator.js', () => ({ + createSnapIndicator: jest.fn(() => ({ show: jest.fn(), hide: jest.fn(), updateColors: jest.fn(), remove: jest.fn() })) +})) +jest.mock('./snapInteraction.js', () => ({ + createSnapInteraction: jest.fn(() => ({ setActive: jest.fn() })) +})) + +const setup = () => { + const map = createFakeMap() + const snap = createSnapManager(map, ['boundaries'], { snapVertex: '#sv' }, 12) + const engine = createSnapEngine.mock.results.at(-1).value + const indicator = createSnapIndicator.mock.results.at(-1).value + const interaction = createSnapInteraction.mock.results.at(-1).value + return { map, snap, engine, indicator, interaction } +} + +afterEach(() => jest.clearAllMocks()) + +test('no snap layers configured means no snap manager at all', () => { + expect(createSnapManager(createFakeMap(), null, {}, 12)).toBeNull() + expect(createSnapManager(createFakeMap(), [], {}, 12)).toBeNull() +}) + +test('starts attached to the map but inactive, matching the initial snap-off UI state', () => { + const { map, snap, interaction } = setup() + expect(map.interactions).toContain(interaction) + expect(interaction.setActive).toHaveBeenCalledWith(false) + expect(snap.snapRadius).toBe(12) +}) + +describe('apply', () => { + test('returns the coordinate untouched while snap is off', () => { + const { snap, engine } = setup() + expect(snap.apply([1, 2])).toEqual([1, 2]) + expect(engine.query).not.toHaveBeenCalled() + }) + + test('snaps to a candidate and shows the indicator, or hides it on a miss', () => { + const { snap, engine, indicator } = setup() + snap.setActive(true) + engine.query.mockReturnValue({ type: 'vertex', coord: [9, 9] }) + expect(snap.apply([8, 8])).toEqual([9, 9]) + expect(indicator.show).toHaveBeenCalledWith([9, 9], 'vertex') + + engine.query.mockReturnValue(null) + expect(snap.apply([1, 2])).toEqual([1, 2]) + expect(indicator.hide).toHaveBeenCalled() + }) +}) + +test('deactivating snap or the indicator hides the circle; the interaction reads indicator state live', () => { + const { snap, indicator, interaction } = setup() + const isIndicatorActive = createSnapInteraction.mock.calls.at(-1)[3] + snap.setIndicatorActive(true) + expect(isIndicatorActive()).toBe(true) + snap.setIndicatorActive(false) + expect(isIndicatorActive()).toBe(false) + expect(indicator.hide).toHaveBeenCalledTimes(1) + + snap.setActive(false) + expect(interaction.setActive).toHaveBeenLastCalledWith(false) + expect(indicator.hide).toHaveBeenCalledTimes(2) +}) + +test('setSnapLayers forwards new layers, falling back to the configured set when cleared', () => { + const { snap, engine } = setup() + snap.setSnapLayers(['other']) + expect(engine.setLayers).toHaveBeenCalledWith(['other']) + snap.setSnapLayers(null) + expect(engine.setLayers).toHaveBeenCalledWith(['boundaries']) + snap.setSnapLayers(undefined) + expect(engine.setLayers).toHaveBeenLastCalledWith(['boundaries']) +}) + +test('reattach re-adds the interaction so it processes pointer events first', () => { + const { map, snap, interaction } = setup() + map.interactions.push({ id: 'draw' }) // a mode was just added + snap.reattach() + expect(map.removeInteraction).toHaveBeenCalledWith(interaction) + expect(map.interactions.at(-1)).toBe(interaction) +}) + +test('colour updates and explicit hides reach the indicator; destroy removes interaction and indicator', () => { + const { map, snap, indicator, interaction } = setup() + snap.hideIndicator() + expect(indicator.hide).toHaveBeenCalled() + snap.updateColors({ snapVertex: '#new' }) + expect(indicator.updateColors).toHaveBeenCalledWith({ snapVertex: '#new' }) + snap.destroy() + expect(map.removeInteraction).toHaveBeenCalledWith(interaction) + expect(indicator.remove).toHaveBeenCalled() +}) diff --git a/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js b/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js new file mode 100644 index 000000000..2ac3dc94a --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js @@ -0,0 +1,103 @@ +/** + * Pure geometry helpers for multi-ring/multi-part geometry support. + * Handles coordinate transformations between flat arrays and hierarchical GeoJSON. + * Supports Polygon, MultiPolygon, LineString, MultiLineString. + */ + +export const getCoords = (geom) => { + if (!geom?.coordinates) { + return [] + } + switch (geom.type) { + case 'LineString': return geom.coordinates + case 'Polygon': return geom.coordinates.flatMap(ring => ring.slice(0, -1)) + case 'MultiLineString': return geom.coordinates.flat(1) + case 'MultiPolygon': return geom.coordinates.flatMap(poly => poly.flatMap(ring => ring.slice(0, -1))) + default: return [] + } +} + +/** + * Segment metadata for each ring or part. + * { start, length, path, closed } + */ +export const getRingSegments = (geom) => { + if (!geom?.coordinates) { + return [] + } + const segments = [] + let start = 0 + + switch (geom.type) { + case 'LineString': + segments.push({ start: 0, length: geom.coordinates.length, path: [], closed: false }) + break + case 'Polygon': + geom.coordinates.forEach((ring, i) => { + const len = ring.length - 1 + segments.push({ start, length: len, path: [i], closed: true }) + start += len + }) + break + case 'MultiLineString': + geom.coordinates.forEach((line, i) => { + segments.push({ start, length: line.length, path: [i], closed: false }) + start += line.length + }) + break + case 'MultiPolygon': + geom.coordinates.forEach((polygon, pi) => { + polygon.forEach((ring, ri) => { + const len = ring.length - 1 + segments.push({ start, length: len, path: [pi, ri], closed: true }) + start += len + }) + }) + break + default: + break + } + + return segments +} + +/** Find which segment a flat vertex index belongs to. */ +export const getSegmentForIndex = (segments, flatIdx) => { + for (const seg of segments) { + if (flatIdx >= seg.start && flatIdx < seg.start + seg.length) { + return { segment: seg, localIdx: flatIdx - seg.start } + } + } + return null +} + +/** Return a reference to the coordinate array at a hierarchical path. */ +export const getModifiableCoords = (geojsonGeometry, path) => { + let coords = geojsonGeometry.coordinates + for (const idx of path) { + coords = coords[idx] + } + return coords +} + +/** Compute midpoints for all segments, respecting open/closed ring boundaries. */ +export const getMidpoints = (geom) => { + const coords = getCoords(geom) + const segments = getRingSegments(geom) + if (!coords.length || !segments.length) { + return [] + } + + const midpoints = [] + for (const seg of segments) { + const count = seg.closed ? seg.length : seg.length - 1 + for (let i = 0; i < count; i++) { + const idx = seg.start + i + const nextIdx = seg.start + ((i + 1) % seg.length) + const [x1, y1] = coords[idx] + const [x2, y2] = coords[nextIdx] + midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2]) + } + } + return midpoints +} diff --git a/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js b/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js new file mode 100644 index 000000000..caccbe7cb --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js @@ -0,0 +1,76 @@ +import { getCoords, getRingSegments, getSegmentForIndex, getModifiableCoords, getMidpoints } from './geometryHelpers.js' + +const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]] +const HOLE = [[2, 2], [4, 2], [4, 4], [2, 2]] +const LINE = [[0, 0], [10, 0], [20, 0]] + +describe('getCoords flattens editable vertices (closing coords stripped)', () => { + test.each([ + ['LineString', { type: 'LineString', coordinates: LINE }, 3], + ['Polygon with hole', { type: 'Polygon', coordinates: [SQUARE, HOLE] }, 7], + ['MultiLineString', { type: 'MultiLineString', coordinates: [LINE, LINE] }, 6], + ['MultiPolygon', { type: 'MultiPolygon', coordinates: [[SQUARE], [SQUARE, HOLE]] }, 11] + ])('%s', (_, geom, expected) => { + expect(getCoords(geom)).toHaveLength(expected) + }) + + test('missing or unsupported geometry yields an empty array', () => { + expect(getCoords(null)).toEqual([]) + expect(getCoords({ type: 'Point' })).toEqual([]) + expect(getCoords({ type: 'Point', coordinates: [0, 0] })).toEqual([]) + }) +}) + +describe('getRingSegments maps flat indices back to parts', () => { + test('describes each ring/part with its offset, length, path and closure', () => { + expect(getRingSegments({ type: 'LineString', coordinates: LINE })) + .toEqual([{ start: 0, length: 3, path: [], closed: false }]) + expect(getRingSegments({ type: 'Polygon', coordinates: [SQUARE, HOLE] })) + .toEqual([ + { start: 0, length: 4, path: [0], closed: true }, + { start: 4, length: 3, path: [1], closed: true } + ]) + expect(getRingSegments({ type: 'MultiLineString', coordinates: [LINE, LINE] })[1]) + .toEqual({ start: 3, length: 3, path: [1], closed: false }) + expect(getRingSegments({ type: 'MultiPolygon', coordinates: [[SQUARE], [HOLE]] })[1]) + .toEqual({ start: 4, length: 3, path: [1, 0], closed: true }) + }) + + test('missing or unsupported geometry yields no segments', () => { + expect(getRingSegments(null)).toEqual([]) + expect(getRingSegments({ type: 'Point', coordinates: [0, 0] })).toEqual([]) + }) +}) + +describe('getSegmentForIndex', () => { + const segments = getRingSegments({ type: 'Polygon', coordinates: [SQUARE, HOLE] }) + + test('finds the owning segment and the local index within it', () => { + expect(getSegmentForIndex(segments, 5)).toEqual({ segment: segments[1], localIdx: 1 }) + }) + + test('returns null beyond the last segment', () => { + expect(getSegmentForIndex(segments, 7)).toBeNull() + }) +}) + +test('getModifiableCoords returns a live reference at a hierarchical path', () => { + const geom = { type: 'MultiPolygon', coordinates: [[SQUARE], [HOLE]] } + const ring = getModifiableCoords(geom, [1, 0]) + ring[0] = [3, 3] + expect(geom.coordinates[1][0][0]).toEqual([3, 3]) + expect(getModifiableCoords(geom, [])).toBe(geom.coordinates) +}) + +describe('getMidpoints', () => { + test('closed rings wrap (one midpoint per vertex); open lines do not', () => { + expect(getMidpoints({ type: 'Polygon', coordinates: [SQUARE] })) + .toEqual([[5, 0], [10, 5], [5, 10], [0, 5]]) + expect(getMidpoints({ type: 'LineString', coordinates: LINE })) + .toEqual([[5, 0], [15, 0]]) + }) + + test('empty geometry yields no midpoints', () => { + expect(getMidpoints({ type: 'LineString', coordinates: [] })).toEqual([]) + }) +}) diff --git a/plugins/draw/src/adapters/openlayers/utils/olCoords.js b/plugins/draw/src/adapters/openlayers/utils/olCoords.js new file mode 100644 index 000000000..de730e136 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/utils/olCoords.js @@ -0,0 +1,35 @@ +/** + * Thin helpers bridging OL's array-based pixel/coordinate API + * and the {x, y} object convention used in touch/keyboard handlers. + */ + +/** OL coordinate [e, n] → screen pixel { x, y } */ +export const coordToPixel = (map, coord) => { + const px = map.getPixelFromCoordinate(coord) + if (!px) { return null } + return { x: px[0], y: px[1] } +} + +/** Screen pixel { x, y } → OL coordinate [e, n] */ +export const pixelToCoord = (map, pixel) => { + return map.getCoordinateFromPixel([pixel.x, pixel.y]) +} + +/** Pixel distance between two { x, y } points */ +export const pixelDist = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) + +/** OL pixel array [x, y] → { x, y } */ +export const arrayToPixel = ([x, y]) => ({ x, y }) + +/** { x, y } → OL pixel array [x, y] */ +export const pixelToArray = ({ x, y }) => [x, y] + +/** + * Nudge a coordinate by (dx, dy) screen pixels. + * Converts pixel offset to map coordinate delta using the current resolution. + */ +export const nudgeCoord = (map, coord, dx, dy) => { + const px = map.getPixelFromCoordinate(coord) + if (!px) { return coord } + return map.getCoordinateFromPixel([px[0] + dx, px[1] + dy]) +} diff --git a/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js b/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js new file mode 100644 index 000000000..ee7576387 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js @@ -0,0 +1,22 @@ +import { coordToPixel, pixelToCoord, pixelDist, arrayToPixel, pixelToArray, nudgeCoord } from './olCoords.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const map = createFakeMap() + +test('converts between OL arrays and {x, y} pixel objects', () => { + expect(coordToPixel(map, [10, 20])).toEqual({ x: 10, y: 20 }) + expect(pixelToCoord(map, { x: 10, y: 20 })).toEqual([10, 20]) + expect(arrayToPixel([1, 2])).toEqual({ x: 1, y: 2 }) + expect(pixelToArray({ x: 1, y: 2 })).toEqual([1, 2]) + expect(pixelDist({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5) +}) + +test('nudgeCoord offsets a coordinate by screen pixels', () => { + expect(nudgeCoord(map, [10, 20], 5, -3)).toEqual([15, 17]) +}) + +test('unprojectable coordinates return null / stay put', () => { + const blindMap = { getPixelFromCoordinate: () => null } + expect(coordToPixel(blindMap, [1, 1])).toBeNull() + expect(nudgeCoord(blindMap, [1, 1], 5, 5)).toEqual([1, 1]) +}) diff --git a/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js b/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js new file mode 100644 index 000000000..adea36456 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js @@ -0,0 +1,22 @@ +/** + * Coordinate-layout helpers for ol/interaction/Draw sketch geometries. + * + * During drawing OL keeps a trailing rubber-band coordinate, and for Polygons + * additionally appends a copy of the first coordinate to close the ring: + * LineString: [...placed, rubber_band] + * Polygon ring: [...placed, rubber_band, v0_closing] + * + * This is the single place that layout is encoded — if an OL upgrade changes + * the Draw interaction's sketch bookkeeping, update it here. + */ +const TRAILING_COORDS = { Polygon: 2, LineString: 1 } + +/** Placed vertex coordinates of an in-progress sketch geometry. */ +export const getPlacedSketchCoords = (geom) => { + const type = geom.getType() + const coords = type === 'Polygon' ? (geom.getCoordinates()[0] ?? []) : geom.getCoordinates() + return coords.slice(0, -TRAILING_COORDS[type]) +} + +/** Last vertex committed by OL's Draw interaction, or null if none placed yet. */ +export const getLastPlacedSketchCoord = (geom) => getPlacedSketchCoords(geom).at(-1) ?? null diff --git a/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js b/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js new file mode 100644 index 000000000..7124e33a3 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js @@ -0,0 +1,18 @@ +import Polygon from 'ol/geom/Polygon.js' +import LineString from 'ol/geom/LineString.js' +import { getPlacedSketchCoords, getLastPlacedSketchCoord } from './sketchHelpers.js' + +test('strips the rubber band (and the closing coord for polygons) to expose placed vertices', () => { + const poly = new Polygon([[[0, 0], [10, 0], [5, 5], [0, 0]]]) // 2 placed + rubber + closing + expect(getPlacedSketchCoords(poly)).toEqual([[0, 0], [10, 0]]) + expect(getLastPlacedSketchCoord(poly)).toEqual([10, 0]) + + const line = new LineString([[0, 0], [10, 0], [5, 5]]) // 2 placed + rubber + expect(getPlacedSketchCoords(line)).toEqual([[0, 0], [10, 0]]) + expect(getLastPlacedSketchCoord(line)).toEqual([10, 0]) +}) + +test('empty or rubber-band-only sketches have no placed vertices', () => { + expect(getPlacedSketchCoords(new Polygon([]))).toEqual([]) + expect(getLastPlacedSketchCoord(new LineString([]))).toBeNull() +}) diff --git a/plugins/draw/src/adapters/openlayers/utils/touchTarget.js b/plugins/draw/src/adapters/openlayers/utils/touchTarget.js new file mode 100644 index 000000000..0a3263240 --- /dev/null +++ b/plugins/draw/src/adapters/openlayers/utils/touchTarget.js @@ -0,0 +1,8 @@ +// Re-export centralized touch target utilities from shared implementation +export { + createTouchTarget, + applyTouchTargetColors, + showTouchTarget, + hideTouchTarget, + isOnTouchTarget +} from '../../../utils/touchTarget.js' diff --git a/plugins/draw/src/api/addFeature.js b/plugins/draw/src/api/addFeature.js new file mode 100644 index 000000000..daad75a53 --- /dev/null +++ b/plugins/draw/src/api/addFeature.js @@ -0,0 +1,22 @@ +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +export const addFeature = ({ mapProvider, services }, feature) => { + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return + } + + const { stroke, fill, strokeWidth, properties, ...featureRest } = feature + const flatFeature = { + ...featureRest, + properties: { + ...properties, + ...flattenStyleProperties({ stroke, fill, strokeWidth }) + } + } + + draw.add(flatFeature) + eventBus.emit('draw:add', flatFeature) +} diff --git a/plugins/draw/src/api/addFeature.test.js b/plugins/draw/src/api/addFeature.test.js new file mode 100644 index 000000000..92de83eb2 --- /dev/null +++ b/plugins/draw/src/api/addFeature.test.js @@ -0,0 +1,41 @@ +import { addFeature } from './addFeature.js' +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +jest.mock('../utils/flattenStyleProperties.js', () => ({ + flattenStyleProperties: jest.fn(() => ({ _flat: true })) +})) + +const setup = (draw) => { + const eventBus = { emit: jest.fn() } + return { context: { mapProvider: { draw }, services: { eventBus } }, eventBus } +} + +beforeEach(() => jest.clearAllMocks()) + +describe('addFeature', () => { + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = setup(undefined) + addFeature(context, { id: 'a' }) + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('flattens style properties and adds the feature', () => { + const draw = { add: jest.fn() } + const { context, eventBus } = setup(draw) + const feature = { id: 'a', geometry: {}, stroke: 'red', fill: 'blue', strokeWidth: 2, properties: { name: 'x' } } + + addFeature(context, feature) + + expect(flattenStyleProperties).toHaveBeenCalledWith({ stroke: 'red', fill: 'blue', strokeWidth: 2 }) + const expected = { id: 'a', geometry: {}, properties: { name: 'x', _flat: true } } + expect(draw.add).toHaveBeenCalledWith(expected) + expect(eventBus.emit).toHaveBeenCalledWith('draw:add', expected) + }) + + test('handles a feature without existing properties', () => { + const draw = { add: jest.fn() } + const { context } = setup(draw) + addFeature(context, { id: 'b', geometry: {} }) + expect(draw.add).toHaveBeenCalledWith({ id: 'b', geometry: {}, properties: { _flat: true } }) + }) +}) diff --git a/plugins/draw/src/api/deleteFeature.js b/plugins/draw/src/api/deleteFeature.js new file mode 100644 index 000000000..3e9096b96 --- /dev/null +++ b/plugins/draw/src/api/deleteFeature.js @@ -0,0 +1,11 @@ +export const deleteFeature = ({ mapProvider, services }, featureId) => { + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return + } + + draw.delete(featureId) + eventBus.emit('draw:delete', { featureId }) +} diff --git a/plugins/draw/src/api/deleteFeature.test.js b/plugins/draw/src/api/deleteFeature.test.js new file mode 100644 index 000000000..948c43345 --- /dev/null +++ b/plugins/draw/src/api/deleteFeature.test.js @@ -0,0 +1,24 @@ +import { deleteFeature } from './deleteFeature.js' + +const setup = (draw) => { + const eventBus = { emit: jest.fn() } + return { context: { mapProvider: { draw }, services: { eventBus } }, eventBus } +} + +describe('deleteFeature', () => { + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = setup(undefined) + deleteFeature(context, 'id1') + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('deletes the feature and emits the delete event', () => { + const draw = { delete: jest.fn() } + const { context, eventBus } = setup(draw) + + deleteFeature(context, 'id1') + + expect(draw.delete).toHaveBeenCalledWith('id1') + expect(eventBus.emit).toHaveBeenCalledWith('draw:delete', { featureId: 'id1' }) + }) +}) diff --git a/plugins/draw/src/api/editFeature.js b/plugins/draw/src/api/editFeature.js new file mode 100644 index 000000000..b7388a955 --- /dev/null +++ b/plugins/draw/src/api/editFeature.js @@ -0,0 +1,54 @@ +import { MAP_SIZE_SCALES } from '../defaults.js' +import { validateGeometry } from '../validation/validateGeometry.js' + +export const editFeature = ({ appState, appConfig, mapState, pluginConfig, pluginState, mapProvider, services }, featureId, options = {}) => { + const { dispatch } = pluginState + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return false + } + + const existingFeature = draw.get(featureId) + if (!existingFeature) { + return false + } + + // Per-call callback overrides the plugin-level one; events.js reads this on every commit. + draw._geometryValidator = options.onGeometryChange ?? pluginConfig.onGeometryChange + + const editModeMap = { LineString: 'edit_line', Polygon: 'edit_polygon' } + eventBus.emit('draw:editstart', { mode: editModeMap[existingFeature.geometry.type] }) + + const snapLayers = options.snapLayers === undefined ? (pluginConfig.snapLayers ?? null) : options.snapLayers + draw.setSnapLayers(snapLayers) + dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + + draw.changeMode('edit_vertex', { + container: appState.layoutRefs.viewportRef.current, + deleteVertexButtonId: `${appConfig.id}-draw-delete-point`, + undoButtonId: `${appConfig.id}-draw-undo`, + isPanEnabled: appState.interfaceType !== 'keyboard', + interfaceType: appState.interfaceType, + scale: MAP_SIZE_SCALES[mapState.mapSize], + featureId, + getSnapEnabled: () => draw.isSnapEnabled() + }) + + const feature = draw.get(featureId) + dispatch({ + type: 'SET_FEATURE', + payload: { feature, tempFeature: feature } + }) + + dispatch({ type: 'SET_MODE', payload: 'edit_vertex' }) + + // Seed the Done-button gate and the stroke from the feature's starting validity + // so an already invalid feature opens dashed and cannot be "finished" until fixed. + const { valid } = validateGeometry(feature, { phase: 'edit-start', mode: 'edit_vertex' }, { onGeometryChange: draw._geometryValidator }) + dispatch({ type: 'SET_GEOMETRY_VALID', payload: valid }) + draw.setInvalid?.(!valid) + + return true +} diff --git a/plugins/draw/src/api/editFeature.test.js b/plugins/draw/src/api/editFeature.test.js new file mode 100644 index 000000000..96c4aedee --- /dev/null +++ b/plugins/draw/src/api/editFeature.test.js @@ -0,0 +1,134 @@ +import { editFeature } from './editFeature.js' + +jest.mock('../defaults.js', () => ({ MAP_SIZE_SCALES: { medium: 1.5, large: 2 } })) + +const makeContext = (overrides = {}) => { + const dispatch = jest.fn() + const eventBus = { emit: jest.fn() } + const draw = { + get: jest.fn(() => ({ id: 'f1', geometry: { type: 'Polygon' } })), + setSnapLayers: jest.fn(), + changeMode: jest.fn(), + isSnapEnabled: jest.fn(() => true) + } + const context = { + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, + appConfig: { id: 'app' }, + mapState: { mapSize: 'medium' }, + pluginConfig: { snapLayers: ['pc-layer'] }, + pluginState: { dispatch }, + mapProvider: { draw }, + services: { eventBus }, + ...overrides + } + return { context, dispatch, eventBus, draw } +} + +beforeEach(() => jest.clearAllMocks()) + +describe('editFeature', () => { + test('returns false when there is no draw instance', () => { + const { context } = makeContext({ mapProvider: { draw: null } }) + expect(editFeature(context, 'f1')).toBe(false) + }) + + test('returns false when the feature does not exist', () => { + const { context, draw, eventBus } = makeContext() + draw.get.mockReturnValue(undefined) + expect(editFeature(context, 'missing')).toBe(false) + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('enters edit mode for a polygon and wires all options', () => { + const { context, dispatch, eventBus, draw } = makeContext() + + const result = editFeature(context, 'f1') + + expect(result).toBe(true) + expect(eventBus.emit).toHaveBeenCalledWith('draw:editstart', { mode: 'edit_polygon' }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['pc-layer']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + + expect(draw.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ + container: 'viewport', + deleteVertexButtonId: 'app-draw-delete-point', + undoButtonId: 'app-draw-undo', + isPanEnabled: true, + interfaceType: 'mouse', + scale: 1.5, + featureId: 'f1' + })) + + const opts = draw.changeMode.mock.calls[0][1] + expect(opts.getSnapEnabled()).toBe(true) + expect(draw.isSnapEnabled).toHaveBeenCalled() + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_FEATURE', payload: { feature: { id: 'f1', geometry: { type: 'Polygon' } }, tempFeature: { id: 'f1', geometry: { type: 'Polygon' } } } }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'edit_vertex' }) + }) + + test('uses edit_line mode for a line string', () => { + const { context, eventBus, draw } = makeContext() + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'LineString' } }) + editFeature(context, 'f1') + expect(eventBus.emit).toHaveBeenCalledWith('draw:editstart', { mode: 'edit_line' }) + }) + + test('stores a per-call onGeometryChange validator, overriding the plugin-level one', () => { + const pluginOnGeometryChange = jest.fn() + const onGeometryChange = jest.fn() + const { context, draw } = makeContext({ pluginConfig: { snapLayers: ['pc-layer'], onGeometryChange: pluginOnGeometryChange } }) + editFeature(context, 'f1', { onGeometryChange }) + expect(draw._geometryValidator).toBe(onGeometryChange) + }) + + test('seeds the geometry-valid gate from the feature starting validity', () => { + const { context, dispatch, draw } = makeContext() + // A valid square → gate opens. + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] } }) + editFeature(context, 'f1') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + }) + + test('seeds the gate closed for an already self-intersecting feature', () => { + const { context, dispatch, draw } = makeContext() + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } }) + editFeature(context, 'f1') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + }) + + test('seeds the stroke alongside the gate — an invalid feature opens edit mode dashed', () => { + const { context, draw } = makeContext() + draw.setInvalid = jest.fn() + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } }) + editFeature(context, 'f1') + expect(draw.setInvalid).toHaveBeenCalledWith(true) + + draw.setInvalid.mockClear() + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] } }) + editFeature(context, 'f1') + expect(draw.setInvalid).toHaveBeenCalledWith(false) + }) + + test('disables panning for the keyboard interface', () => { + const { context, draw } = makeContext({ + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'keyboard' } + }) + editFeature(context, 'f1') + expect(draw.changeMode.mock.calls[0][1].isPanEnabled).toBe(false) + }) + + test('prefers explicit option snapLayers over the plugin config', () => { + const { context, draw, dispatch } = makeContext() + editFeature(context, 'f1', { snapLayers: ['opt-layer'] }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['opt-layer']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + }) + + test('falls back to null snapLayers when neither option nor config is set', () => { + const { context, draw, dispatch } = makeContext({ pluginConfig: {} }) + editFeature(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(null) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: false }) + }) +}) diff --git a/plugins/draw/src/api/merge.js b/plugins/draw/src/api/merge.js new file mode 100644 index 000000000..42c46f7c2 --- /dev/null +++ b/plugins/draw/src/api/merge.js @@ -0,0 +1,30 @@ +import { mergePolygons } from '../utils/spatial.js' + +/** + * Merge multiple contiguous polygons into a single polygon. + * + * Pure computation, like split — this does not touch the feature store. The + * caller is responsible for deleting the original features and adding the + * merged result (see the draw:merge event). + * + * @param {object} context - plugin context + * @param {string[]} featureIds - IDs of the polygons to merge + * @returns {object|null} the merged GeoJSON feature, or null if the merge failed + */ +export const merge = ({ mapProvider, services }, featureIds) => { + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return null + } + + const polygons = featureIds.map((id) => draw.get(id)) + const feature = mergePolygons(polygons) + + if (feature) { + eventBus.emit('draw:merge', { originalFeatureIds: featureIds, feature }) + } + + return feature +} diff --git a/plugins/draw/src/api/merge.test.js b/plugins/draw/src/api/merge.test.js new file mode 100644 index 000000000..f37caf85f --- /dev/null +++ b/plugins/draw/src/api/merge.test.js @@ -0,0 +1,57 @@ +import { merge } from './merge.js' +import { mergePolygons } from '../utils/spatial.js' + +jest.mock('../utils/spatial.js', () => ({ mergePolygons: jest.fn() })) + +const makeContext = (overrides = {}) => { + const eventBus = { emit: jest.fn() } + const draw = { get: jest.fn((id) => ({ id })) } + const context = { + mapProvider: { draw }, + services: { eventBus }, + ...overrides + } + return { context, draw, eventBus } +} + +beforeEach(() => jest.clearAllMocks()) + +describe('merge', () => { + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = makeContext({ mapProvider: { draw: null } }) + expect(merge(context, ['a', 'b'])).toBeNull() + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('resolves each feature id via the adapter and merges them', () => { + const { context, draw } = makeContext() + mergePolygons.mockReturnValue({ id: 'a', geometry: { type: 'Polygon', coordinates: [] } }) + + merge(context, ['a', 'b']) + + expect(draw.get).toHaveBeenCalledWith('a') + expect(draw.get).toHaveBeenCalledWith('b') + expect(mergePolygons).toHaveBeenCalledWith([{ id: 'a' }, { id: 'b' }]) + }) + + test('returns the merged feature and emits draw:merge on success', () => { + const { context, eventBus } = makeContext() + const feature = { id: 'a', geometry: { type: 'Polygon', coordinates: [] } } + mergePolygons.mockReturnValue(feature) + + const result = merge(context, ['a', 'b']) + + expect(result).toBe(feature) + expect(eventBus.emit).toHaveBeenCalledWith('draw:merge', { originalFeatureIds: ['a', 'b'], feature }) + }) + + test('returns null and does not emit when the merge fails', () => { + const { context, eventBus } = makeContext() + mergePolygons.mockReturnValue(null) + + const result = merge(context, ['a', 'b']) + + expect(result).toBeNull() + expect(eventBus.emit).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/api/newLine.js b/plugins/draw/src/api/newLine.js new file mode 100644 index 000000000..91ddb4e62 --- /dev/null +++ b/plugins/draw/src/api/newLine.js @@ -0,0 +1,41 @@ +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +// Near-identical to newPolygon.js, differing only in the 'draw_line'/'draw_polygon' +// mode string — kept as two small, explicit entry points rather than one +// mode-parameterised factory. Revisit if a third geometry type is ever added. +export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapState, mapProvider, services }, featureId, options = {}) => { + const { dispatch } = pluginState + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return + } + + eventBus.emit('draw:started', { mode: 'draw_line' }) + + const snapLayers = options.snapLayers === undefined ? (pluginConfig.snapLayers ?? null) : options.snapLayers + draw.setSnapLayers(snapLayers) + dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + + const { stroke, fill, strokeWidth, properties: customProperties, onGeometryChange, ...modeOptions } = options + // Per-call callback overrides the plugin-level one; events.js reads this on every commit. + draw._geometryValidator = onGeometryChange ?? pluginConfig.onGeometryChange + const properties = { + ...customProperties, + ...flattenStyleProperties({ stroke, fill, strokeWidth }) + } + + draw.changeMode('draw_line', { + container: appState.layoutRefs.viewportRef.current, + addVertexButtonId: `${appConfig.id}-draw-add-point`, + interfaceType: appState.interfaceType, + crossHair: mapState.crossHair, + getSnapEnabled: () => draw.isSnapEnabled(), + featureId, + ...modeOptions, + properties + }) + + dispatch({ type: 'SET_MODE', payload: 'draw_line' }) +} diff --git a/plugins/draw/src/api/newLine.test.js b/plugins/draw/src/api/newLine.test.js new file mode 100644 index 000000000..1c893549e --- /dev/null +++ b/plugins/draw/src/api/newLine.test.js @@ -0,0 +1,93 @@ +import { newLine } from './newLine.js' +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +jest.mock('../utils/flattenStyleProperties.js', () => ({ + flattenStyleProperties: jest.fn(() => ({ _flat: true })) +})) + +const makeContext = (overrides = {}) => { + const dispatch = jest.fn() + const eventBus = { emit: jest.fn() } + const draw = { setSnapLayers: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => false) } + const context = { + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, + appConfig: { id: 'app' }, + pluginConfig: { snapLayers: ['pc-layer'] }, + pluginState: { dispatch }, + mapState: { crossHair: true }, + mapProvider: { draw }, + services: { eventBus }, + ...overrides + } + return { context, dispatch, eventBus, draw } +} + +beforeEach(() => jest.clearAllMocks()) + +describe('newLine', () => { + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = makeContext({ mapProvider: { draw: null } }) + newLine(context, 'f1') + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('starts line drawing with flattened style properties', () => { + const { context, dispatch, eventBus, draw } = makeContext() + + newLine(context, 'f1', { stroke: 'red', fill: 'blue', strokeWidth: 3, properties: { name: 'x' }, extra: 'opt' }) + + expect(eventBus.emit).toHaveBeenCalledWith('draw:started', { mode: 'draw_line' }) + expect(flattenStyleProperties).toHaveBeenCalledWith({ stroke: 'red', fill: 'blue', strokeWidth: 3 }) + + expect(draw.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + container: 'viewport', + addVertexButtonId: 'app-draw-add-point', + interfaceType: 'mouse', + crossHair: true, + featureId: 'f1', + extra: 'opt', + properties: { name: 'x', _flat: true } + })) + + const opts = draw.changeMode.mock.calls[0][1] + expect(opts.getSnapEnabled()).toBe(false) + expect(draw.isSnapEnabled).toHaveBeenCalled() + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_line' }) + }) + + test('prefers explicit option snapLayers and flags them', () => { + const { context, draw, dispatch } = makeContext() + newLine(context, 'f1', { snapLayers: ['opt'] }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['opt']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + }) + + test('falls back to the plugin config snapLayers', () => { + const { context, draw } = makeContext() + newLine(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(['pc-layer']) + }) + + test('falls back to null when neither option nor config is set', () => { + const { context, draw, dispatch } = makeContext({ pluginConfig: {} }) + newLine(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(null) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: false }) + }) + + test('stores a per-call onGeometryChange validator without leaking it into mode options', () => { + const { context, draw } = makeContext() + const onGeometryChange = jest.fn() + newLine(context, 'f1', { onGeometryChange }) + expect(draw._geometryValidator).toBe(onGeometryChange) + expect(draw.changeMode.mock.calls[0][1]).not.toHaveProperty('onGeometryChange') + }) + + test('falls back to the plugin-level onGeometryChange validator', () => { + const pluginOnGeometryChange = jest.fn() + const { context, draw } = makeContext({ pluginConfig: { onGeometryChange: pluginOnGeometryChange } }) + newLine(context, 'f1') + expect(draw._geometryValidator).toBe(pluginOnGeometryChange) + }) +}) diff --git a/plugins/draw/src/api/newPolygon.js b/plugins/draw/src/api/newPolygon.js new file mode 100644 index 000000000..055553413 --- /dev/null +++ b/plugins/draw/src/api/newPolygon.js @@ -0,0 +1,41 @@ +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +// Near-identical to newLine.js, differing only in the 'draw_polygon'/'draw_line' +// mode string — kept as two small, explicit entry points rather than one +// mode-parameterised factory. Revisit if a third geometry type is ever added. +export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, mapState, mapProvider, services }, featureId, options = {}) => { + const { dispatch } = pluginState + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return + } + + eventBus.emit('draw:started', { mode: 'draw_polygon' }) + + const snapLayers = options.snapLayers === undefined ? (pluginConfig.snapLayers ?? null) : options.snapLayers + draw.setSnapLayers(snapLayers) + dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + + const { stroke, fill, strokeWidth, properties: customProperties, onGeometryChange, ...modeOptions } = options + // Per-call callback overrides the plugin-level one; events.js reads this on every commit. + draw._geometryValidator = onGeometryChange ?? pluginConfig.onGeometryChange + const properties = { + ...customProperties, + ...flattenStyleProperties({ stroke, fill, strokeWidth }) + } + + draw.changeMode('draw_polygon', { + container: appState.layoutRefs.viewportRef.current, + addVertexButtonId: `${appConfig.id}-draw-add-point`, + interfaceType: appState.interfaceType, + crossHair: mapState.crossHair, + getSnapEnabled: () => draw.isSnapEnabled(), + featureId, + ...modeOptions, + properties + }) + + dispatch({ type: 'SET_MODE', payload: 'draw_polygon' }) +} diff --git a/plugins/draw/src/api/newPolygon.test.js b/plugins/draw/src/api/newPolygon.test.js new file mode 100644 index 000000000..48e6504dc --- /dev/null +++ b/plugins/draw/src/api/newPolygon.test.js @@ -0,0 +1,93 @@ +import { newPolygon } from './newPolygon.js' +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +jest.mock('../utils/flattenStyleProperties.js', () => ({ + flattenStyleProperties: jest.fn(() => ({ _flat: true })) +})) + +const makeContext = (overrides = {}) => { + const dispatch = jest.fn() + const eventBus = { emit: jest.fn() } + const draw = { setSnapLayers: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => true) } + const context = { + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, + appConfig: { id: 'app' }, + pluginConfig: { snapLayers: ['pc-layer'] }, + pluginState: { dispatch }, + mapState: { crossHair: false }, + mapProvider: { draw }, + services: { eventBus }, + ...overrides + } + return { context, dispatch, eventBus, draw } +} + +beforeEach(() => jest.clearAllMocks()) + +describe('newPolygon', () => { + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = makeContext({ mapProvider: { draw: null } }) + newPolygon(context, 'f1') + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('starts polygon drawing with flattened style properties', () => { + const { context, dispatch, eventBus, draw } = makeContext() + + newPolygon(context, 'f1', { stroke: 'red', fill: 'blue', strokeWidth: 3, properties: { name: 'x' }, extra: 'opt' }) + + expect(eventBus.emit).toHaveBeenCalledWith('draw:started', { mode: 'draw_polygon' }) + expect(flattenStyleProperties).toHaveBeenCalledWith({ stroke: 'red', fill: 'blue', strokeWidth: 3 }) + + expect(draw.changeMode).toHaveBeenCalledWith('draw_polygon', expect.objectContaining({ + container: 'viewport', + addVertexButtonId: 'app-draw-add-point', + interfaceType: 'mouse', + crossHair: false, + featureId: 'f1', + extra: 'opt', + properties: { name: 'x', _flat: true } + })) + + const opts = draw.changeMode.mock.calls[0][1] + expect(opts.getSnapEnabled()).toBe(true) + expect(draw.isSnapEnabled).toHaveBeenCalled() + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_polygon' }) + }) + + test('prefers explicit option snapLayers and flags them', () => { + const { context, draw, dispatch } = makeContext() + newPolygon(context, 'f1', { snapLayers: ['opt'] }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['opt']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + }) + + test('falls back to the plugin config snapLayers', () => { + const { context, draw } = makeContext() + newPolygon(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(['pc-layer']) + }) + + test('falls back to null when neither option nor config is set', () => { + const { context, draw, dispatch } = makeContext({ pluginConfig: {} }) + newPolygon(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(null) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: false }) + }) + + test('stores a per-call onGeometryChange validator without leaking it into mode options', () => { + const { context, draw } = makeContext() + const onGeometryChange = jest.fn() + newPolygon(context, 'f1', { onGeometryChange }) + expect(draw._geometryValidator).toBe(onGeometryChange) + expect(draw.changeMode.mock.calls[0][1]).not.toHaveProperty('onGeometryChange') + }) + + test('falls back to the plugin-level onGeometryChange validator', () => { + const pluginOnGeometryChange = jest.fn() + const { context, draw } = makeContext({ pluginConfig: { onGeometryChange: pluginOnGeometryChange } }) + newPolygon(context, 'f1') + expect(draw._geometryValidator).toBe(pluginOnGeometryChange) + }) +}) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js new file mode 100644 index 000000000..595500189 --- /dev/null +++ b/plugins/draw/src/api/split.js @@ -0,0 +1,117 @@ +import { splitPolygon } from '../utils/spatial.js' +import { ADAPTER_EVENTS } from '../adapterEvents.js' + +const INVALID_REASON = 'Line does not split the shape into two parts' +const MIN_LINE_VERTICES = 2 +const SPLITTER_ID = '_splitter' + +const computeIsValid = (polygonFeature, feature) => { + const coordinates = feature.geometry?.coordinates + if (!coordinates || coordinates.length < MIN_LINE_VERTICES) { + return false + } + return !!splitPolygon(polygonFeature, { id: SPLITTER_ID, geometry: feature.geometry }) +} + +// Colours the splitter line preview. Uses setDrawingPreviewProperty rather than +// setFeatureProperty since the line has no stable id until it's created. +const applySplitPreview = ({ draw, polygonFeature, feature }) => { + const isValid = computeIsValid(polygonFeature, feature) + draw.setDrawingPreviewProperty('splitter', isValid ? 'valid' : 'invalid') + return isValid +} + +// Colours the preview and gates Done/the finish gesture for a committed vertex. +const applySplitCommit = ({ draw, dispatch, polygonFeature, feature }) => { + const isValid = applySplitPreview({ draw, polygonFeature, feature }) + dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } }) + dispatch({ type: 'SET_GEOMETRY_VALID', payload: isValid }) + draw.setGeometryValid(isValid) + return isValid +} + +// Installed as draw._geometryValidator. 'place' and 'create' are no-ops — a split +// isn't complete until its last vertex, so checking those would block placement +// and hijack an early finish into edit mode. +const createSplitValidator = ({ draw, dispatch, polygonFeature }) => ({ feature, phase }) => { + let isValid + if (phase === 'preview') { + isValid = applySplitPreview({ draw, polygonFeature, feature }) + } else if (phase?.startsWith('commit-')) { + isValid = applySplitCommit({ draw, dispatch, polygonFeature, feature }) + } else { + return { valid: true } + } + return isValid ? { valid: true } : { valid: false, reason: INVALID_REASON } +} + +/** + * Start drawing a split line for a polygon. + * + * @param {object} context - plugin context + * @param {string} featureId - ID of the polygon to split + * @param {object} options - Options including snapLayers. + */ +export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, services }, featureId, options = {}) => { + const { dispatch } = pluginState + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return + } + + const polygonFeature = draw.get(featureId) + + // Swap in split's own rule; restored in stopListening. + const previousValidator = draw._geometryValidator + draw._geometryValidator = createSplitValidator({ draw, dispatch, polygonFeature }) + + // Always include the draw outline layer so the split line snaps to it + const snapLayers = ['stroke-inactive.cold', ...(options.snapLayers || [])] + draw.setSnapLayers(snapLayers) + dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + + draw.changeMode('draw_line', { + container: appState.layoutRefs.viewportRef.current, + addVertexButtonId: `${appConfig.id}-draw-add-point`, + interfaceType: appState.interfaceType, + crossHair: mapState?.crossHair, + getSnapEnabled: () => draw.isSnapEnabled(), + featureId: SPLITTER_ID, + properties: { splitter: 'invalid' } + }) + + // Unregister everything scoped to this session. + const stopListening = () => { + draw._geometryValidator = previousValidator + draw.off(ADAPTER_EVENTS.CREATE, onSplitCreate) + draw.off(ADAPTER_EVENTS.CANCEL, onSplitCancel) + } + + // Compute split result once the line is finalised. The splitter line only ever + // exists to compute this — it must never linger in the draw store afterwards. + const onSplitCreate = (geojsonFeature) => { + stopListening() + draw.delete(SPLITTER_ID) + const featureCollection = splitPolygon(polygonFeature, geojsonFeature) + + dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid: !!featureCollection } }) + + if (featureCollection) { + eventBus.emit('draw:split', { + originalFeatureId: featureId, + featureCollection + }) + } + } + + // Abandoned (e.g. Escape) — just stop listening. + const onSplitCancel = () => { stopListening() } + + draw.on(ADAPTER_EVENTS.CREATE, onSplitCreate) + draw.on(ADAPTER_EVENTS.CANCEL, onSplitCancel) + + dispatch({ type: 'SET_MODE', payload: 'draw_line' }) + dispatch({ type: 'SET_ACTION', payload: { name: 'split' } }) +} diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js new file mode 100644 index 000000000..3a50db401 --- /dev/null +++ b/plugins/draw/src/api/split.test.js @@ -0,0 +1,204 @@ +import { split } from './split.js' +import { splitPolygon } from '../utils/spatial.js' + +jest.mock('../utils/spatial.js', () => ({ splitPolygon: jest.fn() })) + +const makeContext = (overrides = {}) => { + const dispatch = jest.fn() + const eventBus = { emit: jest.fn() } + const draw = { + get: jest.fn(() => ({ id: 'poly' })), + setSnapLayers: jest.fn(), + changeMode: jest.fn(), + on: jest.fn(), + off: jest.fn(), + isSnapEnabled: jest.fn(() => true), + setGeometryValid: jest.fn(), + setDrawingPreviewProperty: jest.fn(), + delete: jest.fn() + } + const context = { + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, + appConfig: { id: 'app' }, + pluginState: { dispatch }, + mapState: { crossHair: true }, + mapProvider: { draw }, + services: { eventBus }, + ...overrides + } + return { context, dispatch, draw, eventBus } +} + +const handlerFor = (draw, event) => draw.on.mock.calls.find(([name]) => name === event)?.[1] + +const lineFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } }) + +beforeEach(() => jest.clearAllMocks()) + +describe('split', () => { + test('does nothing when there is no draw instance', () => { + const { context, dispatch } = makeContext({ mapProvider: { draw: null } }) + expect(() => split(context, 'poly')).not.toThrow() + expect(dispatch).not.toHaveBeenCalled() + }) + + test('sets up the splitter line drawing, installs a validator, and registers listeners', () => { + const { context, dispatch, draw } = makeContext() + + split(context, 'poly') + + expect(typeof draw._geometryValidator).toBe('function') + expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + expect(draw.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + container: 'viewport', + addVertexButtonId: 'app-draw-add-point', + interfaceType: 'mouse', + crossHair: true, + featureId: '_splitter', + properties: { splitter: 'invalid' } + })) + expect(draw.on).toHaveBeenCalledWith('create', expect.any(Function)) + expect(draw.on).toHaveBeenCalledWith('cancel', expect.any(Function)) + expect(draw.on).not.toHaveBeenCalledWith('geometrychange', expect.anything()) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_line' }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split' } }) + expect(draw.changeMode.mock.calls[0][1].getSnapEnabled()).toBe(true) + }) + + test('merges option snapLayers with the outline layer', () => { + const { context, draw } = makeContext() + split(context, 'poly', { snapLayers: ['extra'] }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold', 'extra']) + }) + + test('finalising the line computes a valid split, emits the result, and restores the previous validator', () => { + const { context, dispatch, draw, eventBus } = makeContext() + const polygonFeature = { id: 'poly' } + const featureCollection = { type: 'FeatureCollection' } + const previousValidator = jest.fn() + draw._geometryValidator = previousValidator + draw.get.mockReturnValue(polygonFeature) + splitPolygon.mockReturnValue(featureCollection) + + split(context, 'poly') + const onCreate = handlerFor(draw, 'create') + const geojson = { id: 'line' } + onCreate(geojson) + + expect(draw.off).toHaveBeenCalledWith('create', onCreate) + expect(draw.off).toHaveBeenCalledWith('cancel', expect.any(Function)) + expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, geojson) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:split', { originalFeatureId: 'poly', featureCollection }) + expect(draw._geometryValidator).toBe(previousValidator) + // The splitter line only ever existed to compute the split — it must not + // linger in the draw store afterwards. + expect(draw.delete).toHaveBeenCalledWith('_splitter') + }) + + test('cancelling the splitter line stops listening and restores the previous validator', () => { + const { context, draw } = makeContext() + const previousValidator = jest.fn() + draw._geometryValidator = previousValidator + + split(context, 'poly') + const onCancel = handlerFor(draw, 'cancel') + onCancel() + + expect(draw.off).toHaveBeenCalledWith('create', expect.any(Function)) + expect(draw.off).toHaveBeenCalledWith('cancel', onCancel) + expect(splitPolygon).not.toHaveBeenCalled() + expect(draw._geometryValidator).toBe(previousValidator) + }) + + test('finalising the line computes an invalid split and does not emit', () => { + const { context, dispatch, draw, eventBus } = makeContext() + splitPolygon.mockReturnValue(null) + + split(context, 'poly') + handlerFor(draw, 'create')({ id: 'line' }) + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) + expect(eventBus.emit).not.toHaveBeenCalled() + expect(draw.delete).toHaveBeenCalledWith('_splitter') + }) + + describe('the installed draw._geometryValidator', () => { + test('never blocks a hard placement veto (phase: place)', () => { + const { context, dispatch, draw } = makeContext() + split(context, 'poly') + dispatch.mockClear() + + const result = draw._geometryValidator({ feature: lineFeature([[0, 0], [1, 1]]), phase: 'place', mode: 'draw_line', vertexIndex: 1 }) + + expect(result).toEqual({ valid: true }) + expect(splitPolygon).not.toHaveBeenCalled() + expect(draw.setDrawingPreviewProperty).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_ACTION' })) + }) + + test('never blocks the whole-feature finish check (phase: create)', () => { + const { context, dispatch, draw } = makeContext() + split(context, 'poly') + dispatch.mockClear() + + const result = draw._geometryValidator({ feature: lineFeature([[0, 0], [1, 1]]), phase: 'create', mode: 'draw_line' }) + + expect(result).toEqual({ valid: true }) + expect(splitPolygon).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_ACTION' })) + }) + + test('the live preview (phase: preview) only updates the visual colour, never Done-gating — even with just 1 placed vertex', () => { + const { context, dispatch, draw } = makeContext() + const polygonFeature = { id: 'poly' } + draw.get.mockReturnValue(polygonFeature) + splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) + split(context, 'poly') + dispatch.mockClear() + + // 1 placed vertex + the rubber-band cursor. validateDisplayedGeometry only + // gates the built-in rules by numVertices, not a caller's own rule, so this + // must still reach the validator (numVertices: 1, below MIN_VERTICES.LineString). + const feature = lineFeature([[0, 0], [1, 1]]) + const result = draw._geometryValidator({ feature, phase: 'preview', mode: 'draw_line', numVertices: 1 }) + + expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, { id: '_splitter', geometry: feature.geometry }) + expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') + // Committed Done-gating (pluginState.geometryValid / map._drawGeometryValid) must only + // change on an actual commit — otherwise a live "what if you added a point here" check + // could flicker a just-committed valid split back to disabled. + expect(dispatch).not.toHaveBeenCalled() + expect(draw.setGeometryValid).not.toHaveBeenCalled() + expect(result).toEqual({ valid: true }) + }) + + test('a committed vertex (phase: commit-add) updates both the visual colour and Done-gating', () => { + const { context, dispatch, draw } = makeContext() + splitPolygon.mockReturnValue(null) + split(context, 'poly') + + const feature = lineFeature([[0, 0], [1, 1]]) + const result = draw._geometryValidator({ feature, phase: 'commit-add', mode: 'draw_line', vertexIndex: 1 }) + + expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(false) + expect(result).toEqual({ valid: false, reason: expect.any(String) }) + }) + + test('a line with fewer than two coordinates is treated as invalid without calling splitPolygon', () => { + const { context, draw } = makeContext() + split(context, 'poly') + + const feature = lineFeature([[0, 0]]) + const result = draw._geometryValidator({ feature, phase: 'preview', mode: 'draw_line', numVertices: 0 }) + + expect(splitPolygon).not.toHaveBeenCalled() + expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') + expect(result).toEqual({ valid: false, reason: expect.any(String) }) + }) + }) +}) diff --git a/plugins/draw/src/defaults.js b/plugins/draw/src/defaults.js new file mode 100644 index 000000000..47c484d52 --- /dev/null +++ b/plugins/draw/src/defaults.js @@ -0,0 +1,52 @@ +const BLUE = 'rgba(29,112,184,1)' +const WHITE = '#ffffff' +const BLACK = 'rgba(11,12,12,1)' +const LIGHT_BLUE = 'rgba(29,112,184,0.1)' +const LIGHT_WHITE = 'rgba(255,255,255,0.1)' +const RED = 'rgba(212,53,28,1)' +const MID_ORANGE = 'rgba(212,53,28,0.5)' +const MID_BLUE = 'rgba(29,112,184,0.5)' +const GREEN = 'rgba(40,161,151,1)' + +export const COLORS = { + editStroke: { light: BLUE, dark: WHITE }, + editFill: { light: LIGHT_BLUE, dark: LIGHT_WHITE }, + editVertex: { light: BLUE, dark: WHITE }, + editMidpoint: { light: BLUE, dark: WHITE }, + editHalo: { light: WHITE, dark: BLACK }, + editActive: { light: BLACK, dark: WHITE }, + splitInvalid: { light: BLUE, dark: WHITE }, + splitValid: { light: BLUE, dark: WHITE }, + invalidStroke: { light: BLUE, dark: WHITE }, + shapeStroke: RED, + shapeFill: MID_ORANGE, + snapVertex: MID_ORANGE, + snapMidpoint: GREEN, + snapEdge: MID_BLUE +} + +export const SIZES = { + strokeWidth: 2, + vertexRadius: 6, + midpointRadius: 4, + vertexHaloRadius: 8, + midpointHaloRadius: 6, + touchTargetSize: 48, + touchIndicatorRadius: 30 +} + +export const TOLERANCES = { + snapRadius: 12 +} + +export const KEYBOARD = { + nudgeAmount: 1, + stepAmount: 5 +} + +// Scale factor applied to draw UI (touch targets, vertex handles) per app map size +export const MAP_SIZE_SCALES = { + small: 1, + medium: 1.5, + large: 2 +} diff --git a/plugins/draw/src/defaults.test.js b/plugins/draw/src/defaults.test.js new file mode 100644 index 000000000..d4f973ebb --- /dev/null +++ b/plugins/draw/src/defaults.test.js @@ -0,0 +1,18 @@ +import { COLORS, SIZES, TOLERANCES, KEYBOARD, MAP_SIZE_SCALES } from './defaults.js' + +describe('draw defaults', () => { + test('exposes colour variants for the edit palette', () => { + expect(COLORS.editStroke).toEqual(expect.objectContaining({ light: expect.any(String), dark: expect.any(String) })) + expect(COLORS.shapeStroke).toEqual(expect.any(String)) + }) + + test('exposes numeric sizes and tolerances', () => { + expect(SIZES.touchTargetSize).toBe(48) + expect(TOLERANCES.snapRadius).toBe(12) + expect(KEYBOARD).toEqual({ nudgeAmount: 1, stepAmount: 5 }) + }) + + test('maps app map sizes to scale factors', () => { + expect(MAP_SIZE_SCALES).toEqual({ small: 1, medium: 1.5, large: 2 }) + }) +}) diff --git a/plugins/draw/src/draw.scss b/plugins/draw/src/draw.scss new file mode 100644 index 000000000..1b9dfe0e4 --- /dev/null +++ b/plugins/draw/src/draw.scss @@ -0,0 +1,43 @@ +// Touch vertex target (MapLibre) +.touch-vertex-target { + circle { + fill: var(--map-overlay-foreground-color); + } + path { + fill: var(--map-overlay-halo-color); + } +} + +// Touch vertex target (OpenLayers) +.im-draw-touch-target { + color: #3b82f6; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15)); + cursor: grab; + + &:active { + cursor: grabbing; + } +} + +// Accessibility: focus visible on buttons +.im-draw-button:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +// Ensure action buttons take up same width +.im-c-actions { + .im-c-button-wrapper--draw-done, + .im-c-button-wrapper--draw-menu, + .im-c-button-wrapper--draw-cancel { + width: 33.33%; + } +} +.im-o-app--tablet .im-c-actions, +.im-o-app--desktop .im-c-actions { + .im-c-button-wrapper--draw-done, + .im-c-button-wrapper--draw-menu, + .im-c-button-wrapper--draw-cancel { + width: 100px; + } +} diff --git a/plugins/draw/src/events.js b/plugins/draw/src/events.js new file mode 100644 index 000000000..fa581330e --- /dev/null +++ b/plugins/draw/src/events.js @@ -0,0 +1,237 @@ +/** + * Button and map-event wiring for the draw plugin. + * + * Uses the normalised adapter interface (draw.on/off, draw.done(), draw.cancel(), + * draw.setSnapEnabled(), etc.) so this file is map-framework-agnostic. + * All MapLibre / OL specifics live in the adapter. + */ +import { ADAPTER_EVENTS } from './adapterEvents.js' +import { validateGeometry } from './validation/validateGeometry.js' +import { MIN_VERTICES_REASONS } from './validation/rules.js' +import { MAP_SIZE_SCALES } from './defaults.js' + +const EDIT_VERTEX_MODE = 'edit_vertex' +const GEOMETRY_INVALID_EVENT = 'draw:geometryinvalid' + +// A shape that simply hasn't reached its minimum vertex count yet isn't a mistake — +// it's the normal, expected state of "still being drawn" — so it's excluded below +// even though it's a genuine SOFT_RULES failure like any other. +const isIncompleteShape = (reason) => Object.values(MIN_VERTICES_REASONS).includes(reason) + +// A discrete, one-shot invalid result (a rejected placement, a finished-but-invalid +// shape, or a completed commit that failed validation — never a continuous live-drag +// flip, which goes via onValidityChange/CAN_PLACE_CHANGE instead) gets a hint toast +// alongside the existing public event, so the reason is visible without every +// consumer having to wire up their own listener for it. The public event still +// fires either way — only the hint is skipped for an incomplete shape. +const createGeometryInvalidEmitter = (eventBus, hints) => (payload) => { + eventBus.emit(GEOMETRY_INVALID_EVENT, payload) + if (payload.reason && !isIncompleteShape(payload.reason)) { hints.show(payload.reason) } +} + +// Claims MoveControl's D-pad for the currently selected vertex — see the generic +// mapProvider.activeMoveTarget contract (MoveControl.jsx). Any plugin could use +// this slot; the draw plugin is just its first consumer. +const buildVertexMoveTarget = (draw) => ({ + move: (dx, dy, isLargeStep) => draw.nudgeSelectedVertex(dx, dy, isLargeStep), + label: 'vertex' +}) + +// Vertex-selection handlers: sync pluginState.selectedVertexIndex, and claim/release +// MoveControl's D-pad via buildVertexMoveTarget above. +const createVertexSelectionHandlers = ({ draw, pluginState, mapProvider, eventBus }) => ({ + onVertexSelection: (e) => { + pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e }) + mapProvider.activeMoveTarget = e.index >= 0 ? buildVertexMoveTarget(draw) : null + eventBus.emit('draw:vertexselection', e) + }, + onVertexChange: (e) => { + pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } }) + mapProvider.activeMoveTarget = null + } +}) + +// Re-open a feature in vertex-edit mode (used when a drawn shape finishes invalid). +// Mirrors the options built by api/editFeature.js. Edit-mode Done is gated by the +// same validity, so the shape can't be finished until it's fixed. +const enterEditVertexMode = ({ draw, appState, appConfig, mapState, dispatch }, featureId) => { + draw.changeMode(EDIT_VERTEX_MODE, { + container: appState?.layoutRefs?.viewportRef?.current, + deleteVertexButtonId: `${appConfig?.id}-draw-delete-point`, + undoButtonId: `${appConfig?.id}-draw-undo`, + isPanEnabled: appState?.interfaceType !== 'keyboard', + interfaceType: appState?.interfaceType, + scale: MAP_SIZE_SCALES[mapState?.mapSize], + featureId, + getSnapEnabled: () => draw.isSnapEnabled() + }) + const editing = draw.get(featureId) + dispatch({ type: 'SET_FEATURE', payload: { feature: editing, tempFeature: editing } }) + dispatch({ type: 'SET_MODE', payload: EDIT_VERTEX_MODE }) + dispatch({ type: 'SET_GEOMETRY_VALID', payload: false }) + draw.setGeometryValid?.(false) + draw.setInvalid?.(true) +} + +function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, hints, resetState }) { + const { draw } = mapProvider + const emitGeometryInvalid = createGeometryInvalidEmitter(eventBus, hints) + const { feature, tempFeature } = pluginState + const { dispatch } = pluginState + // A shape that finished invalid and was re-opened in edit mode: its eventual + // edit-finish must report as a creation, not an edit. + let pendingCreateId = null + + return { + handleDone: () => { draw.done() }, + handleCancel: () => { + const mode = draw.getMode() + if (mode === EDIT_VERTEX_MODE && tempFeature?.id) { draw.add(feature) } + pendingCreateId = null + draw.cancel(); resetState() + eventBus.emit('draw:cancelled', feature) + }, + handleUndo: () => draw.undo(), + handleDeleteVertex: () => draw.deleteVertex(), + handleSnap: () => { + pluginState.dispatch({ type: 'TOGGLE_SNAP' }) + draw.setSnapEnabled(!pluginState.snap) + }, + onCreate: (f) => { + // A shape can be finished by the Done button OR a map gesture (double-click, + // clicking the first vertex, Enter). Only the button is gated, so re-validate + // here to catch the gesture paths — an invalid shape must never be finalised. + const { valid, reason } = validateGeometry(f, { phase: 'create', mode: draw.getMode() }, { onGeometryChange: draw._geometryValidator }) + if (!valid) { + pendingCreateId = f.id + emitGeometryInvalid({ feature: f, reason, phase: 'create', mode: EDIT_VERTEX_MODE }) + setTimeout(() => enterEditVertexMode({ draw, appState, appConfig, mapState, dispatch }, f.id), 0) + return + } + resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:created', f) + }, + onEditFinish: (f) => { + resetState() + setTimeout(() => draw.changeMode('disabled'), 0) + // A shape that was drawn-then-fixed reports as a creation, not an edit. + if (pendingCreateId && f.id === pendingCreateId) { + pendingCreateId = null + eventBus.emit('draw:created', f) + } else { + eventBus.emit('draw:edited', f) + } + }, + onCancel: () => {}, + ...createVertexSelectionHandlers({ draw, pluginState, mapProvider, eventBus }), + onUndoChange: (l) => { pluginState.dispatch({ type: 'SET_UNDO_STACK_LENGTH', payload: l }) }, + onUpdate: (f) => { eventBus.emit('draw:updated', f) }, + onGeometryChange: (e) => { + // Only commit-level changes (commit-add/move/insert/delete) carry a `phase`. + // Preview events (e.g. split's live preview) have none and are ignored. + if (!e?.phase) { return } + + const mode = draw.getMode() + const context = { phase: e.phase, vertexIndex: e.vertexIndex, mode } + const { valid, reason } = validateGeometry(e.feature, context, { onGeometryChange: draw._geometryValidator }) + + // Rules only gate the Done button — never revert. A shape can pass through + // interim invalid states while being built or reshaped. + pluginState.dispatch({ type: 'SET_GEOMETRY_VALID', payload: valid }) + draw.setGeometryValid?.(valid) + // The invalid stroke is committed-validity-driven in edit mode only; in draw + // mode the adapters drive it live from the displayed geometry (placed + // vertices + cursor) on every rubber-band move. + if (mode === EDIT_VERTEX_MODE) { + draw.setInvalid?.(!valid) + } + if (!valid) { + emitGeometryInvalid({ reason, ...context, feature: e.feature }) + } + }, + // A vertex placement was rejected (hard rule or user callback veto). Surface it + // on the public bus with phase 'place', and as a hint toast, so the reason is visible. + onPlacementBlocked: (e) => { + emitGeometryInvalid(e) + }, + // Live (mid-drag) validity flip while editing — the displayed shape is exactly + // what Done finishes there, so it gates the Done button in real time. Emitted + // by the adapters' edit wiring only, on flips only. + onValidityChange: (e) => { + pluginState.dispatch({ type: 'SET_GEOMETRY_VALID', payload: e.valid }) + draw.setGeometryValid?.(e.valid) + }, + // Live placement-veto flip while drawing — gates the Add point button so it + // never looks active when a tap would be rejected. + onCanPlaceChange: (e) => { + pluginState.dispatch({ type: 'SET_CAN_ADD_POINT', payload: e.canPlace }) + }, + // The mode's interface type changed (device switch mid-draw, or the final + // value on mode exit) — relay so the app can sync appState.interfaceType. + onInterfaceTypeChange: (e) => { + eventBus.emit('draw:interfacetypechange', { interfaceType: e.interfaceType }) + } + } +} + +function attachButtonHandlers (buttonConfig, handlers) { + const { drawDone, drawCancel, drawUndo, drawDeletePoint, drawSnap } = buttonConfig + drawDone.onClick = handlers.handleDone + drawCancel.onClick = handlers.handleCancel + drawUndo.onClick = handlers.handleUndo + if (drawDeletePoint) { drawDeletePoint.onClick = handlers.handleDeleteVertex } + if (drawSnap) { drawSnap.onClick = handlers.handleSnap } +} + +function attachDrawEvents (draw, handlers) { + draw.on(ADAPTER_EVENTS.CREATE, handlers.onCreate) + draw.on(ADAPTER_EVENTS.EDIT_FINISH, handlers.onEditFinish) + draw.on(ADAPTER_EVENTS.CANCEL, handlers.onCancel) + draw.on(ADAPTER_EVENTS.VERTEX_SELECTION, handlers.onVertexSelection) + draw.on(ADAPTER_EVENTS.VERTEX_CHANGE, handlers.onVertexChange) + draw.on(ADAPTER_EVENTS.UNDO_CHANGE, handlers.onUndoChange) + draw.on(ADAPTER_EVENTS.UPDATE, handlers.onUpdate) + draw.on(ADAPTER_EVENTS.GEOMETRY_CHANGE, handlers.onGeometryChange) + draw.on(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked) + draw.on(ADAPTER_EVENTS.VALIDITY_CHANGE, handlers.onValidityChange) + draw.on(ADAPTER_EVENTS.CAN_PLACE_CHANGE, handlers.onCanPlaceChange) + draw.on(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, handlers.onInterfaceTypeChange) +} + +function detachButtonHandlers (buttonConfig) { + const { drawDone, drawCancel, drawUndo, drawDeletePoint, drawSnap } = buttonConfig + drawDone.onClick = null + drawCancel.onClick = null + drawUndo.onClick = null + if (drawDeletePoint) { drawDeletePoint.onClick = null } + if (drawSnap) { drawSnap.onClick = null } +} + +function detachDrawEvents (draw, handlers) { + draw.off(ADAPTER_EVENTS.CREATE, handlers.onCreate) + draw.off(ADAPTER_EVENTS.EDIT_FINISH, handlers.onEditFinish) + draw.off(ADAPTER_EVENTS.CANCEL, handlers.onCancel) + draw.off(ADAPTER_EVENTS.VERTEX_SELECTION, handlers.onVertexSelection) + draw.off(ADAPTER_EVENTS.VERTEX_CHANGE, handlers.onVertexChange) + draw.off(ADAPTER_EVENTS.UNDO_CHANGE, handlers.onUndoChange) + draw.off(ADAPTER_EVENTS.UPDATE, handlers.onUpdate) + draw.off(ADAPTER_EVENTS.GEOMETRY_CHANGE, handlers.onGeometryChange) + draw.off(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked) + draw.off(ADAPTER_EVENTS.VALIDITY_CHANGE, handlers.onValidityChange) + draw.off(ADAPTER_EVENTS.CAN_PLACE_CHANGE, handlers.onCanPlaceChange) + draw.off(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, handlers.onInterfaceTypeChange) +} + +export function attachEvents ({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus, hints }) { + const { draw } = mapProvider + const resetState = () => { + pluginState.dispatch({ type: 'SET_MODE', payload: null }) + pluginState.dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) + // Release MoveControl's D-pad back to panning the map whenever a draw/edit + // session ends — a stale claim here would silently hijack it for good. + mapProvider.activeMoveTarget = null + } + const handlers = createHandlers({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, hints, resetState }) + attachButtonHandlers(buttonConfig, handlers) + attachDrawEvents(draw, handlers) + return () => { detachButtonHandlers(buttonConfig); detachDrawEvents(draw, handlers) } +} diff --git a/plugins/draw/src/events.test.js b/plugins/draw/src/events.test.js new file mode 100644 index 000000000..6f1ac856e --- /dev/null +++ b/plugins/draw/src/events.test.js @@ -0,0 +1,369 @@ +import { attachEvents } from './events.js' + +jest.useFakeTimers() + +const DRAW_EVENTS = ['create', 'editfinish', 'cancel', 'vertexselection', 'vertexchange', 'undochange', 'update', 'geometrychange', 'placementblocked', 'validitychange', 'canplacechange', 'interfacetypechange'] + +const setup = (overrides = {}) => { + const draw = { + getMode: jest.fn(() => 'draw_polygon'), + done: jest.fn(), + cancel: jest.fn(), + add: jest.fn(), + get: jest.fn(() => ({ id: 'F' })), + undo: jest.fn(), + setGeometryValid: jest.fn(), + setInvalid: jest.fn(), + deleteVertex: jest.fn(), + nudgeSelectedVertex: jest.fn(), + setSnapEnabled: jest.fn(), + isSnapEnabled: jest.fn(() => false), + changeMode: jest.fn(), + on: jest.fn(), + off: jest.fn() + } + const dispatch = jest.fn() + const pluginState = { dispatch, feature: { id: 'F' }, tempFeature: { id: 'T' }, snap: false, ...overrides.pluginState } + const mapProvider = { draw } + const eventBus = { emit: jest.fn() } + const hints = { show: jest.fn() } + const buttonConfig = { drawDone: {}, drawCancel: {}, drawUndo: {}, drawDeletePoint: {}, drawSnap: {}, ...overrides.buttonConfig } + const appState = { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' } + const appConfig = { id: 'app' } + const mapState = { mapSize: 'medium' } + + const detach = attachEvents({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus, hints }) + return { draw, dispatch, pluginState, mapProvider, eventBus, hints, buttonConfig, detach } +} + +const drawHandler = (draw, event) => draw.on.mock.calls.find(([name]) => name === event)[1] + +beforeEach(() => jest.clearAllTimers()) + +describe('attachEvents – wiring', () => { + test('assigns onClick to each configured button', () => { + const { buttonConfig } = setup() + expect(typeof buttonConfig.drawDone.onClick).toBe('function') + expect(typeof buttonConfig.drawCancel.onClick).toBe('function') + expect(typeof buttonConfig.drawUndo.onClick).toBe('function') + expect(typeof buttonConfig.drawDeletePoint.onClick).toBe('function') + expect(typeof buttonConfig.drawSnap.onClick).toBe('function') + }) + + test('subscribes to every draw event', () => { + const { draw } = setup() + DRAW_EVENTS.forEach((event) => { + expect(draw.on).toHaveBeenCalledWith(event, expect.any(Function)) + }) + }) + + test('tolerates missing optional buttons', () => { + const { buttonConfig, detach } = setup({ buttonConfig: { drawDeletePoint: undefined, drawSnap: undefined } }) + expect(typeof buttonConfig.drawDone.onClick).toBe('function') + expect(() => detach()).not.toThrow() + }) +}) + +describe('button handlers', () => { + test('done finishes without resetting snap', () => { + const { buttonConfig, draw, dispatch } = setup() + buttonConfig.drawDone.onClick() + expect(dispatch).not.toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) + expect(draw.setSnapEnabled).not.toHaveBeenCalledWith(false) + expect(draw.done).toHaveBeenCalled() + }) + + test('cancel re-adds the feature when cancelling a vertex edit, and releases activeMoveTarget', () => { + const { buttonConfig, draw, dispatch, eventBus, mapProvider } = setup() + draw.getMode.mockReturnValue('edit_vertex') + drawHandler(draw, 'vertexselection')({ index: 1 }) + + buttonConfig.drawCancel.onClick() + + expect(draw.add).toHaveBeenCalledWith({ id: 'F' }) + expect(draw.cancel).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: null }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:cancelled', { id: 'F' }) + expect(mapProvider.activeMoveTarget).toBeNull() + }) + + test('cancel does not re-add outside a vertex edit', () => { + const { buttonConfig, draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + buttonConfig.drawCancel.onClick() + expect(draw.add).not.toHaveBeenCalled() + }) + + test('cancel does not re-add a vertex edit without a temp feature', () => { + const { buttonConfig, draw } = setup({ pluginState: { tempFeature: null } }) + draw.getMode.mockReturnValue('edit_vertex') + buttonConfig.drawCancel.onClick() + expect(draw.add).not.toHaveBeenCalled() + }) + + test('undo and delete-vertex delegate to the adapter', () => { + const { buttonConfig, draw } = setup() + buttonConfig.drawUndo.onClick() + buttonConfig.drawDeletePoint.onClick() + expect(draw.undo).toHaveBeenCalled() + expect(draw.deleteVertex).toHaveBeenCalled() + }) + + test('snap toggles state and syncs the adapter', () => { + const { buttonConfig, draw, dispatch } = setup({ pluginState: { snap: false } }) + buttonConfig.drawSnap.onClick() + expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SNAP' }) + expect(draw.setSnapEnabled).toHaveBeenCalledWith(true) + }) +}) + +describe('draw event handlers', () => { + test('create resets state, preserves snap, disables mode asynchronously and emits', () => { + const { draw, dispatch, eventBus } = setup() + drawHandler(draw, 'create')({ id: 'new' }) + + expect(dispatch).not.toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: null }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:created', { id: 'new' }) + + jest.runAllTimers() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) + + test('editfinish resets state and emits draw:edited', () => { + const { draw, eventBus } = setup() + drawHandler(draw, 'editfinish')({ id: 'edited' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:edited', { id: 'edited' }) + jest.runAllTimers() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) + + test('re-opens an invalid finished shape in edit mode instead of creating it', () => { + const { draw, eventBus, hints } = setup() + const bowtie = { id: 'bad', type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } } + drawHandler(draw, 'create')(bowtie) + expect(eventBus.emit).not.toHaveBeenCalledWith('draw:created', bowtie) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/intersect/i) })) + expect(hints.show).toHaveBeenCalledWith(expect.stringMatching(/intersect/i)) + jest.runAllTimers() + const editCall = draw.changeMode.mock.calls.find(([mode]) => mode === 'edit_vertex') + expect(editCall[1]).toEqual(expect.objectContaining({ featureId: 'bad' })) + // the wired getSnapEnabled option delegates to the adapter + editCall[1].getSnapEnabled() + expect(draw.isSnapEnabled).toHaveBeenCalled() + }) + + test('edit finish of a drawn-then-fixed shape reports as a creation', () => { + const { draw, eventBus } = setup() + // An invalid finished shape is re-opened for fixing (marks it as a pending creation)… + const bowtie = { id: 'bad', type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } } + drawHandler(draw, 'create')(bowtie) + jest.runAllTimers() + // …so when its edit finishes, it reports as a creation, exactly once. + drawHandler(draw, 'editfinish')({ id: 'bad' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:created', { id: 'bad' }) + drawHandler(draw, 'editfinish')({ id: 'bad' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:edited', { id: 'bad' }) + }) + + test('cancel handler is a no-op', () => { + const { draw } = setup() + expect(() => drawHandler(draw, 'cancel')()).not.toThrow() + }) + + test('vertexselection dispatches, emits, and claims mapProvider.activeMoveTarget for MoveControl', () => { + const { draw, dispatch, eventBus, mapProvider } = setup() + drawHandler(draw, 'vertexselection')({ index: 2 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: 2 } }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:vertexselection', { index: 2 }) + expect(mapProvider.activeMoveTarget).toMatchObject({ label: 'vertex' }) + + mapProvider.activeMoveTarget.move(1, 0, true) + expect(draw.nudgeSelectedVertex).toHaveBeenCalledWith(1, 0, true) + }) + + test('vertexselection releases activeMoveTarget when the index is deselected', () => { + const { draw, mapProvider } = setup() + drawHandler(draw, 'vertexselection')({ index: 2 }) + drawHandler(draw, 'vertexselection')({ index: -1 }) + expect(mapProvider.activeMoveTarget).toBeNull() + }) + + test('vertexchange resets the selected index with the new count and releases activeMoveTarget', () => { + const { draw, dispatch, mapProvider } = setup() + drawHandler(draw, 'vertexselection')({ index: 2 }) + drawHandler(draw, 'vertexchange')({ numVertices: 5 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: 5 } }) + expect(mapProvider.activeMoveTarget).toBeNull() + }) + + test('undochange dispatches the stack length', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'undochange')(4) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_UNDO_STACK_LENGTH', payload: 4 }) + }) + + test('update emits draw:updated', () => { + const { draw, eventBus } = setup() + drawHandler(draw, 'update')({ id: 'u' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:updated', { id: 'u' }) + }) +}) + +describe('geometrychange validation', () => { + const squareFeature = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]] } + } + const bowtieFeature = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } + } + const collinearFeature = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [2, 0], [0, 0]]] } + } + const partDrawnFeature = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [0, 0]]] } + } + + test('ignores preview payloads that carry no phase', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'geometrychange')({ coordinates: [[0, 0], [1, 1]] }) + expect(dispatch).not.toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: expect.anything() }) + }) + + test('opens the gate for a valid geometry', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + }) + + test('gates a self-intersecting shape while drawing', () => { + const { draw, dispatch, eventBus, hints } = setup() + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, phase: 'commit-add', vertexIndex: 3 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(false) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/intersect/i) })) + expect(hints.show).toHaveBeenCalledWith(expect.stringMatching(/intersect/i)) + }) + + test('gates a part-drawn shape below the minimum vertex count, but skips the hint — an incomplete shape is expected, not a mistake', () => { + const { draw, dispatch, eventBus, hints } = setup() + drawHandler(draw, 'geometrychange')({ feature: partDrawnFeature, phase: 'commit-add', vertexIndex: 1 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/at least 3 points/) })) + expect(hints.show).not.toHaveBeenCalled() + }) + + test('gates a zero-area shape while drawing', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'geometrychange')({ feature: collinearFeature, phase: 'commit-add', vertexIndex: 2 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + }) + + test('gates a self-intersecting move in edit mode (never reverts)', () => { + const { draw, dispatch, eventBus } = setup() + draw.getMode.mockReturnValue('edit_vertex') + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, phase: 'commit-move', vertexIndex: 2 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/intersect/i) })) + }) + + test('keeps a valid edit move (gate open)', () => { + const { draw, dispatch } = setup() + draw.getMode.mockReturnValue('edit_vertex') + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-move', vertexIndex: 1 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + }) + + test('applies the per-session user validator as a gate', () => { + const { draw, dispatch, eventBus, hints } = setup() + draw._geometryValidator = () => ({ valid: false, reason: 'too big' }) + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: 'too big' })) + expect(hints.show).toHaveBeenCalledWith('too big') + }) + + test('a user validator that vetoes with no reason (plain `false`) still emits, but skips the hint', () => { + const { draw, dispatch, eventBus, hints } = setup() + draw._geometryValidator = () => false + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: null })) + expect(hints.show).not.toHaveBeenCalled() + }) + + test('drives the invalid stroke from committed validity in edit mode only', () => { + const { draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, phase: 'commit-add', vertexIndex: 3 }) + expect(draw.setInvalid).not.toHaveBeenCalled() // draw mode: the adapter's live check owns the stroke + + draw.getMode.mockReturnValue('edit_vertex') + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, phase: 'commit-move', vertexIndex: 2 }) + expect(draw.setInvalid).toHaveBeenCalledWith(true) + }) + + test('a live validity flip (edit drag) drives the Done gate', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'validitychange')({ valid: false, reason: 'Shape must not intersect itself' }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(false) + + drawHandler(draw, 'validitychange')({ valid: true, reason: null }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + }) + + test('a live placement-veto flip drives the Add point gate', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'canplacechange')({ canPlace: false, reason: 'outside region' }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_CAN_ADD_POINT', payload: false }) + + drawHandler(draw, 'canplacechange')({ canPlace: true, reason: null }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_CAN_ADD_POINT', payload: true }) + }) + + test('relays an interface-type change to the public bus', () => { + const { draw, eventBus } = setup() + drawHandler(draw, 'interfacetypechange')({ interfaceType: 'keyboard' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:interfacetypechange', { interfaceType: 'keyboard' }) + }) + + test('relays a blocked placement to the public bus as draw:geometryinvalid, and as a hint', () => { + const { draw, eventBus, hints } = setup() + const blocked = { phase: 'place', mode: 'draw_polygon', vertexIndex: 2, reason: 'outside region', feature: { type: 'Feature' } } + drawHandler(draw, 'placementblocked')(blocked) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', blocked) + expect(hints.show).toHaveBeenCalledWith('outside region') + }) + + test('passes the current mode into the validation context', () => { + const { draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + const validator = jest.fn(() => true) + draw._geometryValidator = validator + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) + expect(validator).toHaveBeenCalledWith({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3, mode: 'draw_polygon' }) + }) +}) + +describe('detach', () => { + test('clears button handlers and unsubscribes from draw events', () => { + const { detach, buttonConfig, draw } = setup() + + detach() + + expect(buttonConfig.drawDone.onClick).toBeNull() + expect(buttonConfig.drawCancel.onClick).toBeNull() + expect(buttonConfig.drawUndo.onClick).toBeNull() + expect(buttonConfig.drawDeletePoint.onClick).toBeNull() + expect(buttonConfig.drawSnap.onClick).toBeNull() + DRAW_EVENTS.forEach((event) => { + expect(draw.off).toHaveBeenCalledWith(event, expect.any(Function)) + }) + }) +}) diff --git a/plugins/draw/src/index.js b/plugins/draw/src/index.js new file mode 100644 index 000000000..0a9b09870 --- /dev/null +++ b/plugins/draw/src/index.js @@ -0,0 +1,10 @@ +export default function createPlugin (options = {}) { + return { + ...options, + id: 'draw', + load: async () => { + const module = (await import(/* webpackChunkName: "im-draw-plugin" */ './manifest.js')).manifest + return module + } + } +} diff --git a/plugins/draw/src/index.test.js b/plugins/draw/src/index.test.js new file mode 100644 index 000000000..01cb9d0e8 --- /dev/null +++ b/plugins/draw/src/index.test.js @@ -0,0 +1,24 @@ +import createPlugin from './index.js' +import { manifest } from './manifest.js' + +jest.mock('./manifest.js', () => ({ manifest: { id: 'draw-manifest' } })) + +describe('createPlugin', () => { + test('returns a plugin descriptor with a fixed id and passes options through', () => { + const plugin = createPlugin({ foo: 'bar' }) + expect(plugin).toMatchObject({ id: 'draw', foo: 'bar' }) + expect(typeof plugin.load).toBe('function') + }) + + test('the fixed id cannot be overridden by options', () => { + expect(createPlugin({ id: 'other' }).id).toBe('draw') + }) + + test('defaults options to an empty object', () => { + expect(() => createPlugin()).not.toThrow() + }) + + test('load dynamically imports the manifest', async () => { + await expect(createPlugin().load()).resolves.toBe(manifest) + }) +}) diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js new file mode 100644 index 000000000..85f3a5d1d --- /dev/null +++ b/plugins/draw/src/manifest.js @@ -0,0 +1,170 @@ +import { initialState, actions } from './reducer.js' +import { DrawInit } from './DrawInit.jsx' +import { newPolygon } from './api/newPolygon.js' +import { newLine } from './api/newLine.js' +import { editFeature } from './api/editFeature.js' +import { addFeature } from './api/addFeature.js' +import { deleteFeature } from './api/deleteFeature.js' +import { split } from './api/split.js' +import { merge } from './api/merge.js' +import { isMac } from '../../../src/utils/isMac.js' + +const DRAW_ACTIONS_SLOT = 'top-middle' + +// Show the platform-appropriate undo modifier (⌘ on macOS, Ctrl elsewhere). +const undoCommand = isMac() ? 'Command + Z' : 'Ctrl + Z' + +const createButtonSlots = (showLabel) => ({ + mobile: { slot: 'actions', showLabel }, + tablet: { slot: 'actions', showLabel }, + desktop: { slot: 'actions', showLabel } +}) + +export const manifest = { + reducer: { + initialState, + actions + }, + + InitComponent: DrawInit, + + buttons: [ + { + id: 'drawCancel', + label: 'Cancel', + variant: 'tertiary', + exclusiveSlot: true, + hiddenWhen: ({ pluginState }) => !pluginState.mode, + ...createButtonSlots(true) + }, + { + id: 'drawAddPoint', + label: 'Add point', + variant: 'primary', + exclusiveSlot: true, + hiddenWhen: ({ appState, pluginState }) => + !['draw_polygon', 'draw_line'].includes(pluginState.mode) || appState.interfaceType !== 'touch', + // Disabled while placing at the crosshair would be vetoed (validatePlacement) — + // driven live so the button never looks active when a tap would do nothing. + enableWhen: ({ pluginState }) => pluginState.canAddPoint, + ...createButtonSlots(true) + }, + { + id: 'drawDone', + label: 'Done', + variant: 'primary', + exclusiveSlot: true, + hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), + enableWhen: ({ pluginState }) => { + const { mode, geometryValid } = pluginState + // Min-vertices, area and self-intersection are all enforced by the validation + // rules via geometryValid, so the gate is simply "is the geometry valid now". + return ['draw_polygon', 'draw_line', 'edit_vertex'].includes(mode) && geometryValid + }, + ...createButtonSlots(true) + }, + { + id: 'drawMenu', + label: ({ pluginState }) => pluginState.mode === 'edit_vertex' ? 'Edit actions' : 'Draw actions', + iconId: 'menu', + exclusiveSlot: true, + hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), + menuItems: [ + { + id: 'drawUndo', + label: 'Undo', + iconId: 'undo', + hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), + enableWhen: ({ pluginState }) => { + if (['draw_polygon', 'draw_line'].includes(pluginState.mode)) { + return pluginState.numVertices > 0 + } + return pluginState.undoStackLength > 0 + } + }, + { + id: 'drawSnap', + label: 'Snap to feature', + iconId: 'magnet', + hiddenWhen: ({ pluginState }) => !pluginState.mode || !pluginState.hasSnapLayers, + pressedWhen: ({ pluginState }) => !!pluginState.snap + }, + { + id: 'drawDeletePoint', + label: 'Delete point', + iconId: 'trash', + enableWhen: ({ pluginState }) => { + if (pluginState.selectedVertexIndex < 0) { return false } + const isPolygon = pluginState.feature?.geometry?.type === 'Polygon' + return isPolygon ? pluginState.numVertices > 3 : pluginState.numVertices > 2 // NOSONAR + }, + hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' + } + ], + mobile: { slot: DRAW_ACTIONS_SLOT }, + tablet: { slot: DRAW_ACTIONS_SLOT }, + desktop: { slot: DRAW_ACTIONS_SLOT } + } + ], + + keyboardShortcuts: [{ + id: 'drawAddPoint', + group: 'Drawing', + title: 'Add point (draw)', + command: 'Enter' + }, { + id: 'drawSelectPoint', + group: 'Drawing', + title: 'Select nearest point (edit)', + command: 'Spacebar' + }, { + id: 'drawSelectAdjacentPoint', + group: 'Drawing', + title: 'Select adjacent point (edit)', + command: 'Alt + or ' + }, { + id: 'drawMovePoint', + group: 'Drawing', + title: 'Move point (edit)', + command: ' or ' + }, { + id: 'drawNudgePoint', + group: 'Drawing', + title: 'Nudge point (edit)', + command: 'Shift + or ' + }, { + id: 'drawDeletePoint', + group: 'Drawing', + title: 'Delete point (edit)', + command: 'Delete' + }, { + id: 'drawUndo', + group: 'Drawing', + title: 'Undo', + command: undoCommand + }], + + icons: [{ + id: 'menu', + svgContent: '' + }, { + id: 'undo', + svgContent: '' + }, { + id: 'magnet', + svgContent: '' + }, { + id: 'trash', + svgContent: '' + }], + + api: { + newPolygon, + newLine, + editFeature, + addFeature, + deleteFeature, + split, + merge + } +} diff --git a/plugins/draw/src/manifest.test.js b/plugins/draw/src/manifest.test.js new file mode 100644 index 000000000..5a13cddd5 --- /dev/null +++ b/plugins/draw/src/manifest.test.js @@ -0,0 +1,147 @@ +import { manifest } from './manifest.js' + +const findButton = (id) => manifest.buttons.find((b) => b.id === id) +const findMenuItem = (menuId, itemId) => findButton(menuId).menuItems.find((m) => m.id === itemId) + +describe('manifest structure', () => { + test('exposes the reducer, init component and api surface', () => { + expect(manifest.reducer).toHaveProperty('initialState') + expect(manifest.reducer).toHaveProperty('actions') + expect(manifest.InitComponent).toBeDefined() + expect(Object.keys(manifest.api)).toEqual(expect.arrayContaining([ + 'newPolygon', 'newLine', 'editFeature', 'addFeature', 'deleteFeature', 'split', 'merge' + ])) + }) +}) + +describe('drawCancel', () => { + test('is hidden only when there is no active mode', () => { + expect(findButton('drawCancel').hiddenWhen({ pluginState: { mode: null } })).toBe(true) + expect(findButton('drawCancel').hiddenWhen({ pluginState: { mode: 'draw_line' } })).toBe(false) + }) +}) + +describe('drawAddPoint', () => { + const hidden = (interfaceType, mode) => + findButton('drawAddPoint').hiddenWhen({ appState: { interfaceType }, pluginState: { mode } }) + + test('is shown only while drawing on a touch interface', () => { + expect(hidden('touch', 'draw_polygon')).toBe(false) + expect(hidden('mouse', 'draw_polygon')).toBe(true) + expect(hidden('touch', 'edit_vertex')).toBe(true) + }) + + test('is disabled while placing at the crosshair would be vetoed', () => { + const btn = findButton('drawAddPoint') + expect(btn.enableWhen({ pluginState: { canAddPoint: true } })).toBe(true) + expect(btn.enableWhen({ pluginState: { canAddPoint: false } })).toBe(false) + }) +}) + +describe('drawDone', () => { + const btn = () => findButton('drawDone') + + test('is hidden outside draw/edit modes', () => { + expect(btn().hiddenWhen({ pluginState: { mode: null } })).toBe(true) + expect(btn().hiddenWhen({ pluginState: { mode: 'draw_polygon' } })).toBe(false) + }) + + test('enables in a draw/edit mode only when the geometry is valid', () => { + expect(btn().enableWhen({ pluginState: { mode: 'draw_polygon', geometryValid: true } })).toBe(true) + expect(btn().enableWhen({ pluginState: { mode: 'draw_line', geometryValid: true } })).toBe(true) + expect(btn().enableWhen({ pluginState: { mode: 'edit_vertex', geometryValid: true } })).toBe(true) + expect(btn().enableWhen({ pluginState: { mode: 'disabled', geometryValid: true } })).toBe(false) + }) + + test('stays disabled while the geometry is invalid', () => { + expect(btn().enableWhen({ pluginState: { mode: 'draw_polygon', geometryValid: false } })).toBe(false) + expect(btn().enableWhen({ pluginState: { mode: 'draw_line', geometryValid: false } })).toBe(false) + expect(btn().enableWhen({ pluginState: { mode: 'edit_vertex', geometryValid: false } })).toBe(false) + }) +}) + +describe('drawMenu', () => { + test('is hidden outside draw/edit modes', () => { + expect(findButton('drawMenu').hiddenWhen({ pluginState: { mode: null } })).toBe(true) + expect(findButton('drawMenu').hiddenWhen({ pluginState: { mode: 'edit_vertex' } })).toBe(false) + }) + + test('labels "Edit actions" in edit mode, "Draw actions" otherwise', () => { + expect(findButton('drawMenu').label({ pluginState: { mode: 'edit_vertex' } })).toBe('Edit actions') + expect(findButton('drawMenu').label({ pluginState: { mode: 'draw_polygon' } })).toBe('Draw actions') + expect(findButton('drawMenu').label({ pluginState: { mode: 'draw_line' } })).toBe('Draw actions') + }) + + describe('drawUndo', () => { + const item = () => findMenuItem('drawMenu', 'drawUndo') + + test('is hidden outside draw/edit modes', () => { + expect(item().hiddenWhen({ pluginState: { mode: null } })).toBe(true) + expect(item().hiddenWhen({ pluginState: { mode: 'draw_line' } })).toBe(false) + }) + + test('enables from vertex count while drawing and from the undo stack while editing', () => { + expect(item().enableWhen({ pluginState: { mode: 'draw_polygon', numVertices: 1 } })).toBe(true) + expect(item().enableWhen({ pluginState: { mode: 'draw_polygon', numVertices: 0 } })).toBe(false) + expect(item().enableWhen({ pluginState: { mode: 'edit_vertex', undoStackLength: 2 } })).toBe(true) + expect(item().enableWhen({ pluginState: { mode: 'edit_vertex', undoStackLength: 0 } })).toBe(false) + }) + }) + + describe('drawSnap', () => { + const item = () => findMenuItem('drawMenu', 'drawSnap') + + test('is hidden without a mode or snap layers', () => { + expect(item().hiddenWhen({ pluginState: { mode: null, hasSnapLayers: true } })).toBe(true) + expect(item().hiddenWhen({ pluginState: { mode: 'draw_line', hasSnapLayers: false } })).toBe(true) + expect(item().hiddenWhen({ pluginState: { mode: 'draw_line', hasSnapLayers: true } })).toBe(false) + }) + + test('is pressed when snapping is enabled', () => { + expect(item().pressedWhen({ pluginState: { snap: true } })).toBe(true) + expect(item().pressedWhen({ pluginState: { snap: false } })).toBe(false) + }) + }) + + describe('drawDeletePoint', () => { + const item = () => findMenuItem('drawMenu', 'drawDeletePoint') + + test('is hidden outside edit mode', () => { + expect(item().hiddenWhen({ pluginState: { mode: 'draw_polygon' } })).toBe(true) + expect(item().hiddenWhen({ pluginState: { mode: 'edit_vertex' } })).toBe(false) + }) + + test('enables only with a selection and enough vertices (polygon vs line)', () => { + expect(item().enableWhen({ pluginState: { selectedVertexIndex: -1 } })).toBe(false) + expect(item().enableWhen({ pluginState: { selectedVertexIndex: 0, feature: { geometry: { type: 'Polygon' } }, numVertices: 4 } })).toBe(true) + expect(item().enableWhen({ pluginState: { selectedVertexIndex: 0, feature: { geometry: { type: 'Polygon' } }, numVertices: 3 } })).toBe(false) + expect(item().enableWhen({ pluginState: { selectedVertexIndex: 0, feature: { geometry: { type: 'LineString' } }, numVertices: 3 } })).toBe(true) + expect(item().enableWhen({ pluginState: { selectedVertexIndex: 0, feature: { geometry: { type: 'LineString' } }, numVertices: 2 } })).toBe(false) + }) + }) +}) + +describe('drawUndo keyboard shortcut (platform-specific command)', () => { + const loadUndoCommand = (mac) => { + let command + jest.isolateModules(() => { + jest.doMock('../../../src/utils/isMac.js', () => ({ isMac: () => mac })) + const { manifest: reloaded } = require('./manifest.js') + command = reloaded.keyboardShortcuts.find((s) => s.id === 'drawUndo').command + }) + return command + } + + afterEach(() => { + jest.dontMock('../../../src/utils/isMac.js') + jest.resetModules() + }) + + test('uses Command on macOS', () => { + expect(loadUndoCommand(true)).toBe('Command + Z') + }) + + test('uses Ctrl on non-mac platforms', () => { + expect(loadUndoCommand(false)).toBe('Ctrl + Z') + }) +}) diff --git a/plugins/draw/src/reducer.js b/plugins/draw/src/reducer.js new file mode 100644 index 000000000..73972a2e4 --- /dev/null +++ b/plugins/draw/src/reducer.js @@ -0,0 +1,69 @@ +const initialState = { + mode: null, + action: null, + actionValid: false, + feature: null, + tempFeature: null, + selectedVertexIndex: -1, + numVertices: null, + geometryValid: true, + canAddPoint: true, + snap: false, + hasSnapLayers: false, + undoStackLength: 0 +} + +const DRAW_MODES = new Set(['draw_polygon', 'draw_line']) + +const setMode = (state, payload) => ({ + ...state, + mode: payload, + numVertices: DRAW_MODES.has(payload) ? 0 : state.numVertices, + // A new/empty shape is never valid; validation flips this true once the geometry + // passes all soft rules (edit mode seeds it explicitly in api/editFeature). + geometryValid: false, + // A fresh mode can always place; the live placement gate flips this on veto. + canAddPoint: true +}) + +const setAction = (state, payload) => ({ + ...state, + action: payload.name, + actionValid: payload.isValid +}) + +const setSelectedVertexIndex = (state, payload) => ({ + ...state, + selectedVertexIndex: payload.index, + numVertices: payload.numVertices +}) + +const setFeature = (state, payload) => ({ + ...state, + feature: payload.feature === undefined ? state.feature : payload.feature, + tempFeature: payload.tempFeature === undefined ? state.tempFeature : payload.tempFeature +}) + +const toggleSnap = (state) => ({ ...state, snap: !state.snap }) + +const setHasSnapLayers = (state, payload) => ({ ...state, hasSnapLayers: !!payload }) + +const setUndoStackLength = (state, payload) => ({ ...state, undoStackLength: payload }) + +const setGeometryValid = (state, payload) => ({ ...state, geometryValid: !!payload }) + +const setCanAddPoint = (state, payload) => ({ ...state, canAddPoint: !!payload }) + +const actions = { + SET_MODE: setMode, + SET_ACTION: setAction, + SET_FEATURE: setFeature, + SET_SELECTED_VERTEX_INDEX: setSelectedVertexIndex, + TOGGLE_SNAP: toggleSnap, + SET_HAS_SNAP_LAYERS: setHasSnapLayers, + SET_UNDO_STACK_LENGTH: setUndoStackLength, + SET_GEOMETRY_VALID: setGeometryValid, + SET_CAN_ADD_POINT: setCanAddPoint +} + +export { initialState, actions } diff --git a/plugins/draw/src/reducer.test.js b/plugins/draw/src/reducer.test.js new file mode 100644 index 000000000..867359b0c --- /dev/null +++ b/plugins/draw/src/reducer.test.js @@ -0,0 +1,85 @@ +import { initialState, actions } from './reducer.js' + +describe('initialState', () => { + test('has sensible defaults', () => { + expect(initialState).toMatchObject({ + mode: null, + action: null, + actionValid: false, + feature: null, + tempFeature: null, + selectedVertexIndex: -1, + numVertices: null, + snap: false, + hasSnapLayers: false, + undoStackLength: 0, + canAddPoint: true + }) + }) +}) + +describe('SET_MODE', () => { + test('resets numVertices to 0 for draw modes', () => { + expect(actions.SET_MODE(initialState, 'draw_polygon')).toMatchObject({ mode: 'draw_polygon', numVertices: 0 }) + expect(actions.SET_MODE(initialState, 'draw_line')).toMatchObject({ mode: 'draw_line', numVertices: 0 }) + }) + + test('a fresh mode can always place a point', () => { + const state = { ...initialState, canAddPoint: false } + expect(actions.SET_MODE(state, 'draw_polygon')).toMatchObject({ canAddPoint: true }) + }) + + test('preserves numVertices for non-draw modes', () => { + const state = { ...initialState, numVertices: 5 } + expect(actions.SET_MODE(state, 'edit_vertex')).toMatchObject({ mode: 'edit_vertex', numVertices: 5 }) + expect(actions.SET_MODE(state, null)).toMatchObject({ mode: null, numVertices: 5 }) + }) +}) + +describe('SET_ACTION', () => { + test('sets the action name and validity', () => { + expect(actions.SET_ACTION(initialState, { name: 'split', isValid: true })) + .toMatchObject({ action: 'split', actionValid: true }) + }) +}) + +describe('SET_FEATURE', () => { + test('updates provided fields and preserves undefined ones', () => { + const state = { ...initialState, feature: 'F', tempFeature: 'T' } + expect(actions.SET_FEATURE(state, { feature: 'F2' })).toMatchObject({ feature: 'F2', tempFeature: 'T' }) + expect(actions.SET_FEATURE(state, { tempFeature: null })).toMatchObject({ feature: 'F', tempFeature: null }) + expect(actions.SET_FEATURE(state, { feature: null, tempFeature: null })).toMatchObject({ feature: null, tempFeature: null }) + }) +}) + +describe('SET_SELECTED_VERTEX_INDEX', () => { + test('sets the index and numVertices', () => { + expect(actions.SET_SELECTED_VERTEX_INDEX(initialState, { index: 2, numVertices: 4 })) + .toMatchObject({ selectedVertexIndex: 2, numVertices: 4 }) + }) +}) + +describe('snap actions', () => { + test('TOGGLE_SNAP flips the snap flag', () => { + expect(actions.TOGGLE_SNAP({ ...initialState, snap: false }).snap).toBe(true) + expect(actions.TOGGLE_SNAP({ ...initialState, snap: true }).snap).toBe(false) + }) + + test('SET_HAS_SNAP_LAYERS coerces the payload to a boolean', () => { + expect(actions.SET_HAS_SNAP_LAYERS(initialState, ['x']).hasSnapLayers).toBe(true) + expect(actions.SET_HAS_SNAP_LAYERS(initialState, null).hasSnapLayers).toBe(false) + }) +}) + +describe('SET_UNDO_STACK_LENGTH', () => { + test('sets the undo stack length', () => { + expect(actions.SET_UNDO_STACK_LENGTH(initialState, 3).undoStackLength).toBe(3) + }) +}) + +describe('SET_CAN_ADD_POINT', () => { + test('coerces the payload to a boolean', () => { + expect(actions.SET_CAN_ADD_POINT(initialState, false).canAddPoint).toBe(false) + expect(actions.SET_CAN_ADD_POINT(initialState, 1).canAddPoint).toBe(true) + }) +}) diff --git a/plugins/draw/src/utils/debounce.js b/plugins/draw/src/utils/debounce.js new file mode 100644 index 000000000..0e480ff6d --- /dev/null +++ b/plugins/draw/src/utils/debounce.js @@ -0,0 +1,16 @@ +// --- Debounce Helper --- +export const debounce = (fn, delay) => { + let timeout + + const debounced = (...args) => { + clearTimeout(timeout) + timeout = setTimeout(() => fn(...args), delay) + } + + debounced.cancel = () => { + clearTimeout(timeout) + timeout = null + } + + return debounced +} diff --git a/plugins/draw/src/utils/debounce.test.js b/plugins/draw/src/utils/debounce.test.js new file mode 100644 index 000000000..4cc1f3b21 --- /dev/null +++ b/plugins/draw/src/utils/debounce.test.js @@ -0,0 +1,40 @@ +import { debounce } from './debounce.js' + +jest.useFakeTimers() + +describe('debounce', () => { + test('invokes the function after the delay with the latest arguments', () => { + const fn = jest.fn() + const debounced = debounce(fn, 100) + + debounced('a') + expect(fn).not.toHaveBeenCalled() + + jest.advanceTimersByTime(100) + expect(fn).toHaveBeenCalledWith('a') + }) + + test('collapses rapid calls into a single trailing invocation', () => { + const fn = jest.fn() + const debounced = debounce(fn, 100) + + debounced(1) + debounced(2) + debounced(3) + jest.advanceTimersByTime(100) + + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith(3) + }) + + test('cancel prevents a pending invocation', () => { + const fn = jest.fn() + const debounced = debounce(fn, 100) + + debounced('x') + debounced.cancel() + jest.advanceTimersByTime(100) + + expect(fn).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/utils/eventBus.js b/plugins/draw/src/utils/eventBus.js new file mode 100644 index 000000000..fa17407f1 --- /dev/null +++ b/plugins/draw/src/utils/eventBus.js @@ -0,0 +1,36 @@ +/** + * Simple pub-sub event bus for plugin-internal communication. + * Used to decouple components that need to communicate events. + * + * @returns {{on: Function, off: Function, emit: Function}} + */ +export const createEventBus = () => { + const listeners = new Map() + + return { + on (type, handler) { + if (!listeners.has(type)) { + listeners.set(type, new Set()) + } + listeners.get(type).add(handler) + }, + + off (type, handler) { + listeners.get(type)?.delete(handler) + }, + + emit (type, ...args) { + const handlers = listeners.get(type) + if (handlers) { + // Array.from, not [...handlers] — under Docusaurus's docs build, Babel + // compiles with `loose: true` (@docusaurus/babel/preset.js), which turns + // spread-of-non-array-iterable into `[].concat(handlers)`. concat() only + // flattens real arrays; for a Set it appends the whole Set as a single + // element, so every handler call becomes `set.apply(...)` and throws + // "h.apply is not a function". Array.from() is a plain function call, so + // it's untouched by that transform and always iterates correctly. + Array.from(handlers).forEach(h => h(...args)) + } + } + } +} diff --git a/plugins/draw/src/utils/eventBus.test.js b/plugins/draw/src/utils/eventBus.test.js new file mode 100644 index 000000000..fe346b838 --- /dev/null +++ b/plugins/draw/src/utils/eventBus.test.js @@ -0,0 +1,47 @@ +import { createEventBus } from './eventBus.js' + +describe('createEventBus', () => { + test('emits to a registered handler with the supplied arguments', () => { + const bus = createEventBus() + const handler = jest.fn() + bus.on('thing', handler) + + bus.emit('thing', 1, 2) + + expect(handler).toHaveBeenCalledWith(1, 2) + }) + + test('supports multiple handlers for the same type', () => { + const bus = createEventBus() + const a = jest.fn() + const b = jest.fn() + bus.on('thing', a) + bus.on('thing', b) + + bus.emit('thing') + + expect(a).toHaveBeenCalled() + expect(b).toHaveBeenCalled() + }) + + test('off removes a handler so it no longer fires', () => { + const bus = createEventBus() + const handler = jest.fn() + bus.on('thing', handler) + bus.off('thing', handler) + + bus.emit('thing') + + expect(handler).not.toHaveBeenCalled() + }) + + test('emitting a type with no listeners is a no-op', () => { + const bus = createEventBus() + expect(() => bus.emit('nothing')).not.toThrow() + }) + + test('off on an unknown type does not throw', () => { + const bus = createEventBus() + expect(() => bus.off('unknown', jest.fn())).not.toThrow() + }) +}) diff --git a/plugins/draw/src/utils/flattenStyleProperties.js b/plugins/draw/src/utils/flattenStyleProperties.js new file mode 100644 index 000000000..d0099115c --- /dev/null +++ b/plugins/draw/src/utils/flattenStyleProperties.js @@ -0,0 +1,25 @@ +const STYLE_PROPS = new Set(['stroke', 'fill', 'strokeWidth']) + +export const flattenStyleProperties = (props) => { + if (!props) { + return {} + } + + const result = {} + + for (const [key, value] of Object.entries(props)) { + if (STYLE_PROPS.has(key) && typeof value === 'object' && value !== null) { + const entries = Object.entries(value) + if (entries.length > 0) { + result[key] = entries[0][1] + } + for (const [styleId, styleValue] of entries) { + result[`${key}${styleId.charAt(0).toUpperCase() + styleId.slice(1)}`] = styleValue + } + } else { + result[key] = value + } + } + + return result +} diff --git a/plugins/draw/src/utils/flattenStyleProperties.test.js b/plugins/draw/src/utils/flattenStyleProperties.test.js new file mode 100644 index 000000000..0727cc562 --- /dev/null +++ b/plugins/draw/src/utils/flattenStyleProperties.test.js @@ -0,0 +1,33 @@ +import { flattenStyleProperties } from './flattenStyleProperties.js' + +describe('flattenStyleProperties', () => { + test('returns an empty object for nullish input', () => { + expect(flattenStyleProperties(null)).toEqual({}) + expect(flattenStyleProperties(undefined)).toEqual({}) + }) + + test('flattens a style-variant object into base + per-style keys', () => { + const result = flattenStyleProperties({ stroke: { light: 'red', dark: 'blue' } }) + expect(result).toEqual({ + stroke: 'red', // base = first variant value + strokeLight: 'red', + strokeDark: 'blue' + }) + }) + + test('passes through a scalar style value unchanged', () => { + expect(flattenStyleProperties({ stroke: 'red' })).toEqual({ stroke: 'red' }) + }) + + test('passes through a null style value unchanged', () => { + expect(flattenStyleProperties({ fill: null })).toEqual({ fill: null }) + }) + + test('passes through non-style properties untouched', () => { + expect(flattenStyleProperties({ name: 'field', count: 3 })).toEqual({ name: 'field', count: 3 }) + }) + + test('ignores an empty style-variant object (no base, no variants)', () => { + expect(flattenStyleProperties({ fill: {} })).toEqual({}) + }) +}) diff --git a/plugins/draw/src/utils/getValueForStyle.js b/plugins/draw/src/utils/getValueForStyle.js new file mode 100644 index 000000000..7e3b4054c --- /dev/null +++ b/plugins/draw/src/utils/getValueForStyle.js @@ -0,0 +1,33 @@ +/** + * Resolve a configuration value which may be either a simple value (same for all styles) + * or an object with scheme/style variants. + * + * Useful for resolving any value (colors, sizes, etc.) across different map styles and color schemes. + * Can resolve based on both style ID and color scheme. + * + * Resolution order for objects: + * 1. Exact style ID match (e.g., { outdoor: '...', dark: '...' }) + * 2. Scheme match (e.g., { light: '...', dark: '...' }) + * 3. Fallback to 'light' property + * 4. First value in object + * + * @param {any} value - Simple value or variant object + * @param {string} scheme - Current color scheme ('light' or 'dark') + * @param {string|null} styleId - Map style ID for per-style customization + * @returns {any} Resolved value + */ +export const getValueForStyle = (value, scheme, styleId = null) => { + if (typeof value !== 'object' || value === null) { + return value + } + if (styleId && value[styleId] !== undefined) { + return value[styleId] + } + if (value[scheme] !== undefined) { + return value[scheme] + } + if (value.light !== undefined) { + return value.light + } + return Object.values(value)[0] +} diff --git a/plugins/draw/src/utils/getValueForStyle.test.js b/plugins/draw/src/utils/getValueForStyle.test.js new file mode 100644 index 000000000..cf743fb4a --- /dev/null +++ b/plugins/draw/src/utils/getValueForStyle.test.js @@ -0,0 +1,32 @@ +import { getValueForStyle } from './getValueForStyle.js' + +describe('getValueForStyle', () => { + test('returns primitive values unchanged', () => { + expect(getValueForStyle('red', 'light')).toBe('red') + expect(getValueForStyle(5, 'light')).toBe(5) + }) + + test('returns null unchanged', () => { + expect(getValueForStyle(null, 'light')).toBeNull() + }) + + test('prefers an exact style-id match', () => { + expect(getValueForStyle({ outdoor: 'green', light: 'red' }, 'light', 'outdoor')).toBe('green') + }) + + test('falls back to the scheme match when the style id is absent', () => { + expect(getValueForStyle({ light: 'red', dark: 'blue' }, 'dark', 'outdoor')).toBe('blue') + }) + + test('uses the scheme match when no style id is provided', () => { + expect(getValueForStyle({ light: 'red', dark: 'blue' }, 'dark')).toBe('blue') + }) + + test('falls back to the light property when the scheme is missing', () => { + expect(getValueForStyle({ light: 'red', dark: 'blue' }, 'sepia')).toBe('red') + }) + + test('falls back to the first value when neither scheme nor light exist', () => { + expect(getValueForStyle({ outdoor: 'green', satellite: 'grey' }, 'dark')).toBe('green') + }) +}) diff --git a/plugins/draw/src/utils/resolveColors.js b/plugins/draw/src/utils/resolveColors.js new file mode 100644 index 000000000..e666f1499 --- /dev/null +++ b/plugins/draw/src/utils/resolveColors.js @@ -0,0 +1,39 @@ +import { COLORS, SIZES } from '../defaults.js' +import { getValueForStyle } from './getValueForStyle.js' + +/** + * Resolve all draw colors for the given map style and plugin config overrides. + * Shared by both adapters (MapLibre and OpenLayers) — they draw from the same + * COLORS/SIZES palette (defaults.js), so a plugin-level override behaves + * identically regardless of which map provider is in use. + * + * Values in pluginConfig may be plain strings or variant objects (e.g. { light: '...', dark: '...' }). + * Variant resolution order: exact style ID match → color scheme → 'light' fallback → first value. + * + * @param {object|null} mapStyle - Current map style object (has .id and .mapColorScheme) + * @param {object} pluginConfig - Plugin-level user overrides (may override any COLORS key) + * @returns {object} Flat color values ready for use in each adapter's styles module + */ +export const resolveColors = (mapStyle, pluginConfig = {}) => { + const scheme = mapStyle?.mapColorScheme ?? 'light' + const styleId = mapStyle?.id ?? null + const resolveColor = (key) => getValueForStyle(pluginConfig[key] ?? COLORS[key], scheme, styleId) + + return { + editStroke: resolveColor('editStroke'), + editFill: resolveColor('editFill'), + editVertex: resolveColor('editVertex'), + editMidpoint: resolveColor('editMidpoint'), + editActive: resolveColor('editActive'), + editHalo: resolveColor('editHalo'), + invalidStroke: resolveColor('invalidStroke'), + splitValid: resolveColor('splitValid'), + splitInvalid: resolveColor('splitInvalid'), + shapeStroke: resolveColor('shapeStroke'), + strokeWidth: pluginConfig.strokeWidth ?? SIZES.strokeWidth, + shapeFill: resolveColor('shapeFill'), + snapVertex: resolveColor('snapVertex'), + snapEdge: resolveColor('snapEdge'), + mapStyleId: styleId + } +} diff --git a/plugins/draw/src/utils/resolveColors.test.js b/plugins/draw/src/utils/resolveColors.test.js new file mode 100644 index 000000000..a19c23660 --- /dev/null +++ b/plugins/draw/src/utils/resolveColors.test.js @@ -0,0 +1,34 @@ +import { resolveColors } from './resolveColors.js' +import { COLORS, SIZES } from '../defaults.js' + +test('without a map style, colours resolve to their light variants and defaults', () => { + const colors = resolveColors(null) + expect(colors.editStroke).toBe(COLORS.editStroke.light) + expect(colors.shapeStroke).toBe(COLORS.shapeStroke) // plain value — no variants + expect(colors.strokeWidth).toBe(SIZES.strokeWidth) + expect(colors.mapStyleId).toBeNull() +}) + +test('splitter colours are resolved', () => { + const colors = resolveColors(null) + expect(colors.splitValid).toBe(COLORS.splitValid.light) + expect(colors.splitInvalid).toBe(COLORS.splitInvalid.light) +}) + +test('a dark map style resolves dark variants and carries its id through', () => { + const colors = resolveColors({ id: 'dark', mapColorScheme: 'dark' }) + expect(colors.editStroke).toBe(COLORS.editStroke.dark) + expect(colors.mapStyleId).toBe('dark') +}) + +test('plugin config overrides beat the defaults, as strings or scheme variants', () => { + const colors = resolveColors({ id: 'road', mapColorScheme: 'dark' }, { + editStroke: '#custom', + editFill: { light: '#l', dark: '#d' }, + strokeWidth: 9 + }) + expect(colors.editStroke).toBe('#custom') + expect(colors.editFill).toBe('#d') + expect(colors.strokeWidth).toBe(9) + expect(colors.editVertex).toBe(COLORS.editVertex.dark) // untouched keys still resolve +}) diff --git a/plugins/draw/src/utils/spatial.js b/plugins/draw/src/utils/spatial.js new file mode 100755 index 000000000..adf46b7eb --- /dev/null +++ b/plugins/draw/src/utils/spatial.js @@ -0,0 +1,243 @@ +import polygonSplitter from 'polygon-splitter' +import turfUnion from '@turf/union' +import { + featureCollection as turfFeatureCollection, + polygon as turfPolygon, + multiPolygon as turfMultiPolygon, + lineString as turfLineString, + multiLineString as turfMultiLineString, + point as turfPoint, + multiPoint as turfMultiPoint +} from '@turf/helpers' + +/** + * @typedef {import('geojson').Feature} Polygon + * @typedef {import('geojson').Feature} Line + * @typedef {import('geojson').Feature} Feature + * @typedef {import('geojson').FeatureCollection} FeatureCollection + */ + +/** + * Extend a LineString at its endpoints, along their own direction, by a small + * fraction of the adjacent segment's length. Pure planar vector math — no + * geodesic assumption, so it works for any coordinate system (lon/lat degrees, + * projected meters like British National Grid, etc.). Turf's bearing/distance + * functions assume WGS84 lon/lat input; feeding them projected coordinates + * silently produces nonsense (large eastings/northings get treated as + * out-of-range degrees and wrapped), which is why this doesn't use them. + * + * @param {Feature} line + * @param {number} fraction - portion of the adjacent segment's length to extend by + */ +function extendLine (line, fraction = 0.01) { + const coords = line.geometry.coordinates.map(c => [...c]) + const last = coords.length - 1 + + const extend = (from, towards) => [ + from[0] + (from[0] - towards[0]) * fraction, + from[1] + (from[1] - towards[1]) * fraction + ] + + // Compute both from the original coordinates before assigning either — for a + // 2-point line, coords[last - 1] is coords[0], so writing coords[0] first + // would corrupt the reference point the end extension reads. + const newStart = extend(coords[0], coords[1]) + const newEnd = extend(coords[last], coords[last - 1]) + coords[0] = newStart + coords[last] = newEnd + + return turfLineString(coords) +} + +/** + * Split a polygon using a line. + * Only accepts splits that result in exactly two polygons. + * + * @param {Feature} polygon + * @param {Feature} line + * @returns {FeatureCollection|null} + */ +const splitPolygon = (polygon, line) => { + // Extend only start and end vertices + const extended = extendLine(line) // assume extendLine only touches start/end now + + let result + try { + result = polygonSplitter(polygon, extended) + } catch { + return null + } + + // Must result in exactly 2 polygons + let polygons = [] + if (result.geometry.type === 'MultiPolygon') { + if (result.geometry.coordinates.length !== 2) { + return null + } + polygons = result.geometry.coordinates.map(coords => turfPolygon(coords, polygon.properties)) + } else { + return null + } + + // Assign IDs & properties + const baseId = polygon.id ?? polygon.properties?.id ?? 'poly' + const features = polygons.map((poly, i) => + turfPolygon( + poly.geometry.coordinates, + { ...polygon.properties, id: baseId }, + { id: `${baseId}-${i + 1}` } + ) + ) + + return turfFeatureCollection(features) +} + +/** + * Merge multiple contiguous polygons into a single polygon. + * Only accepts a merge that results in exactly one polygon — a gap between two + * of the inputs would make Turf return a MultiPolygon instead, which is rejected. + * + * @param {Polygon[]} polygons + * @returns {Polygon|null} + */ +const mergePolygons = (polygons) => { + let result + try { + result = turfUnion(turfFeatureCollection(polygons)) + } catch { + return null + } + + if (result?.geometry?.type !== 'Polygon') { + return null + } + + // Assign ID & properties from the first feature, matching splitPolygon's convention. + const baseId = polygons[0].id ?? polygons[0].properties?.id ?? 'poly' + return turfPolygon(result.geometry.coordinates, { ...polygons[0].properties, id: baseId }, { id: baseId }) +} + +/** + * Convert a GeoJSON Feature or geometry-like object into a Turf geometry. + * + * @param {Object} featureOrGeom - Either a Feature with a `.geometry` property or a raw GeoJSON geometry object. + * @returns {Object} Turf geometry (Polygon, LineString, Point, etc.) + * + * @throws Will throw if the geometry type is not supported. + */ +const toTurfGeometry = (featureOrGeom) => { + const geom = featureOrGeom.geometry || featureOrGeom + + switch (geom.type) { + case 'Polygon': + return turfPolygon(geom.coordinates) + case 'MultiPolygon': + return turfMultiPolygon(geom.coordinates) + case 'LineString': + return turfLineString(geom.coordinates) + case 'MultiLineString': + return turfMultiLineString(geom.coordinates) + case 'Point': + return turfPoint(geom.coordinates) + case 'MultiPoint': + return turfMultiPoint(geom.coordinates) + default: + throw new Error(`Unsupported geometry type: ${geom.type}`) + } +} + +const DEGREES_PER_HALF_TURN = 180 + +const haversine = ([lon1, lat1], [lon2, lat2]) => { + const toRad = deg => deg * Math.PI / DEGREES_PER_HALF_TURN + const R = 6371000 // meters + const dLat = toRad(lat2 - lat1) + const dLon = toRad(lon2 - lon1) + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2 + return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) +} + +const SMALL_RING_LENGTH = 3 + +// True if any two points in the ring are within `tolerance` of each other +const hasDuplicatePair = (ring, tolerance) => { + for (let i = 0; i < ring.length; i++) { + for (let j = i + 1; j < ring.length; j++) { + if (haversine(ring[i], ring[j]) < tolerance) { + return true + } + } + } + return false +} + +const isNewCoordinate = (coords, tolerance = 0.01) => { + const ring = coords[0] + // First coord is always new + if (ring.length <= 1) { + return true + } + // For small rings, reject if any two points coincide + if (ring.length <= SMALL_RING_LENGTH && hasDuplicatePair(ring, tolerance)) { + return false + } + return true +} + +const isValidLineClick = (coords, tolerance = 0.01) => { + // First coord is always valid + if (coords.length <= 1) { + return true + } + // The new coordinate must differ from the previous one + const last = coords[coords.length - 1] + const secondLast = coords[coords.length - 2] + return haversine(last, secondLast) >= tolerance +} + +const isValidClick = (coords) => { + // Valid when it's the very first point, or a genuinely new (non-duplicate) coordinate. + // Callers only pass single-ring polygon coordinates while drawing, so no ring-closure / + // self-intersection checks are needed here. + return coords[0].length <= 1 || isNewCoordinate(coords) +} + +const spatialNavigate = (start, pixels, direction) => { + const quadrant = pixels.filter((p) => { + const offsetX = Math.abs(p[0] - start[0]) + const offsetY = Math.abs(p[1] - start[1]) + let isQuadrant = false + if (direction === 'ArrowUp') { + isQuadrant = p[1] <= start[1] && offsetY >= offsetX + } else if (direction === 'ArrowDown') { + isQuadrant = p[1] > start[1] && offsetY >= offsetX + } else if (direction === 'ArrowLeft') { + isQuadrant = p[0] <= start[0] && offsetY < offsetX + } else if (direction === 'ArrowRight') { + isQuadrant = p[0] > start[0] && offsetY < offsetX + } else { + isQuadrant = true + } + return isQuadrant && (JSON.stringify(p) !== JSON.stringify(start)) + }) + + if (!quadrant.length) { + quadrant.push(start) + } + + const pythagorean = (a, b) => Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2)) + const distances = quadrant.map(p => pythagorean(Math.abs(start[0] - p[0]), Math.abs(start[1] - p[1]))) + const closest = quadrant[distances.indexOf(Math.min(...distances))] + return pixels.findIndex(i => JSON.stringify(i) === JSON.stringify(closest)) +} + +export { + toTurfGeometry, + splitPolygon, + mergePolygons, + extendLine, + isNewCoordinate, + isValidClick, + isValidLineClick, + spatialNavigate +} diff --git a/plugins/draw/src/utils/spatial.test.js b/plugins/draw/src/utils/spatial.test.js new file mode 100644 index 000000000..960327ce4 --- /dev/null +++ b/plugins/draw/src/utils/spatial.test.js @@ -0,0 +1,243 @@ +import polygonSplitter from 'polygon-splitter' +import turfUnion from '@turf/union' +import { + toTurfGeometry, + splitPolygon, + mergePolygons, + extendLine, + isNewCoordinate, + isValidClick, + isValidLineClick, + spatialNavigate +} from './spatial.js' + +jest.mock('polygon-splitter', () => jest.fn()) +jest.mock('@turf/union', () => jest.fn()) + +beforeEach(() => jest.clearAllMocks()) + +describe('toTurfGeometry', () => { + test.each([ + ['Polygon', { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }], + ['MultiPolygon', { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]]] }], + ['LineString', { type: 'LineString', coordinates: [[0, 0], [1, 1]] }], + ['MultiLineString', { type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]]] }], + ['Point', { type: 'Point', coordinates: [0, 0] }], + ['MultiPoint', { type: 'MultiPoint', coordinates: [[0, 0], [1, 1]] }] + ])('converts a raw %s geometry', (type, geom) => { + expect(toTurfGeometry(geom).geometry.type).toBe(type) + }) + + test('unwraps a Feature via its .geometry property', () => { + const feature = { geometry: { type: 'Point', coordinates: [0, 0] } } + expect(toTurfGeometry(feature).geometry.type).toBe('Point') + }) + + test('throws for unsupported geometry types', () => { + expect(() => toTurfGeometry({ type: 'GeometryCollection' })) + .toThrow('Unsupported geometry type: GeometryCollection') + }) +}) + +describe('extendLine', () => { + test('extends a two-point line at both ends, along each segment\'s own direction', () => { + const line = { geometry: { coordinates: [[0, 0], [0, 10]] } } + const result = extendLine(line, 0.1) + expect(result.geometry.type).toBe('LineString') + // Start moves further away from the second point (backward); end moves + // further away from the second-to-last point (forward). + expect(result.geometry.coordinates).toEqual([[0, -1], [0, 11]]) + }) + + test('extends only the endpoints of a multi-point line', () => { + const line = { geometry: { coordinates: [[0, 0], [0, 1], [0, 2]] } } + const result = extendLine(line, 0.1) + expect(result.geometry.coordinates).toHaveLength(3) + expect(result.geometry.coordinates[1]).toEqual([0, 1]) // middle vertex untouched + }) + + test('is pure planar math — correct at British National Grid scale (large eastings/northings)', () => { + // Geodesic bearing/destination would misread these as out-of-range lon/lat + // degrees and wrap them; plain vector extension has no such assumption. + const line = { geometry: { coordinates: [[337600, 504600], [337650, 504650]] } } + const result = extendLine(line, 0.1) + expect(result.geometry.coordinates[0]).toEqual([337595, 504595]) + expect(result.geometry.coordinates[1]).toEqual([337655, 504655]) + }) +}) + +describe('splitPolygon', () => { + const polygon = { + id: 'sq', + properties: { id: 'sq', name: 'field' }, + geometry: { type: 'Polygon', coordinates: [[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]] } + } + const line = { geometry: { type: 'LineString', coordinates: [[2, -1], [2, 5]] } } + + const twoPolygonResult = { + geometry: { + type: 'MultiPolygon', + coordinates: [ + [[[0, 0], [2, 0], [2, 4], [0, 4], [0, 0]]], + [[[2, 0], [4, 0], [4, 4], [2, 4], [2, 0]]] + ] + } + } + + test('returns a two-feature collection for a valid split', () => { + polygonSplitter.mockReturnValue(twoPolygonResult) + + const result = splitPolygon(polygon, line) + + expect(result.type).toBe('FeatureCollection') + expect(result.features).toHaveLength(2) + expect(result.features[0].id).toBe('sq-1') + expect(result.features[1].id).toBe('sq-2') + expect(result.features[0].properties).toMatchObject({ id: 'sq', name: 'field' }) + }) + + test('returns null when the splitter throws', () => { + polygonSplitter.mockImplementation(() => { throw new Error('bad geometry') }) + expect(splitPolygon(polygon, line)).toBeNull() + }) + + test('returns null when the result is not a MultiPolygon', () => { + polygonSplitter.mockReturnValue({ geometry: { type: 'Polygon', coordinates: [] } }) + expect(splitPolygon(polygon, line)).toBeNull() + }) + + test('returns null when the split does not produce exactly two polygons', () => { + polygonSplitter.mockReturnValue({ geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]]] } }) + expect(splitPolygon(polygon, line)).toBeNull() + }) + + test('derives the base id from properties.id then a default', () => { + polygonSplitter.mockReturnValue(twoPolygonResult) + + const noTopLevelId = { properties: { id: 'pid' }, geometry: polygon.geometry } + expect(splitPolygon(noTopLevelId, line).features[0].id).toBe('pid-1') + + const anonymous = { properties: {}, geometry: polygon.geometry } + expect(splitPolygon(anonymous, line).features[0].id).toBe('poly-1') + }) +}) + +describe('mergePolygons', () => { + const squareA = { + id: 'sq-a', + properties: { id: 'sq-a', name: 'field' }, + geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] } + } + const squareB = { + id: 'sq-b', + properties: { id: 'sq-b' }, + geometry: { type: 'Polygon', coordinates: [[[1, 0], [2, 0], [2, 1], [1, 1], [1, 0]]] } + } + + const mergedResult = { + geometry: { type: 'Polygon', coordinates: [[[0, 0], [2, 0], [2, 1], [0, 1], [0, 0]]] } + } + + test('returns a single merged polygon for a valid union', () => { + turfUnion.mockReturnValue(mergedResult) + + const result = mergePolygons([squareA, squareB]) + + expect(result.geometry.type).toBe('Polygon') + expect(result.id).toBe('sq-a') + expect(result.properties).toMatchObject({ id: 'sq-a', name: 'field' }) + }) + + test('returns null when union throws', () => { + turfUnion.mockImplementation(() => { throw new Error('bad geometry') }) + expect(mergePolygons([squareA, squareB])).toBeNull() + }) + + test('returns null when the inputs are not contiguous (MultiPolygon result)', () => { + turfUnion.mockReturnValue({ geometry: { type: 'MultiPolygon', coordinates: [] } }) + expect(mergePolygons([squareA, squareB])).toBeNull() + }) + + test('returns null when union returns null', () => { + turfUnion.mockReturnValue(null) + expect(mergePolygons([squareA, squareB])).toBeNull() + }) + + test('derives the base id from properties.id then a default', () => { + turfUnion.mockReturnValue(mergedResult) + + const noTopLevelId = { properties: { id: 'pid' }, geometry: squareA.geometry } + expect(mergePolygons([noTopLevelId, squareB]).id).toBe('pid') + + const anonymous = { properties: {}, geometry: squareA.geometry } + expect(mergePolygons([anonymous, squareB]).id).toBe('poly') + }) +}) + +describe('isNewCoordinate', () => { + test('is true for a ring with a single point', () => { + expect(isNewCoordinate([[[0, 0]]])).toBe(true) + }) + + test('is false when a small ring has coincident points', () => { + expect(isNewCoordinate([[[0, 0], [0, 0]]])).toBe(false) + }) + + test('is true when a small ring has distinct points', () => { + expect(isNewCoordinate([[[0, 0], [10, 10]]])).toBe(true) + }) + + test('skips the duplicate check for larger rings', () => { + expect(isNewCoordinate([[[0, 0], [0, 0], [1, 1], [2, 2], [3, 3]]])).toBe(true) + }) +}) + +describe('isValidClick', () => { + test('is true when the ring has a single point', () => { + expect(isValidClick([[[0, 0]]])).toBe(true) + }) + + test('is true for a new (non-duplicate) coordinate', () => { + expect(isValidClick([[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]])).toBe(true) + }) + + test('is false when a small ring repeats a coordinate', () => { + expect(isValidClick([[[0, 0], [0, 0]]])).toBe(false) + }) +}) + +describe('isValidLineClick', () => { + test('is true for the first coordinate', () => { + expect(isValidLineClick([[0, 0]])).toBe(true) + }) + + test('is true when the last two coordinates differ', () => { + expect(isValidLineClick([[0, 0], [10, 10]])).toBe(true) + }) + + test('is false when the last two coordinates coincide', () => { + expect(isValidLineClick([[0, 0], [0, 0]])).toBe(false) + }) +}) + +describe('spatialNavigate', () => { + const start = [0, 0] + const pixels = [[0, 0], [0, -10], [0, 10], [-10, 0], [10, 0]] + + test.each([ + ['ArrowUp', 1], + ['ArrowDown', 2], + ['ArrowLeft', 3], + ['ArrowRight', 4] + ])('finds the nearest pixel for %s', (direction, expectedIndex) => { + expect(spatialNavigate(start, pixels, direction)).toBe(expectedIndex) + }) + + test('considers all pixels for an unrecognised direction', () => { + expect(spatialNavigate(start, pixels, 'Tab')).toBe(1) + }) + + test('falls back to the start point when no pixel is in the quadrant', () => { + expect(spatialNavigate(start, [[0, 0]], 'ArrowUp')).toBe(0) + }) +}) diff --git a/plugins/draw/src/utils/touchTarget.js b/plugins/draw/src/utils/touchTarget.js new file mode 100644 index 000000000..662d4d996 --- /dev/null +++ b/plugins/draw/src/utils/touchTarget.js @@ -0,0 +1,95 @@ +/** + * SVG offset vertex target for touch edit mode + * Shown below the finger so users can accurately reposition vertices without finger occlusion. + * + * Colors update dynamically based on current color scheme via CSS custom properties: + * --draw-halo outer ring (editActive color) + * --draw-bg inner background (editHalo color) + * --draw-primary arrow icons and centre dot (editVertex color) + */ + +import { SIZES } from '../defaults.js' + +const HALF_SIZE = SIZES.touchTargetSize / 2 + +/** + * Generate SVG touch target HTML string based on configured size + * @returns {string} SVG HTML + */ +function generateSvgHtml () { + const size = SIZES.touchTargetSize + const half = HALF_SIZE + return ` + + ` +} + +/** + * Create or retrieve the touch target SVG element + * @param {HTMLElement} container - Parent container element + * @returns {SVGElement} The touch target element + */ +export const createTouchTarget = (container) => { + let el = container.querySelector('[data-im-draw-touch-target]') + if (!el) { + container.insertAdjacentHTML('beforeend', generateSvgHtml()) + el = container.querySelector('[data-im-draw-touch-target]') + } + return el +} + +/** + * Apply color scheme to touch target SVG + * @param {SVGElement} el - Touch target element + * @param {object} colors - Color object with editActive, editHalo, editVertex properties + */ +export const applyTouchTargetColors = (el, colors) => { + if (!el) { + return + } + el.style.setProperty('--draw-halo', colors.editActive) + el.style.setProperty('--draw-bg', colors.editHalo) + el.style.setProperty('--draw-primary', colors.editVertex) +} + +/** + * Show touch target at specified pixel position + * @param {SVGElement} el - Touch target element + * @param {object} pixel - Position object with x, y properties + */ +export const showTouchTarget = (el, pixel) => { + if (!pixel || !el) { + return + } + el.style.left = `${pixel.x}px` + el.style.top = `${pixel.y}px` + el.style.display = 'block' +} + +/** + * Hide touch target + * @param {SVGElement} el - Touch target element + */ +export const hideTouchTarget = (el) => { + if (el) { + el.style.display = 'none' + } +} + +/** + * Check if event target is part of the touch target SVG + * @param {HTMLElement} el - Element to check + * @returns {boolean} True if element is part of touch target + */ +export const isOnTouchTarget = (el) => { + if (!el) { + return false + } + const parent = el.parentNode + return (parent instanceof globalThis.SVGElement) || (parent?.ownerSVGElement != null) +} diff --git a/plugins/draw/src/utils/touchTarget.test.js b/plugins/draw/src/utils/touchTarget.test.js new file mode 100644 index 000000000..ad36988b2 --- /dev/null +++ b/plugins/draw/src/utils/touchTarget.test.js @@ -0,0 +1,107 @@ +import { + createTouchTarget, + applyTouchTargetColors, + showTouchTarget, + hideTouchTarget, + isOnTouchTarget +} from './touchTarget.js' + +const SVG_NS = 'http://www.w3.org/2000/svg' + +describe('createTouchTarget', () => { + test('inserts the SVG target once and returns it', () => { + const container = document.createElement('div') + + const el = createTouchTarget(container) + + expect(el).not.toBeNull() + expect(el.hasAttribute('data-im-draw-touch-target')).toBe(true) + expect(container.querySelectorAll('[data-im-draw-touch-target]')).toHaveLength(1) + }) + + test('returns the existing target without inserting a second one', () => { + const container = document.createElement('div') + const first = createTouchTarget(container) + const second = createTouchTarget(container) + + expect(second).toBe(first) + expect(container.querySelectorAll('[data-im-draw-touch-target]')).toHaveLength(1) + }) +}) + +describe('applyTouchTargetColors', () => { + test('sets the CSS custom properties from the colours', () => { + const el = document.createElement('div') + + applyTouchTargetColors(el, { editActive: 'a', editHalo: 'h', editVertex: 'v' }) + + expect(el.style.getPropertyValue('--draw-halo')).toBe('a') + expect(el.style.getPropertyValue('--draw-bg')).toBe('h') + expect(el.style.getPropertyValue('--draw-primary')).toBe('v') + }) + + test('does nothing for a null element', () => { + expect(() => applyTouchTargetColors(null, {})).not.toThrow() + }) +}) + +describe('showTouchTarget', () => { + test('positions and shows the element', () => { + const el = document.createElement('div') + + showTouchTarget(el, { x: 10, y: 20 }) + + expect(el.style.left).toBe('10px') + expect(el.style.top).toBe('20px') + expect(el.style.display).toBe('block') + }) + + test('does nothing without a pixel or an element', () => { + const el = document.createElement('div') + showTouchTarget(el, null) + expect(el.style.display).toBe('') + + expect(() => showTouchTarget(null, { x: 0, y: 0 })).not.toThrow() + }) +}) + +describe('hideTouchTarget', () => { + test('hides the element', () => { + const el = document.createElement('div') + el.style.display = 'block' + + hideTouchTarget(el) + + expect(el.style.display).toBe('none') + }) + + test('does nothing for a null element', () => { + expect(() => hideTouchTarget(null)).not.toThrow() + }) +}) + +describe('isOnTouchTarget', () => { + test('returns false for a null element', () => { + expect(isOnTouchTarget(null)).toBe(false) + }) + + test('returns false for an element without a parent', () => { + expect(isOnTouchTarget(document.createElement('div'))).toBe(false) + }) + + test('returns true when the parent is an SVG element', () => { + const svg = document.createElementNS(SVG_NS, 'svg') + const circle = document.createElementNS(SVG_NS, 'circle') + svg.appendChild(circle) + + expect(isOnTouchTarget(circle)).toBe(true) + }) + + test('returns false when the parent is a plain HTML element', () => { + const div = document.createElement('div') + const span = document.createElement('span') + div.appendChild(span) + + expect(isOnTouchTarget(span)).toBe(false) + }) +}) diff --git a/plugins/draw/src/utils/undoStack.js b/plugins/draw/src/utils/undoStack.js new file mode 100644 index 000000000..bd27e4659 --- /dev/null +++ b/plugins/draw/src/utils/undoStack.js @@ -0,0 +1,31 @@ +/** + * Undo stack for draw operations. + * Calls onChange(length) whenever the stack changes so the UI can update. + * + * @param {(length: number) => void} onChange + */ +export const createUndoStack = (onChange) => { + const stack = [] + + return { + push (operation) { + stack.push(operation) + onChange(stack.length) + }, + + pop () { + const op = stack.pop() + onChange(stack.length) + return op + }, + + clear () { + stack.length = 0 + onChange(stack.length) + }, + + get length () { + return stack.length + } + } +} diff --git a/plugins/draw/src/utils/undoStack.test.js b/plugins/draw/src/utils/undoStack.test.js new file mode 100644 index 000000000..cfe74ea09 --- /dev/null +++ b/plugins/draw/src/utils/undoStack.test.js @@ -0,0 +1,49 @@ +import { createUndoStack } from './undoStack.js' + +describe('createUndoStack', () => { + test('push adds an operation and reports the new length', () => { + const onChange = jest.fn() + const stack = createUndoStack(onChange) + + stack.push({ id: 1 }) + stack.push({ id: 2 }) + + expect(stack.length).toBe(2) + expect(onChange).toHaveBeenNthCalledWith(1, 1) + expect(onChange).toHaveBeenNthCalledWith(2, 2) + }) + + test('pop returns and removes the last operation, reporting the length', () => { + const onChange = jest.fn() + const stack = createUndoStack(onChange) + stack.push({ id: 1 }) + onChange.mockClear() + + const op = stack.pop() + + expect(op).toEqual({ id: 1 }) + expect(stack.length).toBe(0) + expect(onChange).toHaveBeenCalledWith(0) + }) + + test('pop on an empty stack returns undefined and reports zero', () => { + const onChange = jest.fn() + const stack = createUndoStack(onChange) + + expect(stack.pop()).toBeUndefined() + expect(onChange).toHaveBeenCalledWith(0) + }) + + test('clear empties the stack and reports zero', () => { + const onChange = jest.fn() + const stack = createUndoStack(onChange) + stack.push({ id: 1 }) + stack.push({ id: 2 }) + onChange.mockClear() + + stack.clear() + + expect(stack.length).toBe(0) + expect(onChange).toHaveBeenCalledWith(0) + }) +}) diff --git a/plugins/draw/src/validation/liveDrawChecks.js b/plugins/draw/src/validation/liveDrawChecks.js new file mode 100644 index 000000000..17fbac469 --- /dev/null +++ b/plugins/draw/src/validation/liveDrawChecks.js @@ -0,0 +1,120 @@ +import { validateGeometry } from './validateGeometry.js' +import { LIVE_RULES, HARD_RULES, MIN_VERTICES } from './rules.js' +import { requestFrame, cancelFrame } from './liveStroke.js' + +/** + * Live validation driver for DRAW mode (draw_polygon/draw_line) only — edit mode + * has no Add-point gate and keeps using the plain createLiveStroke (liveStroke.js). + * + * On every rubber-band move, a user `onGeometryChange` callback is called ONCE, + * throttled to one call per animation frame (phase 'preview', with NO + * vertex-count floor — a location-based rule is meaningful even against the + * very first candidate point, before any vertex is committed). Its verdict is + * combined with two INDEPENDENTLY-floored built-in rule checks, each flip-guarded + * and reported through its own callback — `onChange(invalid, reason)` fires only + * when THAT gate's own state flips, same contract as createLiveStroke: + * - LIVE_RULES (closed-ring self-intersect/area), floored at the geometry + * type's minimum vertex count — self-intersection/area are meaningless + * below that — reported via onStrokeChange (dashed stroke / Done gate). + * - HARD_RULES (open-path self-intersect), never floored, so it still + * protects placing an invalid first vertex — reported via onPlaceChange + * (Add-point gate). + * Previously each gate ran its own independently-throttled call to the same + * user callback (one forcing phase 'preview', the other forcing phase 'place'), + * firing it twice per rubber-band move for what was really one live moment. + * This unifies that into a single call, whose verdict both gates share. + * + * Being flip-guarded itself (unlike an earlier version of this module), a + * caller only strictly needs createLiveStroke for onStrokeChange — where + * MaplibreDrawAdapter.js/OL DrawMode.js feed it into their existing shared + * `_liveStroke.set()` sink, since that instance is also driven independently + * by edit mode and must stay the single source of truth for the rendered + * stroke. onPlaceChange has no such cross-mode instance to stay in sync with — + * draw mode is its only caller — so it can emit CAN_PLACE_CHANGE directly. + * + * @param {object} params + * @param {(invalid: boolean, reason: string|null) => void} params.onStrokeChange + * @param {(vetoed: boolean, reason: string|null) => void} params.onPlaceChange + * @returns {{ update: Function, reset: Function, destroy: Function }} + */ +export const createLiveDrawChecks = ({ onStrokeChange, onPlaceChange }) => { + // A small flip-guarded gate — same shape as liveStroke.js's own `flip`, one + // per output so the stroke and placement verdicts track independently. + const makeGate = (onChange) => { + let invalid = false + return (next, reason) => { + if (next !== invalid) { + invalid = next + onChange(next, reason ?? null) + } + } + } + const flipStroke = makeGate(onStrokeChange) + const flipPlace = makeGate(onPlaceChange) + + let customInvalid = false + let customReason = null + let frame = null + let pending = null + + const cancelPending = () => { + if (frame != null) { cancelFrame(frame); frame = null } + pending = null + } + + // One built-in rule set, its own floor, and the gate it drives — the stroke + // and placement checks are otherwise identical shape. + const evaluateGate = (feature, context, rules, floor, flip) => { + const numVertices = context.numVertices ?? 0 + const result = numVertices < floor + ? { valid: true } + : validateGeometry(feature, { ...context, phase: 'preview' }, { rules }) + flip(!result.valid || customInvalid, result.valid ? customReason : result.reason) + } + + const recompute = () => { + if (!pending) { return } + const { feature, context } = pending + const min = MIN_VERTICES[feature?.geometry?.type] ?? 0 + evaluateGate(feature, context, LIVE_RULES, min, flipStroke) + evaluateGate(feature, context, HARD_RULES, 0, flipPlace) + } + + const runUserRule = () => { + frame = null + if (!pending) { return } + const { feature, context, onGeometryChange } = pending + const result = validateGeometry(feature, { ...context, phase: 'preview' }, { rules: [], onGeometryChange }) + customInvalid = !result.valid + customReason = result.reason + recompute() + } + + return { + // Re-evaluate the displayed geometry after a rubber-band move. + update ({ feature, context = {}, numVertices, onGeometryChange }) { + pending = { feature, context: { ...context, numVertices }, onGeometryChange } + // Built-in rules first, synchronously — immediate feedback either gate. + recompute() + // A user rule (if any) decides last, throttled to one frame; its cached + // verdict (customInvalid) feeds the next recompute() once it lands. + if (typeof onGeometryChange === 'function' && frame == null) { + frame = requestFrame(runUserRule) + } + }, + // Authoritative reset for starting a fresh draw session: clears cached + // custom validity and drops any pending frame so a previous session's + // verdict can't leak, and re-asserts both gates back to valid — through + // the same flip guards, so it only fires what actually changed. + reset () { + cancelPending() + customInvalid = false + customReason = null + flipStroke(false, null) + flipPlace(false, null) + }, + destroy () { + cancelPending() + } + } +} diff --git a/plugins/draw/src/validation/liveDrawChecks.test.js b/plugins/draw/src/validation/liveDrawChecks.test.js new file mode 100644 index 000000000..e03055b39 --- /dev/null +++ b/plugins/draw/src/validation/liveDrawChecks.test.js @@ -0,0 +1,159 @@ +import { createLiveDrawChecks } from './liveDrawChecks.js' + +const poly = (ring) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } }) + +// Closed-ring self-intersect only (LIVE_RULES fails, HARD_RULES/open-path passes) — +// only the implicit closing edge crosses. +const closingEdgeCrosses = poly([[0, 0], [2, 0], [0, 2], [2, 2]]) +// Open-path self-intersect (HARD_RULES fails) — also fails LIVE_RULES, since a +// crossing open path implies a crossing closed one too. +const openPathCrosses = poly([[0, 0], [2, 2], [2, 0], [0, 2]]) +const square = poly([[0, 0], [10, 0], [10, 10], [0, 10]]) + +const setup = () => { + const onStrokeChange = jest.fn() + const onPlaceChange = jest.fn() + const checks = createLiveDrawChecks({ onStrokeChange, onPlaceChange }) + return { onStrokeChange, onPlaceChange, checks } +} + +beforeEach(() => jest.useFakeTimers()) +afterEach(() => jest.useRealTimers()) + +describe('built-in rules (synchronous, independently floored, flip-guarded)', () => { + test('LIVE_RULES (closed ring) gates the stroke; HARD_RULES (open path) gates placement — independently', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: closingEdgeCrosses, numVertices: 3 }) + expect(onStrokeChange).toHaveBeenCalledWith(true, expect.any(String)) // dashed + expect(onPlaceChange).not.toHaveBeenCalled() // still placeable — never flipped from its valid default + }) + + test('an open-path crossing vetoes placement too, since it also crosses closed', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: openPathCrosses, numVertices: 3 }) + expect(onStrokeChange).toHaveBeenCalledWith(true, expect.any(String)) + expect(onPlaceChange).toHaveBeenCalledWith(true, expect.any(String)) + }) + + test('a valid shape leaves both gates open, and flips them back once they were invalid', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: openPathCrosses, numVertices: 3 }) // both invalid first + checks.update({ feature: square, numVertices: 3 }) // valid again — should flip back + expect(onStrokeChange).toHaveBeenLastCalledWith(false, null) + expect(onPlaceChange).toHaveBeenLastCalledWith(false, null) + }) + + test('LIVE_RULES stays floored below the minimum vertex count — never flips at all', () => { + const { onStrokeChange, checks } = setup() + checks.update({ feature: closingEdgeCrosses, numVertices: 1 }) + expect(onStrokeChange).not.toHaveBeenCalled() + }) + + test('numVertices defaults to 0 when omitted from context', () => { + const { onStrokeChange, checks } = setup() + checks.update({ feature: closingEdgeCrosses }) + expect(onStrokeChange).not.toHaveBeenCalled() // treated as 0 — below the floor + }) + + test('HARD_RULES is never floored — it must protect placing an invalid first vertex', () => { + const { onPlaceChange, checks } = setup() + // A single point can never self-intersect, so this stays placeable — the + // point is that placement is EVALUATED (not skipped) even at numVertices 0, + // proven by the user-callback tests below where a custom veto DOES apply here. + checks.update({ feature: poly([[0, 0]]), numVertices: 0 }) + expect(onPlaceChange).not.toHaveBeenCalled() // stayed valid — no flip to report + }) +}) + +describe('user callback (throttled, single call drives both gates)', () => { + test('runs ONCE per frame with phase preview, not once per gate', () => { + const { checks } = setup() + const onGeometryChange = jest.fn(() => true) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + expect(onGeometryChange).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(onGeometryChange).toHaveBeenCalledTimes(1) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature: square, numVertices: 3, phase: 'preview' })) + }) + + test('a veto flips BOTH the stroke and placement gates from the one call', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + const onGeometryChange = () => ({ valid: false, reason: 'outside region' }) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + jest.runAllTimers() + expect(onStrokeChange).toHaveBeenLastCalledWith(true, 'outside region') + expect(onPlaceChange).toHaveBeenLastCalledWith(true, 'outside region') + }) + + test('runs even with zero committed vertices — a location-based rule is meaningful against the first candidate point', () => { + const { onPlaceChange, checks } = setup() + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + checks.update({ feature: poly([[0, 0]]), numVertices: 0, onGeometryChange }) + jest.runAllTimers() + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ numVertices: 0, phase: 'preview' })) + expect(onPlaceChange).toHaveBeenLastCalledWith(true, 'outside region') + }) + + test('a built-in HARD_RULES failure still applies even when the user callback passes', () => { + const { onPlaceChange, checks } = setup() + const onGeometryChange = jest.fn(() => true) + checks.update({ feature: openPathCrosses, numVertices: 3, onGeometryChange }) + jest.runAllTimers() + expect(onPlaceChange).toHaveBeenLastCalledWith(true, expect.any(String)) + }) + + test('runs once per frame using the latest geometry (trailing edge)', () => { + const { checks } = setup() + const onGeometryChange = jest.fn(() => true) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + checks.update({ feature: square, numVertices: 4, onGeometryChange }) + const latest = poly([[0, 0], [20, 0], [20, 20], [0, 20]]) + checks.update({ feature: latest, numVertices: 5, onGeometryChange }) + jest.runAllTimers() + expect(onGeometryChange).toHaveBeenCalledTimes(1) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature: latest, numVertices: 5 })) + }) +}) + +describe('flip guard', () => { + test('each gate fires only when its own state flips, not on every update', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: openPathCrosses, numVertices: 3 }) + checks.update({ feature: openPathCrosses, numVertices: 3 }) + checks.update({ feature: openPathCrosses, numVertices: 3 }) + expect(onStrokeChange).toHaveBeenCalledTimes(1) + expect(onPlaceChange).toHaveBeenCalledTimes(1) + }) + + test('reset() is a no-op for a gate that was never invalid', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: square, numVertices: 3 }) // valid — neither gate ever flips + checks.reset() + expect(onStrokeChange).not.toHaveBeenCalled() + expect(onPlaceChange).not.toHaveBeenCalled() + }) +}) + +describe('reset / destroy', () => { + test('reset() clears the cached custom verdict and drops a pending frame', () => { + const { onStrokeChange, checks } = setup() + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + jest.runAllTimers() + expect(onStrokeChange).toHaveBeenLastCalledWith(true, 'outside region') + + checks.reset() + onGeometryChange.mockClear() + checks.update({ feature: square, numVertices: 3 }) // fresh session, no callback this time + expect(onStrokeChange).toHaveBeenLastCalledWith(false, null) + }) + + test('destroy() cancels a pending user-rule frame', () => { + const { checks } = setup() + const onGeometryChange = jest.fn(() => true) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + checks.destroy() + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/validation/liveStroke.js b/plugins/draw/src/validation/liveStroke.js new file mode 100644 index 000000000..715d8822f --- /dev/null +++ b/plugins/draw/src/validation/liveStroke.js @@ -0,0 +1,85 @@ +import { validateDisplayedGeometry } from './validateGeometry.js' + +// Shared with liveDrawChecks.js — the same one-call-per-frame throttle for a +// user rule of unknown cost, used both for the invalid stroke and (there) the +// combined draw-mode live checks. +export const requestFrame = (cb) => + (typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : setTimeout(cb, 16)) +export const cancelFrame = (id) => + (typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame(id) : clearTimeout(id)) + +/** + * Engine-agnostic driver for a live validity signal while drawing/editing — + * the invalid stroke by default, or (via `validate`) any other continuously + * re-evaluated verdict such as the Add-point placement gate. + * + * On every rubber-band/drag move the caller passes the displayed geometry (placed + * vertices + cursor). The default rules run synchronously for immediate feedback; + * a user `onGeometryChange` callback — whose cost is unknown — is throttled to one + * call per animation frame using the latest geometry (trailing edge), so an + * arbitrary user rule can't jank the drag. The default-fail path short-circuits + * (no user call) and cancels any pending frame so a stale evaluation can't flip + * the state back. `onChange(invalid, reason)` fires only when the state flips. + * + * @param {object} params + * @param {(invalid: boolean, reason: string|null) => void} params.onChange + * @param {Function} [params.validate] - (feature, context, config) => { valid, reason }; + * defaults to validateDisplayedGeometry (the invalid-stroke rules) + * @returns {{ update: Function, set: Function, reset: Function, destroy: Function }} + */ +export const createLiveStroke = ({ onChange, validate = validateDisplayedGeometry }) => { + let invalid = false + let frame = null + let pending = null + + const cancelPending = () => { + if (frame != null) { cancelFrame(frame); frame = null } + pending = null + } + + const flip = (next, reason) => { + if (next !== invalid) { + invalid = next + onChange(next, reason ?? null) + } + } + + const runUserRule = () => { + frame = null + if (!pending) { return } + const { feature, context, onGeometryChange } = pending + const { valid, reason } = validate(feature, context, { onGeometryChange }) + flip(!valid, reason) + } + + return { + // Re-evaluate the displayed geometry after a rubber-band move. + update ({ feature, context = {}, numVertices, onGeometryChange }) { + const ctx = { ...context, numVertices } + // Default rules first, synchronously — immediate feedback on self-intersection / area. + const base = validate(feature, ctx) + if (!base.valid) { cancelPending(); flip(true, base.reason); return } + // Default rules pass — a user rule (if any) decides, throttled to one frame. + if (typeof onGeometryChange !== 'function') { cancelPending(); flip(false, null); return } + pending = { feature, context: ctx, onGeometryChange } + if (frame == null) { frame = requestFrame(runUserRule) } + }, + // Authoritative external write — a committed verdict (events.js) or a mode + // reset. Applies through the same flip guard so the cached state always + // mirrors what is rendered, and drops any pending user-rule frame so a stale + // live evaluation can't overwrite it a frame later. + set (next, reason) { + cancelPending() + flip(next, reason ?? null) + }, + // Re-assert the cached state through onChange unconditionally — for callers + // whose rendered output was reset behind the controller's back (e.g. a map + // style reload re-adding layers with their spec-default visibility). + refresh () { + onChange(invalid, null) + }, + destroy () { + cancelPending() + } + } +} diff --git a/plugins/draw/src/validation/liveStroke.test.js b/plugins/draw/src/validation/liveStroke.test.js new file mode 100644 index 000000000..bd6e2575d --- /dev/null +++ b/plugins/draw/src/validation/liveStroke.test.js @@ -0,0 +1,150 @@ +import { createLiveStroke } from './liveStroke.js' + +const poly = (ring) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } }) + +const square = poly([[0, 0], [10, 0], [10, 10], [0, 10]]) +const bowtie = poly([[0, 0], [10, 10], [10, 0], [0, 10]]) + +const setup = () => { + const onChange = jest.fn() + const stroke = createLiveStroke({ onChange }) + return { onChange, stroke } +} + +beforeEach(() => jest.useFakeTimers()) +afterEach(() => jest.useRealTimers()) + +describe('default rules (synchronous)', () => { + test('a failing default rule flips dashed immediately with the reason', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, numVertices: 3 }) + expect(onChange).toHaveBeenCalledWith(true, expect.stringMatching(/intersect/i)) + }) + + test('going valid again flips back solid', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, numVertices: 3 }) + stroke.update({ feature: square, numVertices: 3 }) + expect(onChange).toHaveBeenLastCalledWith(false, null) + }) + + test('onChange fires only when the state flips, not on every update', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, numVertices: 3 }) + stroke.update({ feature: bowtie, numVertices: 3 }) + stroke.update({ feature: bowtie, numVertices: 3 }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + test('below the minimum vertex count the shape is part-drawn — never dashed', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, numVertices: 2 }) + expect(onChange).not.toHaveBeenCalled() + }) + + test('a default-rule failure never invokes the user callback', () => { + const { stroke } = setup() + const onGeometryChange = jest.fn() + stroke.update({ feature: bowtie, numVertices: 3, onGeometryChange }) + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() + }) +}) + +describe('user callback (throttled)', () => { + test('runs once per frame with the latest geometry (trailing edge)', () => { + const { stroke } = setup() + const onGeometryChange = jest.fn(() => true) + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) + stroke.update({ feature: square, numVertices: 4, onGeometryChange }) + const latest = poly([[0, 0], [20, 0], [20, 20], [0, 20]]) + stroke.update({ feature: latest, numVertices: 5, onGeometryChange }) + expect(onGeometryChange).not.toHaveBeenCalled() // nothing synchronous + jest.runAllTimers() + expect(onGeometryChange).toHaveBeenCalledTimes(1) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature: latest, numVertices: 5 })) + }) + + test('a user-callback veto flips dashed with its reason', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: square, numVertices: 3, onGeometryChange: () => ({ valid: false, reason: 'outside region' }) }) + jest.runAllTimers() + expect(onChange).toHaveBeenCalledWith(true, 'outside region') + }) + + test('a synchronous default failure cancels a pending user-rule frame', () => { + const { onChange, stroke } = setup() + const onGeometryChange = jest.fn(() => true) + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) + stroke.update({ feature: bowtie, numVertices: 3, onGeometryChange }) // sync dashed + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() // stale frame dropped + expect(onChange).toHaveBeenLastCalledWith(true, expect.any(String)) + }) + + test('without a user callback a valid update settles solid immediately', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, numVertices: 3 }) + stroke.update({ feature: square, numVertices: 3 }) + expect(onChange).toHaveBeenLastCalledWith(false, null) + expect(jest.getTimerCount()).toBe(0) + }) +}) + +describe('custom validate function', () => { + test('drives both the synchronous default pass and the throttled user pass', () => { + const onChange = jest.fn() + const validate = jest.fn((feature, context, config) => + config?.onGeometryChange ? config.onGeometryChange(feature, context) : { valid: true }) + const stroke = createLiveStroke({ onChange, validate }) + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'vetoed' })) + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) + expect(validate).toHaveBeenCalledWith(square, expect.objectContaining({ numVertices: 3 })) + jest.runAllTimers() + expect(onChange).toHaveBeenCalledWith(true, 'vetoed') + }) +}) + +describe('set / reset / destroy', () => { + test('set() applies through the flip guard and drops any pending frame', () => { + const { onChange, stroke } = setup() + const onGeometryChange = jest.fn(() => true) + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) + stroke.set(true, 'committed invalid') + expect(onChange).toHaveBeenCalledWith(true, 'committed invalid') + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() // stale live frame dropped + onChange.mockClear() + stroke.set(true) // same state — guarded no-op + expect(onChange).not.toHaveBeenCalled() + }) + + test('set() keeps the cache in sync so the next live update flips correctly', () => { + const { onChange, stroke } = setup() + stroke.set(true) + onChange.mockClear() + stroke.update({ feature: square, numVertices: 3 }) // valid → back solid + expect(onChange).toHaveBeenCalledWith(false, null) + }) + + test('refresh() re-asserts the cached state unconditionally (style-reload resync)', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, numVertices: 3 }) + onChange.mockClear() + stroke.refresh() // rendered output was reset externally — re-apply dashed + expect(onChange).toHaveBeenCalledWith(true, null) + stroke.update({ feature: square, numVertices: 3 }) + onChange.mockClear() + stroke.refresh() + expect(onChange).toHaveBeenCalledWith(false, null) + }) + + test('destroy() cancels a pending user-rule frame', () => { + const { stroke } = setup() + const onGeometryChange = jest.fn(() => true) + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) + stroke.destroy() + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/validation/rules.areaError.test.js b/plugins/draw/src/validation/rules.areaError.test.js new file mode 100644 index 000000000..34bf203b3 --- /dev/null +++ b/plugins/draw/src/validation/rules.areaError.test.js @@ -0,0 +1,12 @@ +import { nonZeroArea } from './rules.js' + +// A separate file is required: this file-level @turf/area mock is hoisted above the +// import and applies to the whole file, so it can't live in rules.test.js without +// breaking the tests there that rely on the real area calculation. It covers the +// defensive catch in nonZeroArea (a turf failure is treated as "skip / valid"). +jest.mock('@turf/area', () => ({ __esModule: true, default: jest.fn(() => { throw new Error('turf boom') }) })) + +test('nonZeroArea treats a turf area failure as a skip (valid)', () => { + const feature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1]]] } } + expect(nonZeroArea(feature)).toEqual({ valid: true }) +}) diff --git a/plugins/draw/src/validation/rules.js b/plugins/draw/src/validation/rules.js new file mode 100644 index 000000000..cf80e9a41 --- /dev/null +++ b/plugins/draw/src/validation/rules.js @@ -0,0 +1,186 @@ +import turfArea from '@turf/area' + +/** + * Engine-agnostic geometry validation. + * + * A rule is a pure `(feature, context) => { valid, reason }`. Two classes: + * - SOFT_RULES gate the Done button (geometryValid) — the change is always + * kept, so a shape can pass through interim invalid states while being + * built or reshaped. + * - HARD_RULES gate vertex placement — a failure rejects the placement and + * the vertex never appears (used for unrecoverable states, e.g. a vertex + * that would force the drawn path to cross itself). + * + * `context` is `{ phase, vertexIndex, mode }` (phase: import('../adapterEvents.js').GeometryChangePhase) + * so rules can vary by phase or mode. + * Add a rule by appending it to SOFT_RULES or HARD_RULES. + */ + +// Minimum vertices for a finishable shape — the single source for every +// threshold check (rules, live gating, adapter finish conditions). +export const MIN_VERTICES = { Polygon: 3, LineString: 2 } +const MIN_INTERSECT_VERTICES = 4 // need 4+ vertices for two non-adjacent edges to exist + +const getGeometry = (feature) => feature?.geometry ?? feature +const getPolygon = (feature) => { + const geometry = getGeometry(feature) + return geometry?.type === 'Polygon' ? geometry : null +} + +// Distinct outer-ring vertices. Drops consecutive duplicates (zero-length edges — +// e.g. the rubber band sitting on the just-placed vertex) which would otherwise +// read as false intersections, then any explicit closing point so open +// (in-progress) and closed (finished) rings are handled the same way. +const getRingVertices = (geometry) => { + const raw = geometry.coordinates?.[0] ?? [] + const ring = raw.filter((v, i) => i === 0 || v[0] !== raw[i - 1][0] || v[1] !== raw[i - 1][1]) + while (ring.length > 1 && + ring[0][0] === ring[ring.length - 1][0] && + ring[0][1] === ring[ring.length - 1][1]) { + ring.pop() + } + return ring +} + +const closeRing = (vertices) => [...vertices, vertices[0]] + +// Signed area of the triangle (o, a, b); sign gives orientation, zero = collinear. +const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + +// True when collinear point q lies within the bounding box of segment p→r. +const onSegment = (p, q, r) => + Math.min(p[0], r[0]) <= q[0] && q[0] <= Math.max(p[0], r[0]) && + Math.min(p[1], r[1]) <= q[1] && q[1] <= Math.max(p[1], r[1]) + +// True when d1 and d2 lie on opposite sides (one strictly positive, one strictly negative). +const straddles = (d1, d2) => (d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0) + +// True when p is collinear with the segment (d === 0) and lies on it. +const collinearHit = (d, a, p, b) => d === 0 && onSegment(a, p, b) + +// True when segments (p1,p2) and (p3,p4) intersect (including collinear overlap). +const segmentsIntersect = (p1, p2, p3, p4) => { + const d1 = cross(p3, p4, p1) + const d2 = cross(p3, p4, p2) + const d3 = cross(p1, p2, p3) + const d4 = cross(p1, p2, p4) + + if (straddles(d1, d2) && straddles(d3, d4)) { return true } + + return collinearHit(d1, p3, p1, p4) || collinearHit(d2, p3, p2, p4) || + collinearHit(d3, p1, p3, p2) || collinearHit(d4, p1, p4, p2) +} + +// Do any two non-adjacent edges cross? When `closed`, the implicit closing edge +// (last vertex back to the first) is included; when open, only the drawn edges are +// tested (used to reject a self-crossing vertex placement while drawing). +const edgesSelfIntersect = (vertices, closed) => { + const n = vertices.length + const edgeCount = closed ? n : n - 1 + for (let i = 0; i < edgeCount; i++) { + for (let j = i + 1; j < edgeCount; j++) { + const adjacent = j === i + 1 || (closed && i === 0 && j === edgeCount - 1) + if (adjacent) { continue } + if (segmentsIntersect(vertices[i], vertices[(i + 1) % n], vertices[j], vertices[(j + 1) % n])) { + return true + } + } + } + return false +} + +/** + * HARD (draw placement): would the open drawn path cross itself? Run against the + * candidate path (placed vertices + the point about to be placed) — a failure + * rejects the placement so the vertex never appears and a genuine self-intersection + * can't be drawn forward. + */ +const pathSelfIntersects = (feature) => { + const geometry = getPolygon(feature) + if (!geometry) { return false } + const vertices = getRingVertices(geometry) + if (vertices.length < MIN_INTERSECT_VERTICES) { return false } + return edgesSelfIntersect(vertices, false) +} + +/** Rule-shaped wrapper for pathSelfIntersects (see HARD_RULES). */ +export const noPathSelfIntersection = (feature) => + pathSelfIntersects(feature) + ? { valid: false, reason: 'Shape must not intersect itself' } + : { valid: true } + +/** + * A polygon must not self-intersect. Gates Done while drawing (a would-be-crossing + * closing edge disables Done) and while editing. + */ +export const noSelfIntersection = (feature) => { + const geometry = getPolygon(feature) + if (!geometry) { return { valid: true } } + const vertices = getRingVertices(geometry) + if (vertices.length < MIN_INTERSECT_VERTICES) { return { valid: true } } + return edgesSelfIntersect(vertices, true) + ? { valid: false, reason: 'Shape must not intersect itself' } + : { valid: true } +} + +/** + * A polygon must enclose a non-zero area (rejects collinear / degenerate rings). + */ +export const nonZeroArea = (feature) => { + const geometry = getPolygon(feature) + if (!geometry) { return { valid: true } } + + const vertices = getRingVertices(geometry) + if (vertices.length < MIN_VERTICES.Polygon) { return { valid: true } } + + let area = 0 + try { + area = turfArea({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [closeRing(vertices)] }, properties: {} }) + } catch { + return { valid: true } + } + + return area > 0 ? { valid: true } : { valid: false, reason: 'Shape must enclose an area' } +} + +// minVertices' own reason strings, exported so callers (events.js's hint toast) +// can recognise "still being built" as distinct from a genuine rule violation — +// unlike self-intersection/zero-area/a custom onGeometryChange veto, an +// incomplete shape isn't a mistake worth interrupting the user over. +export const MIN_VERTICES_REASONS = { + Polygon: 'Shape needs at least 3 points', + LineString: 'Line needs at least 2 points' +} + +/** + * A shape needs enough vertices to be finishable (3 for a polygon, 2 for a line). + */ +export const minVertices = (feature) => { + const geometry = getGeometry(feature) + if (geometry?.type === 'Polygon') { + return getRingVertices(geometry).length >= MIN_VERTICES.Polygon + ? { valid: true } + : { valid: false, reason: MIN_VERTICES_REASONS.Polygon } + } + if (geometry?.type === 'LineString') { + return (geometry.coordinates?.length ?? 0) >= MIN_VERTICES.LineString + ? { valid: true } + : { valid: false, reason: MIN_VERTICES_REASONS.LineString } + } + return { valid: true } +} + +// Validation rules. A failure disables the Done button (geometryValid). +// Order sets which reason surfaces first. +export const SOFT_RULES = [noSelfIntersection, nonZeroArea, minVertices] + +// Rules that drive the live invalid stroke while drawing, run against the displayed +// geometry (placed vertices + cursor) on every rubber-band move. Everything soft +// EXCEPT minVertices — an incomplete shape is "part-drawn", not invalid. +// validateDisplayedGeometry applies the minimum-placed-vertex threshold instead. +export const LIVE_RULES = [noSelfIntersection, nonZeroArea] + +// Placement rules. Run by validatePlacement against the candidate geometry +// (placed vertices + the point about to be placed); a failure rejects the +// placement outright — the vertex never appears. +export const HARD_RULES = [noPathSelfIntersection] diff --git a/plugins/draw/src/validation/rules.test.js b/plugins/draw/src/validation/rules.test.js new file mode 100644 index 000000000..15e2f2a46 --- /dev/null +++ b/plugins/draw/src/validation/rules.test.js @@ -0,0 +1,120 @@ +import { noSelfIntersection, nonZeroArea, minVertices, noPathSelfIntersection, SOFT_RULES, HARD_RULES, MIN_VERTICES_REASONS } from './rules.js' + +const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } }) +const line = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } }) + +describe('noSelfIntersection', () => { + test('rejects a closed self-intersecting polygon', () => { + expect(noSelfIntersection(poly([[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]])).valid).toBe(false) + }) + + test('rejects a shape whose closing edge crosses another edge', () => { + expect(noSelfIntersection(poly([[0, 0], [2, 0], [0, 2], [2, 2]])).valid).toBe(false) + }) + + test('detects a T-touch (collinear) crossing', () => { + // Edge (1,0)-(1,2) starts on the earlier edge (0,0)-(3,0): a collinear/touch hit. + expect(noSelfIntersection(poly([[0, 0], [3, 0], [1, 0], [1, 2]])).valid).toBe(false) + }) + + test('accepts a simple polygon', () => { + expect(noSelfIntersection(poly([[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]))).toEqual({ valid: true }) + }) + + test('skips non-polygons and short rings', () => { + expect(noSelfIntersection(line([[0, 0], [1, 1]]))).toEqual({ valid: true }) + expect(noSelfIntersection(poly([[0, 0], [1, 1], [1, 0]]))).toEqual({ valid: true }) + }) +}) + +describe('nonZeroArea (soft)', () => { + test('rejects a collinear (zero-area) ring', () => { + const result = nonZeroArea(poly([[0, 0], [1, 0], [2, 0]])) + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/area/i) + }) + + test('accepts a ring with area', () => { + expect(nonZeroArea(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true }) + }) + + test('skips non-polygons and rings under three vertices', () => { + expect(nonZeroArea(line([[0, 0], [1, 1]]))).toEqual({ valid: true }) + expect(nonZeroArea(poly([[0, 0], [1, 0]]))).toEqual({ valid: true }) + }) + // The defensive catch (a turf failure → skip) is covered in rules.areaError.test.js, + // which needs a file-level @turf/area mock that must not affect the real-area tests here. +}) + +describe('minVertices (soft)', () => { + test('requires three points for a polygon', () => { + expect(minVertices(poly([[0, 0], [1, 0]])).valid).toBe(false) + expect(minVertices(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true }) + }) + + // events.js's hint toast recognises MIN_VERTICES_REASONS to skip hinting on an + // incomplete (still-being-drawn) shape — these must stay the actual reasons used. + test('uses the exported MIN_VERTICES_REASONS text', () => { + expect(minVertices(poly([[0, 0], [1, 0]])).reason).toBe(MIN_VERTICES_REASONS.Polygon) + expect(minVertices(line([[0, 0]])).reason).toBe(MIN_VERTICES_REASONS.LineString) + }) + + test('counts a closed ring by its distinct vertices', () => { + expect(minVertices(poly([[0, 0], [1, 0], [1, 1], [0, 0]]))).toEqual({ valid: true }) + }) + + test('requires two points for a line', () => { + expect(minVertices(line([[0, 0]])).valid).toBe(false) + expect(minVertices(line([[0, 0], [1, 1]]))).toEqual({ valid: true }) + }) + + test('skips geometry that is neither a polygon nor a line', () => { + expect(minVertices({ geometry: { type: 'Point', coordinates: [0, 0] } })).toEqual({ valid: true }) + }) +}) + +describe('noPathSelfIntersection (hard, draw placement)', () => { + test('rejects a self-crossing open drawn path with a reason', () => { + expect(noPathSelfIntersection(poly([[0, 0], [2, 2], [2, 0], [0, 2]]))) + .toEqual({ valid: false, reason: expect.stringMatching(/intersect/i) }) + }) + + test('accepts an open path that only closes into a crossing', () => { + // A bow-tie only crosses via its closing edge, which the open-path check ignores. + expect(noPathSelfIntersection(poly([[0, 0], [2, 0], [0, 2], [2, 2]]))).toEqual({ valid: true }) + }) + + test('accepts a simple open path', () => { + expect(noPathSelfIntersection(poly([[0, 0], [2, 0], [2, 2], [0, 2]]))).toEqual({ valid: true }) + }) + + test('skips non-polygons and paths under four vertices', () => { + expect(noPathSelfIntersection(line([[0, 0], [1, 1]]))).toEqual({ valid: true }) + expect(noPathSelfIntersection(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true }) + }) +}) + +describe('consecutive duplicate vertices (zero-length edges)', () => { + test('a rubber band sitting on the just-placed vertex is not an intersection', () => { + // 3 placed + duplicate rubber-band coord + closing point — the in-progress + // ring layout the instant after a vertex is placed. + expect(noSelfIntersection(poly([[0, 0], [10, 0], [10, 10], [10, 10], [0, 0]]))) + .toEqual({ valid: true }) + }) + + test('a real crossing is still detected when a duplicate vertex is present', () => { + expect(noSelfIntersection(poly([[0, 0], [1, 1], [1, 1], [1, 0], [0, 1], [0, 0]])).valid).toBe(false) + }) +}) + +describe('SOFT_RULES', () => { + test('are the gating rules in reason-priority order', () => { + expect(SOFT_RULES).toEqual([noSelfIntersection, nonZeroArea, minVertices]) + }) +}) + +describe('HARD_RULES', () => { + test('are the placement-veto rules', () => { + expect(HARD_RULES).toEqual([noPathSelfIntersection]) + }) +}) diff --git a/plugins/draw/src/validation/validateGeometry.js b/plugins/draw/src/validation/validateGeometry.js new file mode 100644 index 000000000..a697bae46 --- /dev/null +++ b/plugins/draw/src/validation/validateGeometry.js @@ -0,0 +1,119 @@ +import { SOFT_RULES, HARD_RULES, LIVE_RULES, MIN_VERTICES } from './rules.js' + +/** + * Normalise a rule / callback result into `{ valid, reason }`. + * + * Supports three return shapes so a user `onGeometryChange` callback can stay + * terse: + * - `true` / `undefined` → valid + * - `false` → invalid (no reason) + * - `{ valid, reason }` → passed through + */ +const normaliseResult = (result) => { + if (result === undefined || result === true) { return { valid: true } } + if (result === false) { return { valid: false, reason: null } } + return { valid: result.valid !== false, reason: result.reason } +} + +/** + * Validate an in-progress geometry against the default rules and an optional user + * callback. Rules run first and short-circuit on the first failure; the callback + * runs last. Gates the Done button only — never reverts a vertex change. + * + * @param {object} feature - current GeoJSON feature + * @param {object} context - { phase: import('../adapterEvents.js').GeometryChangePhase, vertexIndex, mode } + * @param {object} [config] + * @param {Array} [config.rules] - defaults to DEFAULT_RULES; called as rule(feature, context) + * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [config.onGeometryChange] + * - user callback, called as onGeometryChange({ feature, ...context }) — a single + * event object, unlike the internal rules above, matching the shape of the + * PLACEMENT_BLOCKED adapter event + * @returns {{ valid: boolean, reason?: string }} + */ +export const validateGeometry = (feature, context = {}, config = {}) => { + const { rules = SOFT_RULES, onGeometryChange } = config + + for (const rule of rules) { + const result = normaliseResult(rule(feature, context)) + if (!result.valid) { return result } + } + + if (typeof onGeometryChange === 'function') { + return normaliseResult(onGeometryChange({ feature, ...context })) + } + + return { valid: true } +} + +/** + * Validate a candidate vertex placement against the hard rules and the same + * optional user callback. A failure means the vertex is rejected outright and + * never appears. The callback receives `context.phase === 'place'`. + * + * @param {object} feature - candidate GeoJSON feature (placed vertices + new point) + * @param {object} context - { vertexIndex, mode }; phase is forced to 'place' + * @param {object} [config] + * @param {Array} [config.rules] - defaults to HARD_RULES + * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [config.onGeometryChange] - user callback + * @returns {{ valid: boolean, reason?: string }} + */ +export const validatePlacement = (feature, context = {}, config = {}) => { + const { rules = HARD_RULES, onGeometryChange } = config + return validateGeometry(feature, { ...context, phase: 'place' }, { rules, onGeometryChange }) +} + +export const MODE_BY_GEOMETRY = { Polygon: 'draw_polygon', LineString: 'draw_line' } + +/** + * Attempt to place a vertex — shared by both adapters. Builds the candidate + * geometry, validates it, and on a veto returns the PLACEMENT_BLOCKED payload + * for the caller to emit. + * + * @param {object} params + * @param {Array>} params.placed - committed vertex coordinates + * @param {Array} params.point - the coordinate about to be placed + * @param {'Polygon'|'LineString'} params.geometryType + * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [params.onGeometryChange] - user callback + * @returns {{ valid: true } | { valid: false, blocked: object }} + */ +export const attemptPlacement = ({ placed, point, geometryType, onGeometryChange }) => { + const candidate = [...placed, point] + const geometry = geometryType === 'Polygon' + ? { type: 'Polygon', coordinates: [candidate] } + : { type: 'LineString', coordinates: candidate } + const feature = { type: 'Feature', geometry, properties: {} } + const mode = MODE_BY_GEOMETRY[geometryType] + const { valid, reason } = validatePlacement(feature, { mode, vertexIndex: placed.length }, { onGeometryChange }) + if (valid) { return { valid: true } } + return { valid: false, blocked: { feature, reason: reason ?? null, phase: 'place', mode, vertexIndex: placed.length } } +} + +/** + * Validate the displayed (in-progress) geometry that drives the live invalid + * stroke: the placed vertices plus the current cursor point. The built-in live + * rules (self-intersection/area) are meaningless below the geometry type's + * minimum vertex count — there isn't a real ring/line yet — so they're skipped + * entirely until then; a caller's own `onGeometryChange` has no such floor + * (numVertices defaults to 0 via `context.numVertices ?? 0`, so it still runs). + * + * Used directly by edit mode's live-stroke check (there's no Add-point gate to + * combine with there, so it's a self-contained call). Draw mode does NOT use + * this — see liveDrawChecks.js, which combines this same LIVE_RULES set with a + * separate HARD_RULES-based placement check, both driven from a single shared + * `onGeometryChange` call rather than one call each. + * + * @param {object} feature - displayed GeoJSON feature (placed vertices + cursor) + * @param {object} context - { mode, numVertices, phase }; phase defaults to 'preview' + * @param {object} [config] + * @param {Array} [config.rules] - defaults to LIVE_RULES + * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [config.onGeometryChange] - user callback + * @returns {{ valid: boolean, reason?: string }} + */ +export const validateDisplayedGeometry = (feature, context = {}, config = {}) => { + const { rules = LIVE_RULES, onGeometryChange } = config + const type = feature?.geometry?.type ?? feature?.type + const min = MIN_VERTICES[type] ?? 0 + const numVertices = context.numVertices ?? 0 + const effectiveRules = numVertices < min ? [] : rules + return validateGeometry(feature, { ...context, phase: context.phase ?? 'preview' }, { rules: effectiveRules, onGeometryChange }) +} diff --git a/plugins/draw/src/validation/validateGeometry.test.js b/plugins/draw/src/validation/validateGeometry.test.js new file mode 100644 index 000000000..657b68576 --- /dev/null +++ b/plugins/draw/src/validation/validateGeometry.test.js @@ -0,0 +1,200 @@ +import { validateGeometry, validatePlacement, attemptPlacement, validateDisplayedGeometry } from './validateGeometry.js' + +const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } }) + +const square = poly([[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]) +const bowtie = poly([[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]) +const collinear = poly([[0, 0], [1, 0], [2, 0], [0, 0]]) +const twoPoints = poly([[0, 0], [1, 0]]) + +describe('validateGeometry (soft gating)', () => { + test('a valid polygon passes', () => { + expect(validateGeometry(square)).toEqual({ valid: true }) + }) + + test('a self-intersecting polygon fails', () => { + const result = validateGeometry(bowtie) + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/intersect/i) + }) + + test('a zero-area polygon fails', () => { + expect(validateGeometry(collinear).reason).toMatch(/area/i) + }) + + test('too few vertices fails', () => { + expect(validateGeometry(twoPoints).reason).toMatch(/points/i) + }) + + test('short-circuits on the first failing rule', () => { + const first = jest.fn(() => ({ valid: false, reason: 'first' })) + const second = jest.fn(() => ({ valid: true })) + expect(validateGeometry(square, {}, { rules: [first, second] })).toEqual({ valid: false, reason: 'first' }) + expect(second).not.toHaveBeenCalled() + }) + + test('passes the context to rules as (feature, context), and to the callback as a single flattened event object', () => { + const rule = jest.fn(() => ({ valid: true })) + const onGeometryChange = jest.fn(() => ({ valid: true })) + const context = { phase: 'commit-move', vertexIndex: 2, mode: 'edit_vertex' } + validateGeometry(square, context, { rules: [rule], onGeometryChange }) + expect(rule).toHaveBeenCalledWith(square, context) + expect(onGeometryChange).toHaveBeenCalledWith({ feature: square, ...context }) + }) + + test('runs the user callback after the rules pass', () => { + expect(validateGeometry(square, {}, { rules: [], onGeometryChange: () => false })) + .toEqual({ valid: false, reason: null }) + expect(validateGeometry(square, {}, { rules: [], onGeometryChange: () => true })) + .toEqual({ valid: true }) + expect(validateGeometry(square, {}, { rules: [], onGeometryChange: () => ({ valid: false, reason: 'too big' }) })) + .toEqual({ valid: false, reason: 'too big' }) + }) + + test('does not run the callback when a rule fails', () => { + const onGeometryChange = jest.fn() + validateGeometry(square, {}, { rules: [() => ({ valid: false })], onGeometryChange }) + expect(onGeometryChange).not.toHaveBeenCalled() + }) + + test('is valid with no rules and no callback', () => { + expect(validateGeometry(square, {}, { rules: [] })).toEqual({ valid: true }) + }) +}) + +describe('attemptPlacement (shared engine gate)', () => { + const placedL = [[0, 0], [1, 1], [1, 0]] // adding (0,1) makes the open path cross + + test('a legal placement passes with no payload', () => { + expect(attemptPlacement({ placed: [[0, 0], [1, 0], [1, 1]], point: [0, 1], geometryType: 'Polygon' })) + .toEqual({ valid: true }) + }) + + test('a self-crossing placement is vetoed with the PLACEMENT_BLOCKED payload', () => { + const result = attemptPlacement({ placed: placedL, point: [0, 1], geometryType: 'Polygon' }) + expect(result.valid).toBe(false) + expect(result.blocked).toEqual({ + feature: expect.objectContaining({ type: 'Feature' }), + reason: expect.stringMatching(/intersect/i), + phase: 'place', + mode: 'draw_polygon', + vertexIndex: 3 + }) + }) + + test('the user callback can veto, with mode derived from the geometry type', () => { + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const result = attemptPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange }) + expect(result.blocked).toEqual(expect.objectContaining({ mode: 'draw_line', reason: 'outside region', vertexIndex: 1 })) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ phase: 'place', mode: 'draw_line', vertexIndex: 1 })) + }) + + test('a veto with no reason falls back to null in the PLACEMENT_BLOCKED payload', () => { + const onGeometryChange = jest.fn(() => false) // false → invalid, no reason (see normaliseResult) + const result = attemptPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange }) + expect(result.blocked.reason).toBeNull() + }) + + test('attemptPlacement mode is set correctly for Polygon vs LineString', () => { + // Both legal and illegal placements should have the correct mode set + const polygonLegal = attemptPlacement({ placed: [[0, 0], [1, 0]], point: [1, 1], geometryType: 'Polygon' }) + expect(polygonLegal).toEqual({ valid: true }) + // Test a Polygon placement that would cross + const polygonCrossing = attemptPlacement({ placed: [[0, 0], [2, 2], [2, 0]], point: [0, 2], geometryType: 'Polygon' }) + expect(polygonCrossing.blocked?.mode).toBe('draw_polygon') + }) +}) + +describe('validateDisplayedGeometry edge cases', () => { + test('handles unknown geometry types with fallback min vertices', () => { + const result = validateDisplayedGeometry({ type: 'Feature', geometry: { type: 'Unknown', coordinates: [] } }, { numVertices: 0 }) + expect(result.valid).toBe(true) // unknown types use MIN_VERTICES fallback of 0, so 0 placed = valid + }) + + test('feature without geometry type defaults to context.phase', () => { + const result = validateDisplayedGeometry({ type: 'Feature', geometry: { coordinates: [[0, 0]] } }, { numVertices: 2, phase: 'custom' }) + expect(typeof result).toBe('object') + expect(result).toHaveProperty('valid') + }) + + test('context without numVertices defaults to 0 for min-vertex check', () => { + const result = validateDisplayedGeometry(poly([[0, 0]]), {}) + expect(result.valid).toBe(true) // no numVertices = 0, below any min, so valid + }) + + test('context and config are both optional — called with just a feature', () => { + expect(validateDisplayedGeometry(poly([[0, 0]]))).toEqual({ valid: true }) + }) + + test('calls the caller\'s own onGeometryChange once at/above the vertex threshold, with a single flattened event object', () => { + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'too few for my rule' })) + // Polygon minimum is 3 placed vertices — this feature/numVertices pair is at it. + const feature = poly([[0, 0], [1, 0], [1, 1]]) + + const result = validateDisplayedGeometry(feature, { numVertices: 3 }, { onGeometryChange }) + + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature, numVertices: 3, phase: 'preview' })) + expect(result).toEqual({ valid: false, reason: 'too few for my rule' }) + }) + + test('skips the built-in rules with zero committed vertices, but still runs the caller\'s onGeometryChange — a location-based rule is meaningful against the very first candidate point', () => { + const failingRule = jest.fn(() => ({ valid: false, reason: 'should not run' })) + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const feature = poly([[0, 0]]) + + const result = validateDisplayedGeometry(feature, { numVertices: 0 }, { rules: [failingRule], onGeometryChange }) + + expect(failingRule).not.toHaveBeenCalled() + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature, numVertices: 0, phase: 'preview' })) + expect(result).toEqual({ valid: false, reason: 'outside region' }) + }) + + test('runs the caller\'s onGeometryChange from the first committed vertex, even while the built-in rules are still skipped', () => { + const failingRule = jest.fn(() => ({ valid: false, reason: 'should not run' })) + // A single committed vertex — well below the Polygon minimum of 3 — so the + // built-in self-intersection/area rules stay skipped, but a per-point user + // rule (e.g. "is this inside a region?") is meaningful and should still run. + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const feature = poly([[0, 0], [1, 1]]) + + const result = validateDisplayedGeometry(feature, { numVertices: 1 }, { rules: [failingRule], onGeometryChange }) + + expect(failingRule).not.toHaveBeenCalled() + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature, numVertices: 1, phase: 'preview' })) + expect(result).toEqual({ valid: false, reason: 'outside region' }) + }) +}) + +describe('validatePlacement (hard gating)', () => { + // The candidate is the open drawn path plus the point about to be placed. + const crossingPath = poly([[0, 0], [1, 1], [1, 0], [0, 1]]) + const simplePath = poly([[0, 0], [1, 0], [1, 1], [0, 1]]) + + test('rejects a candidate whose open path self-crosses, with a reason', () => { + const result = validatePlacement(crossingPath) + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/intersect/i) + }) + + test('passes a simple candidate', () => { + expect(validatePlacement(simplePath)).toEqual({ valid: true }) + }) + + test('forces phase "place" into the rule and callback context', () => { + const onGeometryChange = jest.fn(() => true) + validatePlacement(simplePath, { mode: 'draw_polygon', vertexIndex: 4 }, { onGeometryChange }) + expect(onGeometryChange).toHaveBeenCalledWith({ feature: simplePath, phase: 'place', mode: 'draw_polygon', vertexIndex: 4 }) + }) + + test('the user callback can veto a placement with a reason', () => { + const onGeometryChange = () => ({ valid: false, reason: 'outside region' }) + expect(validatePlacement(simplePath, {}, { onGeometryChange })) + .toEqual({ valid: false, reason: 'outside region' }) + }) + + test('hard rules run before the user callback', () => { + const onGeometryChange = jest.fn() + validatePlacement(crossingPath, {}, { onGeometryChange }) + expect(onGeometryChange).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js index 7a0718a14..34a6897df 100755 --- a/plugins/interact/src/hooks/useInteractionHandlers.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.js @@ -48,7 +48,7 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers) } eventBus.emit('interact:selectionchange', { - selectedFeatures: selectedFeatures.map(({ featureId, layerId, idProperty, properties }) => ({ featureId, layerId, idProperty, properties })), + selectedFeatures: selectedFeatures.map(({ featureId, layerId, idProperty, properties, geometry }) => ({ featureId, layerId, idProperty, properties, geometryType: geometry?.type })), selectedMarkers, contiguous: areAllContiguous(selectedFeatures) }) diff --git a/plugins/interact/src/hooks/useInteractionHandlers.test.js b/plugins/interact/src/hooks/useInteractionHandlers.test.js index 01e8d8de1..f583ec9f6 100644 --- a/plugins/interact/src/hooks/useInteractionHandlers.test.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.test.js @@ -360,7 +360,7 @@ it('emits selectionchange when features are selected', () => { expect(deps.services.eventBus.emit).toHaveBeenCalledWith( 'interact:selectionchange', expect.objectContaining({ - selectedFeatures: [{ featureId: 'F1', layerId: 'l1', idProperty: 'id', properties: { name: 'A' } }], + selectedFeatures: [{ featureId: 'F1', layerId: 'l1', idProperty: 'id', properties: { name: 'A' }, geometryType: 'Point' }], selectedMarkers: [], contiguous: false }) diff --git a/plugins/interact/src/utils/buildStylesMap.js b/plugins/interact/src/utils/buildStylesMap.js index 720ee2bfe..0f2bd8bfc 100755 --- a/plugins/interact/src/utils/buildStylesMap.js +++ b/plugins/interact/src/utils/buildStylesMap.js @@ -21,6 +21,7 @@ export const buildStylesMap = (dataLayers, mapStyle) => { const stylesMap = {} if (!mapStyle) { + console.warn('[interact] buildStylesMap: mapStyle is null/undefined, cannot build styles') return stylesMap } diff --git a/rollup.esm.mjs b/rollup.esm.mjs index 10505f1a4..5359688cb 100644 --- a/rollup.esm.mjs +++ b/rollup.esm.mjs @@ -1,6 +1,6 @@ -import path, { dirname } from 'path' -import { fileURLToPath } from 'url' -import fs from 'fs' +import path, { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import fs from 'node:fs' import { nodeResolve } from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' @@ -11,7 +11,7 @@ import terser from '@rollup/plugin-terser' import postcss from 'rollup-plugin-postcss' import { visualizer } from 'rollup-plugin-visualizer' -const __dirname = dirname(fileURLToPath(import.meta.url)) +const __dirname = dirname(fileURLToPath(import.meta.url)) // NOSONAR - Standard Node.js convention in bundler configs /** * Cleans output directories before each build starts. @@ -69,7 +69,7 @@ const removeFullCssPlugin = (cssDir) => ({ // rewritten by the alias plugin before Rollup's external check fires. const PREACT_EXTERNALS = [ 'preact', - 'preact/compat', + 'preact/compat', // NOSONAR 'preact/compat/client', 'preact/hooks', 'preact/jsx-runtime' @@ -177,13 +177,12 @@ const createESMConfig = (entryPath, outDir, isCore = false, manualChunks = null, ...(isCore ? [removeFullCssPlugin(cssDir)] : []), // Only runs when ANALYZE=1 is set; writes stats to dist/stats/.html - ...(process.env.ANALYZE -? [visualizer({ - filename: path.resolve(__dirname, 'dist/stats', `${outDir.replace(/\//g, '-')}.html`), + ...(process.env.ANALYZE ? [visualizer({ + filename: path.resolve(__dirname, 'dist/stats', `${outDir.replace(/\//g, '-')}.html`), // NOSONAR replaceAll() requires Chrome 84 or later open: false, gzipSize: true })] -: []) + : []) ], output: { @@ -219,83 +218,91 @@ const ALL_BUILDS = [ entryPath: './providers/maplibre/src/index.js', outDir: 'providers/maplibre/dist/esm', // maplibre-gl is external; only the provider class itself becomes a chunk - manualChunks: (id) => { if (id.includes('/maplibreProvider')) return 'im-maplibre-provider' } + manualChunks: (id) => id.includes('/maplibreProvider') ? 'im-maplibre-provider' : undefined }, { entryPath: './providers/beta/open-names/src/index.js', outDir: 'providers/beta/open-names/dist/esm', - manualChunks: (id) => { if (id.includes('/reverseGeocode')) return 'im-reverse-geocode' } + manualChunks: (id) => id.includes('/reverseGeocode') ? 'im-reverse-geocode' : undefined }, { entryPath: './providers/beta/esri/src/index.js', outDir: 'providers/beta/esri/dist/esm', - manualChunks: (id) => { if (id.includes('/esriProvider')) return 'im-esri-provider' } + manualChunks: (id) => id.includes('/esriProvider') ? 'im-esri-provider' : undefined }, { entryPath: './providers/beta/openlayers/src/index.js', outDir: 'providers/beta/openlayers/dist/esm', extraExternals: [/^ol\//, 'proj4'], - manualChunks: (id) => { - if (id.includes('/openlayersProvider')) { - return 'im-openlayers-provider' - } - } + manualChunks: (id) => id.includes('/openlayersProvider') ? 'im-openlayers-provider' : undefined }, // Plugins — each lazy-loads ./manifest.js; manualChunks names that split chunk { entryPath: './plugins/beta/scale-bar/src/index.js', outDir: 'plugins/beta/scale-bar/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-scale-bar-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-scale-bar-plugin' : undefined }, { entryPath: './plugins/beta/use-location/src/index.js', outDir: 'plugins/beta/use-location/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-use-location-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-use-location-plugin' : undefined }, { entryPath: './plugins/search/src/index.js', outDir: 'plugins/search/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-search-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-search-plugin' : undefined }, { entryPath: './plugins/interact/src/index.js', outDir: 'plugins/interact/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-interact-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-interact-plugin' : undefined }, { entryPath: './plugins/datasets/src/index.js', outDir: 'plugins/datasets/dist/esm', manualChunks: (id) => { - if (id.includes('/manifest')) return 'im-datasets-plugin' - if (id.includes('maplibreLayerAdapter')) return 'im-datasets-ml-adapter' + if (id.includes('/manifest')) { return 'im-datasets-plugin' } + if (id.includes('maplibreLayerAdapter')) { return 'im-datasets-ml-adapter' } + return undefined } }, { entryPath: './plugins/beta/map-styles/src/index.js', outDir: 'plugins/beta/map-styles/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-map-styles-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-map-styles-plugin' : undefined + }, + { + entryPath: './plugins/draw/src/index.js', + outDir: 'plugins/draw/dist/esm', + extraExternals: [/^ol\//], + manualChunks: (id) => { + if (id.includes('/manifest')) { return 'im-draw-plugin' } + if (id.includes('MaplibreDrawAdapter')) { return 'im-draw-ml-adapter' } + if (id.includes('OLDrawAdapter')) { return 'im-draw-ol-adapter' } + return undefined + } }, { entryPath: './plugins/beta/draw-ml/src/index.js', outDir: 'plugins/beta/draw-ml/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-ml-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-draw-ml-plugin' : undefined }, { entryPath: './plugins/beta/draw-es/src/index.js', outDir: 'plugins/beta/draw-es/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-es-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-draw-es-plugin' : undefined }, { entryPath: './plugins/beta/draw-ol/src/index.js', outDir: 'plugins/beta/draw-ol/dist/esm', extraExternals: [/^ol\//], - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-ol-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-draw-ol-plugin' : undefined }, { entryPath: './plugins/beta/frame/src/index.js', outDir: 'plugins/beta/frame/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-frame-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-frame-plugin' : undefined } ] diff --git a/sonar-project.properties b/sonar-project.properties index 2d6422805..89c3bd43b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,10 +11,10 @@ sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.sourceEncoding=UTF-8 sonar.sources=src,plugins,providers -sonar.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**,**/dist/**,**/node_modules/**,plugins/beta/**,providers/beta/** +sonar.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**,**/__helpers__/**,**/dist/**,**/node_modules/**,plugins/beta/**,providers/beta/** sonar.tests=src,plugins,providers sonar.test.inclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/** -sonar.cpd.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/** +sonar.cpd.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**,**/__helpers__/** # Ignored rules sonar.issue.ignore.multicriteria=reactPropsJs,reactPropsJsx,preferGlobalThisJs,preferGlobalThisJsx,preferAtJs,preferAtJsx,replaceAllJs,replaceAllJsx,stringReplaceAllJs,stringReplaceAllJsx,pascalCaseFunctionsJs,pascalCaseFunctionsJsx,pascalCaseFunctionsTs,pascalCaseFunctionsTsx,cssScopingRootMixins diff --git a/src/App/components/KeyboardHelp/KeyboardHelp.jsx b/src/App/components/KeyboardHelp/KeyboardHelp.jsx index e35c927a4..00091a405 100755 --- a/src/App/components/KeyboardHelp/KeyboardHelp.jsx +++ b/src/App/components/KeyboardHelp/KeyboardHelp.jsx @@ -46,7 +46,7 @@ export const KeyboardHelp = ({ context = 'viewport' }) => { // NOSONAR: project const allShortcuts = getKeyboardShortcuts(appConfig) const shortcuts = listboxIsActive ? allShortcuts - : allShortcuts.filter(s => !s.group && s.context !== 'listbox') + : allShortcuts.filter(s => s.context !== 'listbox') const groupMap = buildGroupMap(shortcuts) const groupEntries = Object.entries(groupMap) diff --git a/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx b/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx index cb1b73ccd..81a829e45 100755 --- a/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +++ b/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx @@ -163,6 +163,17 @@ describe('KeyboardHelp — listboxIsActive filtering', () => { expect(screen.getByText('Move')).toBeInTheDocument() }) + it('shows non-listbox grouped shortcuts (e.g. Drawing) when listboxIsActive is false', () => { + getKeyboardShortcuts.mockReturnValue([ + ...VIEWPORT_SHORTCUTS, + { id: 'd1', group: 'Drawing', title: 'Add new point', command: 'Enter' }, + ...LISTBOX_SHORTCUTS + ]) + render() + expect(screen.getByRole('tab', { name: 'Drawing' })).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: SELECT_FEATURES_GROUP })).not.toBeInTheDocument() + }) + it('renders as a flat list when only ungrouped shortcuts remain after filtering', () => { getKeyboardShortcuts.mockReturnValue([...VIEWPORT_SHORTCUTS, ...LISTBOX_SHORTCUTS]) render() diff --git a/src/App/components/MoveControl/MoveControl.jsx b/src/App/components/MoveControl/MoveControl.jsx index d27904c8d..e10308794 100644 --- a/src/App/components/MoveControl/MoveControl.jsx +++ b/src/App/components/MoveControl/MoveControl.jsx @@ -7,10 +7,10 @@ import { useService } from '../../store/serviceContext.js' import { resolveStepAmount } from '../../../utils/resolveNudgeStep.js' const DIRECTIONS = [ - { id: 'panUp', verb: 'up', dx: 0, dy: -1 }, - { id: 'panDown', verb: 'down', dx: 0, dy: 1 }, - { id: 'panLeft', verb: 'left', dx: -1, dy: 0 }, - { id: 'panRight', verb: 'right', dx: 1, dy: 0 } + { id: 'panUp', verb: 'up', dx: 0, dy: -1, key: 'ArrowUp' }, + { id: 'panDown', verb: 'down', dx: 0, dy: 1, key: 'ArrowDown' }, + { id: 'panLeft', verb: 'left', dx: -1, dy: 0, key: 'ArrowLeft' }, + { id: 'panRight', verb: 'right', dx: 1, dy: 0, key: 'ArrowRight' } ] const ZOOM_ACTIONS = [ @@ -20,7 +20,7 @@ const ZOOM_ACTIONS = [ export const MoveControl = () => { const { id: appId, mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta } = useConfig() - const { dispatch, expandedButtons, nudgeStepSize } = useApp() + const { dispatch, expandedButtons, nudgeStepSize, interfaceType, layoutRefs } = useApp() const { isAtMaxZoom, isAtMinZoom } = useMap() const { announce } = useService() @@ -38,16 +38,62 @@ export const MoveControl = () => { // keyboard-shortcut vocabulary, so the label always describes the step size in effect. const actionWord = isLargeStep ? 'Move' : 'Nudge' - const handlePan = (dx, dy, verb) => { - const amount = resolveStepAmount(isLargeStep, nudgePanDelta, panDelta) + // Registry buttons (declared via a plugin's manifest) auto-return focus to the map + // viewport after a click (see createButtonClickHandler in mapButtons.js) unless + // marked keepFocus — which is how e.g. draw's own toolbar buttons never leave + // keyboard shortcuts (like arrow-key vertex nudging) stranded afterward. MoveControl + // renders its own buttons directly, bypassing that mechanism entirely, so it has to + // replicate the same return-to-viewport behaviour itself. But unlike a one-off + // action button, the D-pad is meant to be pressed repeatedly — a keyboard/switch + // user tabs to a direction button once and presses Enter/Space many times in a row, + // which requires focus to stay put. So this only returns focus when the interface + // type isn't 'keyboard': a mouse/touch user doesn't need retained focus to click the + // same visible button again, and returning it immediately means they can switch to + // arrow keys right after without an extra click into the map first. + const returnFocusToViewport = () => { + if (interfaceType !== 'keyboard') { + requestAnimationFrame(() => layoutRefs.viewportRef.current?.focus()) + } + } + + const handlePan = (dx, dy, verb, shiftKey = false) => { + // Shift is a momentary "use the small step" override — matching the map's own + // native keyboard shortcuts (keyboardActions.js's getPan/getZoom: "Shift held + // selects the fine nudge amount... not a large step") — regardless of what the + // Precision toggle currently says. Only the arrow-key path below passes a + // shiftKey; button clicks don't, so they're unaffected and still follow the + // toggle exactly as before. Button labels also stay toggle-only (WAI-ARIA + // stable-name pattern) — only the actual step taken and its announcement + // reflect a momentary Shift override. + const effectiveIsLargeStep = shiftKey ? false : isLargeStep + const effectiveActionWord = effectiveIsLargeStep ? 'Move' : 'Nudge' + + // Any plugin can claim the D-pad for something other than panning the map (e.g. + // moving a selected draw vertex) by setting mapProvider.activeMoveTarget — a + // generic { move(dx, dy, isLargeStep), label? } contract. MoveControl doesn't + // know or care what's claiming it; it just checks for a claimant before + // defaulting to its own panBy behaviour. Read fresh on every click rather than + // subscribed to reactively, since it's a plain mapProvider property, not state. + const activeMoveTarget = mapProvider.activeMoveTarget + if (activeMoveTarget) { + activeMoveTarget.move(dx, dy, effectiveIsLargeStep) + const target = activeMoveTarget.label ? `${activeMoveTarget.label} ` : '' + announce(`${effectiveActionWord}d ${target}${verb}`) + returnFocusToViewport() + return + } + + const amount = resolveStepAmount(effectiveIsLargeStep, nudgePanDelta, panDelta) mapProvider.panBy([dx * amount, dy * amount]) - announce(`${actionWord}d ${verb}`) + announce(`${effectiveActionWord}d ${verb}`) + returnFocusToViewport() } const handleZoom = (method, label) => { const amount = resolveStepAmount(isLargeStep, nudgeZoomDelta, zoomDelta) mapProvider[method](amount) announce(label) + returnFocusToViewport() } const handleToggleStep = () => { @@ -55,6 +101,24 @@ export const MoveControl = () => { announce(isLargeStep ? 'Precision on' : 'Precision off') } + // A keyboard user tabs to a direction button and can repeat-press Enter/Space on + // it (see returnFocusToViewport above — focus deliberately stays put for + // 'keyboard'), but that leaves them unable to fall back to raw arrow keys without + // first tabbing all the way back to the map. Handling arrow keys here — while + // focus is anywhere within this control, not just on a direction button — covers + // that directly, without needing draw's (or the app's own) keyboard-shortcut + // guards to special-case MoveControl's buttons. Only arrow keys are handled; + // other shortcuts (delete/escape/undo) still require focus on the viewport, + // which mouse/touch users already get back automatically after any click here. + const handleContainerKeyDown = (e) => { + const direction = DIRECTIONS.find(({ key }) => key === e.key) + if (!direction) { + return + } + e.preventDefault() + handlePan(direction.dx, direction.dy, direction.verb, e.shiftKey) + } + const containerClassName = [ 'im-c-move-control', !isOpen && 'im-c-move-control--collapsed' @@ -105,7 +169,7 @@ export const MoveControl = () => { ) return ( -
+
{/* NOSONAR - only catches arrow-key bubbling from the real, already-focusable button descendants below; the div itself needs no role/tabIndex */} {directionsGroup} {zoomGroup}
diff --git a/src/App/components/MoveControl/MoveControl.module.scss b/src/App/components/MoveControl/MoveControl.module.scss index efff04827..887b161a5 100644 --- a/src/App/components/MoveControl/MoveControl.module.scss +++ b/src/App/components/MoveControl/MoveControl.module.scss @@ -12,7 +12,7 @@ gap: var(--divider-gap); } -.im-c-move-control--collapsed { +.im-c-move-control.im-c-move-control--collapsed { display: none; } @@ -99,7 +99,17 @@ // aria-expanded rather than aria-pressed — the trigger is correctly a disclosure // button (it reveals the control below), not a toggle button, so it doesn't carry // aria-pressed itself; the colour is purely a visual echo of its expanded state. -.im-c-map-button--move-control[aria-expanded="true"] { +// +// Compound selector (base class + variant class), not the variant class alone — +// same reasoning as the --collapsed rule above. This selector's specificity was +// tied with .im-c-map-button:hover (MapButton.module.scss), both (0,2,0), so +// hovering the expanded trigger button was a coin-flip decided by cascade order — +// one cssnano mergeRules reshuffle in production and hover started winning, +// showing the hover tint instead of the pressed colours. The sibling +// .im-c-move-control .im-c-map-button[aria-pressed="true"] rule above never had +// this problem (it's already (0,3,0), safely ahead of hover); this one needed the +// same headroom. +.im-c-map-button.im-c-map-button--move-control[aria-expanded="true"] { color: var(--pressed-button-foreground-color); border-color: var(--pressed-button-border-color); background-color: var(--pressed-button-background-color); diff --git a/src/App/components/MoveControl/MoveControl.test.jsx b/src/App/components/MoveControl/MoveControl.test.jsx index 54035d485..933bbc124 100644 --- a/src/App/components/MoveControl/MoveControl.test.jsx +++ b/src/App/components/MoveControl/MoveControl.test.jsx @@ -25,6 +25,7 @@ describe('MoveControl', () => { dispatch, expandedButtons: new Set(['moveControl']), nudgeStepSize: 'large', + layoutRefs: { viewportRef: { current: { focus: jest.fn() } } }, ...overrides }) @@ -100,6 +101,31 @@ describe('MoveControl', () => { expect(announce).toHaveBeenCalledWith('Nudged up') }) + it('routes direction clicks to mapProvider.activeMoveTarget instead of panning, when a plugin has claimed it', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + render() + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(1, 0, true) + expect(mapProvider.panBy).not.toHaveBeenCalled() + expect(announce).toHaveBeenCalledWith('Moved vertex right') + }) + + it('falls back to panning once activeMoveTarget is released', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + const { rerender } = render() + mapProvider.activeMoveTarget = null + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0]) + }) + + it('omits the target label from the announcement when activeMoveTarget has none', () => { + mapProvider.activeMoveTarget = { move: jest.fn() } + render() + fireEvent.click(screen.getByRole('button', { name: 'Move up' })) + expect(announce).toHaveBeenCalledWith('Moved up') + }) + it('zooms in and out by the large delta by default and announces the action', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) @@ -139,6 +165,116 @@ describe('MoveControl', () => { expect(mapProvider.zoomOut).not.toHaveBeenCalled() }) + describe('returning focus to the viewport after a click', () => { + let rafSpy + + beforeEach(() => { + rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 }) + }) + + afterEach(() => rafSpy.mockRestore()) + + it('returns focus to the viewport after panning on mouse/touch, so arrow-key shortcuts elsewhere are not left stranded on the D-pad button', () => { + const appState = buildAppState({ interfaceType: 'mouse' }) + useApp.mockReturnValue(appState) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(appState.layoutRefs.viewportRef.current.focus).toHaveBeenCalled() + }) + + it('keeps focus on the button when driven by keyboard, so repeated Enter/Space presses do not require re-tabbing', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(rafSpy).not.toHaveBeenCalled() + }) + + it('also returns focus after zooming on mouse/touch, but not on keyboard', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'mouse' })) + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + expect(rafSpy).toHaveBeenCalledTimes(1) + + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + expect(rafSpy).toHaveBeenCalledTimes(1) + }) + + it('also returns focus after a vertex nudge via activeMoveTarget on mouse/touch', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + useApp.mockReturnValue(buildAppState({ interfaceType: 'touch' })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(rafSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('arrow keys while focus is anywhere within the control', () => { + // A keyboard user who tabs to a direction button and repeat-presses Enter keeps + // focus there (see the describe block above) — this lets them fall back to raw + // arrow keys without first tabbing all the way back out to the map. + it('pans the map on an arrow key, regardless of which button currently has focus', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + render() + // Focus a button unrelated to the direction being pressed, to prove this + // isn't just reading the focused button's own handler. + fireEvent.focus(screen.getByRole('button', { name: 'Zoom in' })) + fireEvent.keyDown(screen.getByRole('button', { name: 'Zoom in' }), { key: 'ArrowRight' }) + expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0]) + expect(announce).toHaveBeenCalledWith('Moved right') + }) + + it('routes the arrow key through activeMoveTarget when a plugin has claimed the control', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp' }) + expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, true) + expect(mapProvider.panBy).not.toHaveBeenCalled() + }) + + it('ignores non-arrow keys, leaving default behaviour (e.g. Enter/Space activating the focused button) untouched', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'Enter' }) + expect(mapProvider.panBy).not.toHaveBeenCalled() + }) + + it('shift+arrow overrides the Precision toggle to the small step, matching the map\'s own native keyboard shortcuts', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight', shiftKey: true }) + expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0]) + expect(announce).toHaveBeenCalledWith('Nudged right') + }) + + it('shift+arrow still resolves to the small step when Precision is already on (idempotent, not a toggle-relative flip)', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'small' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Nudge right' }), { key: 'ArrowRight', shiftKey: true }) + expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0]) + expect(announce).toHaveBeenCalledWith('Nudged right') + }) + + it('shift+arrow overrides activeMoveTarget.move to the small step too', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp', shiftKey: true }) + expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, false) + }) + + it('arrow key without shift still follows the Precision toggle as before', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight' }) + expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0]) + expect(announce).toHaveBeenCalledWith('Moved right') + }) + }) + it('has a stable "Precision" label regardless of state', () => { const { rerender } = render() expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument() diff --git a/src/utils/detectInterfaceType.js b/src/utils/detectInterfaceType.js index 900497c9f..0fa1b57cc 100755 --- a/src/utils/detectInterfaceType.js +++ b/src/utils/detectInterfaceType.js @@ -59,7 +59,11 @@ function createInterfaceDetector () { } const handleKeyDown = e => { - if (e.key === 'Tab') { + // Recognize keyboard mode from Tab (explicit focus), arrow keys (navigation), + // Enter (confirmation), or other significant keys. This allows the interface type + // to update even when keyboard input happens during drawing (where focus is on map). + const keyboardModeKeys = new Set(['Tab', 'Enter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Escape']) + if (keyboardModeKeys.has(e.key)) { notifyListeners('keyboard') } } diff --git a/src/utils/isMac.js b/src/utils/isMac.js new file mode 100644 index 000000000..df5211645 --- /dev/null +++ b/src/utils/isMac.js @@ -0,0 +1,15 @@ +/** + * Detects whether the current platform is macOS. + * + * Prefers the modern User-Agent Client Hints API and falls back to the legacy + * `navigator.platform`. Guarded so it is safe to call in non-browser + * environments (Node/SSR/tests), where it returns `false`. + * + * @returns {boolean} True when running on macOS. + */ +export const isMac = () => { + if (typeof navigator === 'undefined') { + return false + } + return /mac/i.test(navigator.userAgentData?.platform || navigator.platform || '') +} diff --git a/src/utils/isMac.test.js b/src/utils/isMac.test.js new file mode 100644 index 000000000..8f7345b9a --- /dev/null +++ b/src/utils/isMac.test.js @@ -0,0 +1,37 @@ +import { isMac } from './isMac.js' + +describe('isMac', () => { + const original = Object.getOwnPropertyDescriptor(global, 'navigator') + + afterEach(() => { + if (original) { + Object.defineProperty(global, 'navigator', original) + } else { + delete global.navigator + } + }) + + const setNavigator = (value) => { + Object.defineProperty(global, 'navigator', { value, configurable: true, writable: true }) + } + + test('returns false when navigator is undefined', () => { + setNavigator(undefined) + expect(isMac()).toBe(false) + }) + + test('detects macOS via userAgentData.platform', () => { + setNavigator({ userAgentData: { platform: 'macOS' }, platform: '' }) + expect(isMac()).toBe(true) + }) + + test('falls back to navigator.platform', () => { + setNavigator({ platform: 'MacIntel' }) + expect(isMac()).toBe(true) + }) + + test('returns false on non-mac platforms', () => { + setNavigator({ userAgentData: { platform: 'Windows' }, platform: 'Win32' }) + expect(isMac()).toBe(false) + }) +}) diff --git a/webpack.umd.mjs b/webpack.umd.mjs index b67609617..07bc4c8ed 100755 --- a/webpack.umd.mjs +++ b/webpack.umd.mjs @@ -146,6 +146,7 @@ const ALL_BUILDS = [ { entryPath: './plugins/interact/src/index.js', libraryPath: 'interactPlugin', outDir: 'plugins/interact/dist/umd' }, { entryPath: './plugins/datasets/src/index.js', libraryPath: 'datasetsPlugin', outDir: 'plugins/datasets/dist/umd', cssOutDir: 'plugins/datasets/dist' }, { entryPath: './plugins/beta/map-styles/src/index.js', libraryPath: 'mapStylesPlugin', outDir: 'plugins/beta/map-styles/dist/umd' }, + { entryPath: './plugins/draw/src/index.js', libraryPath: 'drawPlugin', outDir: 'plugins/draw/dist/umd' }, { entryPath: './plugins/beta/draw-ml/src/index.js', libraryPath: 'drawMLPlugin', outDir: 'plugins/beta/draw-ml/dist/umd' }, { entryPath: './plugins/beta/frame/src/index.js', libraryPath: 'framePlugin', outDir: 'plugins/beta/frame/dist/umd' } ]