@orbat-mapper/tactical-draw 0.2.0-alpha.34 → 0.2.0-alpha.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +60 -445
- package/dist/index.mjs +533 -437
- package/dist/map-adapter-meRo5VY2.d.mts +361 -0
- package/dist/testing.d.mts +25 -0
- package/dist/testing.mjs +247 -0
- package/package.json +11 -2
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { FillPattern } from "@orbat-mapper/control-measures";
|
|
2
|
+
import { Feature, Position } from "geojson";
|
|
3
|
+
|
|
4
|
+
//#region src/gestures/types.d.ts
|
|
5
|
+
interface PointerEventInfo {
|
|
6
|
+
/** Per-event hit-tolerance override (e.g. larger for touch). */
|
|
7
|
+
hitTolerance?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Whether the Alt/Option modifier was held for this pointer event. The edit
|
|
10
|
+
* controller reads this on pointer-down to turn a vertex hit into a delete
|
|
11
|
+
* (the GIS-standard Alt+click-to-delete gesture) instead of a drag. Only the
|
|
12
|
+
* mouse paths populate it; touch leaves it `undefined`.
|
|
13
|
+
*/
|
|
14
|
+
altKey?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Whether Shift was held for this pointer event. Rotation gestures use the
|
|
17
|
+
* live value to constrain transform-box angles to 15-degree increments.
|
|
18
|
+
*/
|
|
19
|
+
shiftKey?: boolean;
|
|
20
|
+
/** Engine-native event, for drivers that need to call preventDefault etc. */
|
|
21
|
+
native?: unknown;
|
|
22
|
+
}
|
|
23
|
+
interface PointerCallback {
|
|
24
|
+
(pixel: PixelCoordinate, info?: PointerEventInfo): void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Engine-agnostic pointer driver consumed by `TacticalDraw`'s edit controller.
|
|
28
|
+
* The controller owns handle rendering and hit-tests handles in lonLat space;
|
|
29
|
+
* the driver only carries raw pointer events, drag-pan toggle, and pixel
|
|
30
|
+
* projection.
|
|
31
|
+
*/
|
|
32
|
+
interface EditPointerDriver {
|
|
33
|
+
onPointerDown(cb: PointerCallback): () => void;
|
|
34
|
+
onPointerMove(cb: PointerCallback): () => void;
|
|
35
|
+
onPointerUp(cb: PointerCallback): () => void;
|
|
36
|
+
setDragPanEnabled(enabled: boolean): void;
|
|
37
|
+
toPixel(lonLat: Position): PixelCoordinate | null;
|
|
38
|
+
fromPixel(pixel: PixelCoordinate): Position;
|
|
39
|
+
/** Idempotent. */
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/fill-pattern.d.ts
|
|
44
|
+
/**
|
|
45
|
+
* Shared hatch fill-pattern tiles for area control-measure fills (ADR-0035
|
|
46
|
+
* `FillPattern`, excluding `"solid"` which every adapter already renders as a
|
|
47
|
+
* flat color). Both the MapLibre adapter (raster `addImage` pattern) and the
|
|
48
|
+
* OpenLayers adapter (canvas `Pattern` fill) need the *same* diagonal-hachure
|
|
49
|
+
* texture, so it is rasterized once here in pure math — no `HTMLCanvasElement`
|
|
50
|
+
* — so it works in adapter code and in jsdom/node tests alike.
|
|
51
|
+
*/
|
|
52
|
+
type HatchPattern = Exclude<FillPattern, "solid">;
|
|
53
|
+
/**
|
|
54
|
+
* Every non-solid `FillPattern` as a runtime list — the value-level companion
|
|
55
|
+
* to {@link HatchPattern}, so adapters can test membership and build filter
|
|
56
|
+
* expressions without re-spelling the pattern vocabulary.
|
|
57
|
+
*/
|
|
58
|
+
declare const HATCH_PATTERNS: readonly HatchPattern[];
|
|
59
|
+
/**
|
|
60
|
+
* Stable id for a rasterized tile, keyed by pattern and color. Adapters use
|
|
61
|
+
* this as the image-registry key (MapLibre `addImage` id, OpenLayers pattern
|
|
62
|
+
* cache key) so the same color+pattern never gets rasterized twice. Color is
|
|
63
|
+
* lowercased so `"#FF0000"` and `"#ff0000"` share one texture.
|
|
64
|
+
*/
|
|
65
|
+
declare function fillPatternImageId(color: string, pattern: HatchPattern): string;
|
|
66
|
+
/**
|
|
67
|
+
* Tile edge length in device pixels for a given `pixelRatio`. Must be a power
|
|
68
|
+
* of two — MapLibre requires POT dimensions for seamless WebGL texture wrap —
|
|
69
|
+
* and scales linearly with `pixelRatio` for the ratios that matter in
|
|
70
|
+
* practice (1x/2x displays: 32 -> 64, both already POT). Non-POT products
|
|
71
|
+
* (e.g. a 3x ratio, 32*3=96) round up to the next POT rather than losing the
|
|
72
|
+
* power-of-two guarantee.
|
|
73
|
+
*/
|
|
74
|
+
declare function fillPatternImageDimension(pixelRatio: number): number;
|
|
75
|
+
/**
|
|
76
|
+
* Rasterize one seamless hatch tile as a straight RGBA pixel buffer — no
|
|
77
|
+
* canvas involved, so this runs identically in a browser adapter and in
|
|
78
|
+
* jsdom/node tests. Diagonal lines are snapped to slope ±1 (45°) so the
|
|
79
|
+
* mod-based coverage math in {@link lineCoverage} tiles exactly. `"hachure"`
|
|
80
|
+
* draws the "/" diagonal, `"backhachure"` its "\" mirror, and `"cross"` unions
|
|
81
|
+
* both via `Math.max` coverage.
|
|
82
|
+
*/
|
|
83
|
+
declare function makeFillPatternImage(color: string, pattern: HatchPattern, pixelRatio?: number): {
|
|
84
|
+
width: number;
|
|
85
|
+
height: number;
|
|
86
|
+
data: Uint8ClampedArray;
|
|
87
|
+
};
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/map-adapter.d.ts
|
|
90
|
+
type PixelCoordinate = [number, number];
|
|
91
|
+
type MapCursor = "" | "default" | "crosshair" | "grab" | "grabbing" | "pointer";
|
|
92
|
+
interface MapEvent {
|
|
93
|
+
type: string;
|
|
94
|
+
coordinate: Position;
|
|
95
|
+
pixel: PixelCoordinate;
|
|
96
|
+
originalEvent: unknown;
|
|
97
|
+
}
|
|
98
|
+
type MapEventHandler = (event: MapEvent) => void;
|
|
99
|
+
interface StyleOptions {
|
|
100
|
+
strokeColor?: string;
|
|
101
|
+
strokeWidth?: number;
|
|
102
|
+
strokeDash?: number[];
|
|
103
|
+
fillColor?: string;
|
|
104
|
+
pointFillColor?: string;
|
|
105
|
+
pointRadius?: number;
|
|
106
|
+
text?: TextStyleOptions;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Text (label) style for a layer. When `labelProperty` is set, adapters also
|
|
110
|
+
* read per-feature label properties: `rotation` (radians, rendered as
|
|
111
|
+
* `π − rotation`), the size anchor's `textSizeMeters` (ground) or
|
|
112
|
+
* `textSizePixels` (screen) (ADR-0028), and `textAnchor` (`"start"` = text
|
|
113
|
+
* begins at the label point, `"end"` = text ends there; absent = centered).
|
|
114
|
+
* A ground-anchored label renders at
|
|
115
|
+
* `clamp(textSizeMeters / resolution, textSizeMinPixels, textSizeMaxPixels)`;
|
|
116
|
+
* a screen-anchored one at a constant `textSizePixels`, unclamped.
|
|
117
|
+
*/
|
|
118
|
+
interface TextStyleOptions {
|
|
119
|
+
text?: string;
|
|
120
|
+
labelProperty?: string;
|
|
121
|
+
font?: string;
|
|
122
|
+
fillColor?: string;
|
|
123
|
+
strokeColor?: string;
|
|
124
|
+
strokeWidth?: number;
|
|
125
|
+
rotation?: number;
|
|
126
|
+
/**
|
|
127
|
+
* Lower edge of the ground-anchored label clamp band (ADR-0028): text never
|
|
128
|
+
* renders below this pixel size, however far the view zooms out.
|
|
129
|
+
* @default 8
|
|
130
|
+
*/
|
|
131
|
+
textSizeMinPixels?: number;
|
|
132
|
+
/**
|
|
133
|
+
* Upper edge of the ground-anchored label clamp band (ADR-0028): text never
|
|
134
|
+
* renders above this pixel size, however far the view zooms in.
|
|
135
|
+
* @default 24
|
|
136
|
+
*/
|
|
137
|
+
textSizeMaxPixels?: number;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Per-feature style for edit handles (vertex / midpoint / translate / rotate).
|
|
141
|
+
* The producer (`TacticalDraw`) rides this on `properties.style`; the adapter
|
|
142
|
+
* reads it back when rendering the handle, so it is part of the adapter
|
|
143
|
+
* contract rather than the tactical-draw authoring surface. `pointRadius` is a
|
|
144
|
+
* UI affordance not present on `ControlMeasureStyle`. See ADR-0006.
|
|
145
|
+
*/
|
|
146
|
+
interface HandleStyle {
|
|
147
|
+
strokeColor?: string;
|
|
148
|
+
strokeWidth?: number;
|
|
149
|
+
strokeDash?: readonly number[];
|
|
150
|
+
fillColor?: string;
|
|
151
|
+
/**
|
|
152
|
+
* How `fillColor` paints a filled part's interior. Absent or `"solid"` is
|
|
153
|
+
* today's flat fill; `"hachure"`/`"backhachure"`/`"cross"` request the shared hatch texture
|
|
154
|
+
* ({@link makeFillPatternImage}). Alpha rides in `fillColor` — there is no
|
|
155
|
+
* separate opacity field. Adapters that cannot rasterize a pattern degrade
|
|
156
|
+
* to solid. Per-feature only: layer-level `StyleOptions` has no equivalent.
|
|
157
|
+
*/
|
|
158
|
+
fillPattern?: "solid" | HatchPattern;
|
|
159
|
+
pointRadius?: number;
|
|
160
|
+
}
|
|
161
|
+
type LayerId = string;
|
|
162
|
+
/** Canonical SVG resource shared by point features through `iconId`. */
|
|
163
|
+
interface IconResource {
|
|
164
|
+
id: string;
|
|
165
|
+
svg: string;
|
|
166
|
+
width: number;
|
|
167
|
+
height: number;
|
|
168
|
+
anchor: [number, number];
|
|
169
|
+
/**
|
|
170
|
+
* Direct visible mark used to rank icon picks, in unscaled canvas coordinates.
|
|
171
|
+
* Hits elsewhere in the full canvas measure their distance from this rectangle;
|
|
172
|
+
* omit it to preserve whole-canvas distance-zero behavior.
|
|
173
|
+
*/
|
|
174
|
+
coreRect?: {
|
|
175
|
+
x: number;
|
|
176
|
+
y: number;
|
|
177
|
+
width: number;
|
|
178
|
+
height: number;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* One stable, atomic unit in a logical layer's bottom-to-top paint stack.
|
|
183
|
+
*
|
|
184
|
+
* Features retain their renderer-defined order inside the group. An empty
|
|
185
|
+
* group is valid and keeps its identity and stack position.
|
|
186
|
+
*/
|
|
187
|
+
interface FeatureGroup {
|
|
188
|
+
id: string;
|
|
189
|
+
features: readonly Feature[];
|
|
190
|
+
iconResources: readonly IconResource[];
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Click-style hit event delivered by {@link MapAdapter.onPick}.
|
|
194
|
+
*
|
|
195
|
+
* `id` is the control measure id, already split off the feature id by the
|
|
196
|
+
* adapter (see `controlMeasureIdFromFeature`).
|
|
197
|
+
*
|
|
198
|
+
* `originalEvent` is whatever the engine surfaces — OpenLayers normalises to
|
|
199
|
+
* `PointerEvent`, MapLibre delivers `MouseEvent`, Leaflet delivers
|
|
200
|
+
* `MouseEvent | TouchEvent`. Do not narrow to `PointerEvent` at the consumer.
|
|
201
|
+
*/
|
|
202
|
+
interface PickEvent {
|
|
203
|
+
id: string;
|
|
204
|
+
feature: Feature;
|
|
205
|
+
pixel: PixelCoordinate;
|
|
206
|
+
coordinate: Position;
|
|
207
|
+
originalEvent: MouseEvent | PointerEvent | TouchEvent;
|
|
208
|
+
}
|
|
209
|
+
type PickHandler = (event: PickEvent) => void;
|
|
210
|
+
interface PickOptions {
|
|
211
|
+
signal?: AbortSignal;
|
|
212
|
+
}
|
|
213
|
+
interface MapAdapter {
|
|
214
|
+
/**
|
|
215
|
+
* Add a vector layer to the map.
|
|
216
|
+
* @returns unique layer ID
|
|
217
|
+
*/
|
|
218
|
+
addVectorLayer(options?: {
|
|
219
|
+
style?: StyleOptions;
|
|
220
|
+
}): LayerId;
|
|
221
|
+
/**
|
|
222
|
+
* Remove a layer from the map.
|
|
223
|
+
*/
|
|
224
|
+
removeLayer(layerId: LayerId): void;
|
|
225
|
+
/**
|
|
226
|
+
* Set the style for a specific layer.
|
|
227
|
+
*/
|
|
228
|
+
setLayerStyle(layerId: LayerId, style: StyleOptions): void;
|
|
229
|
+
/**
|
|
230
|
+
* Reconcile one complete bottom-to-top desired state for a logical layer.
|
|
231
|
+
*
|
|
232
|
+
* Group IDs must be non-empty and unique within the layer. Every feature
|
|
233
|
+
* must have an ID that is unique across all groups in the layer. Invalid
|
|
234
|
+
* desired state throws synchronously before cache, resource, or engine
|
|
235
|
+
* mutation.
|
|
236
|
+
*/
|
|
237
|
+
setLayerFeatureGroups(layerId: LayerId, groups: readonly FeatureGroup[]): void;
|
|
238
|
+
/**
|
|
239
|
+
* Subscribe to click-style "pick" events for a specific layer. Basemap and
|
|
240
|
+
* other-layer clicks never fire the handler.
|
|
241
|
+
*
|
|
242
|
+
* For control-measure features, adapters must set `PickEvent.id` to the
|
|
243
|
+
* originating control-measure id (not the rendered part's feature id).
|
|
244
|
+
* Features that do not satisfy the control-measure feature-id contract must
|
|
245
|
+
* be ignored rather than dispatched.
|
|
246
|
+
*
|
|
247
|
+
* Bound to the engine's tap-suppressing click event (OL `singleclick`,
|
|
248
|
+
* MapLibre `click`, Leaflet `click`) so the handler does not fire at the
|
|
249
|
+
* end of a pan or pinch-zoom gesture.
|
|
250
|
+
*
|
|
251
|
+
* Hit tolerance is pointer-type aware: 4 px for mouse/pen, 12 px for touch.
|
|
252
|
+
* Outline-only polygon interiors additionally have bounded inward selection
|
|
253
|
+
* reach from visible viewport-clipped boundaries: 24 px for mouse/pen and
|
|
254
|
+
* 36 px for touch. A visible solid or patterned fill owns its full interior.
|
|
255
|
+
* For grouped desired state, comparable hits follow authoritative group
|
|
256
|
+
* order even when the engine reports another native order. A fully opaque
|
|
257
|
+
* solid fill in a higher group suppresses lower candidates beneath its
|
|
258
|
+
* interior; translucent and patterned fills leave visible lower marks
|
|
259
|
+
* eligible.
|
|
260
|
+
*
|
|
261
|
+
* Returns an unsubscribe function. The returned function and the optional
|
|
262
|
+
* `signal` are both honoured; either teardown path stops further events,
|
|
263
|
+
* and both are idempotent.
|
|
264
|
+
*/
|
|
265
|
+
onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
|
|
266
|
+
/**
|
|
267
|
+
* Map Event Listeners
|
|
268
|
+
*
|
|
269
|
+
* Ordering contract: a `"click"` handler and the `onPick` handlers for the
|
|
270
|
+
* same user click MUST observe the pick first — adapters deliver picks and
|
|
271
|
+
* `"click"` from the same underlying engine event, in registration order.
|
|
272
|
+
* The draw/edit controllers register their pick subscriptions before their
|
|
273
|
+
* click handler and read the stashed pick inside it (click-away close /
|
|
274
|
+
* close-then-pick re-dispatch); an adapter that fires `"click"` from an
|
|
275
|
+
* earlier engine event than its picks breaks that contract (e.g. OL
|
|
276
|
+
* `click` vs `singleclick`).
|
|
277
|
+
*/
|
|
278
|
+
on(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
|
|
279
|
+
off(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
|
|
280
|
+
/**
|
|
281
|
+
* Projection / Coordinate Utilities
|
|
282
|
+
*/
|
|
283
|
+
toLonLat(coordinate: number[]): Position;
|
|
284
|
+
fromLonLat(position: Position): number[];
|
|
285
|
+
getPixelFromCoordinate(position: Position): PixelCoordinate | null;
|
|
286
|
+
/**
|
|
287
|
+
* Convert a viewport pixel to longitude/latitude. Optional so third-party
|
|
288
|
+
* adapters compiled against an older ABI remain valid.
|
|
289
|
+
*/
|
|
290
|
+
getCoordinateFromPixel?(pixel: PixelCoordinate): Position | null;
|
|
291
|
+
/**
|
|
292
|
+
* Get current viewport size in CSS pixels.
|
|
293
|
+
*/
|
|
294
|
+
getViewportSize(): {
|
|
295
|
+
width: number;
|
|
296
|
+
height: number;
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* Pan the map by pixel delta.
|
|
300
|
+
* Positive delta means desired on-screen feature shift (right/down).
|
|
301
|
+
*/
|
|
302
|
+
panByPixels(delta: PixelCoordinate, options?: {
|
|
303
|
+
durationMs?: number;
|
|
304
|
+
}): void;
|
|
305
|
+
/**
|
|
306
|
+
* Set the map viewport cursor. Pass an empty string to restore engine default styling.
|
|
307
|
+
*/
|
|
308
|
+
setCursor(cursor: MapCursor): void;
|
|
309
|
+
/**
|
|
310
|
+
* Toggle the engine's native double-click-to-zoom gesture. `TacticalDraw`
|
|
311
|
+
* disables this for the lifetime of a variable-length draw so the commit
|
|
312
|
+
* `dblclick` doesn't also zoom the map.
|
|
313
|
+
*/
|
|
314
|
+
setDoubleClickZoomEnabled(enabled: boolean): void;
|
|
315
|
+
/**
|
|
316
|
+
* Get current view resolution (meters per pixel at center or generally).
|
|
317
|
+
* returns undefined if not supported or not ready.
|
|
318
|
+
*/
|
|
319
|
+
getResolution(): number | undefined;
|
|
320
|
+
/**
|
|
321
|
+
* Get current map zoom level
|
|
322
|
+
*/
|
|
323
|
+
getZoom(): number | undefined;
|
|
324
|
+
/**
|
|
325
|
+
* Get current view bearing, radians — the clockwise-from-north compass
|
|
326
|
+
* direction that screen-up points to (MapLibre-bearing semantics). `0` for
|
|
327
|
+
* engines/views that don't rotate.
|
|
328
|
+
*/
|
|
329
|
+
getBearing(): number;
|
|
330
|
+
/**
|
|
331
|
+
* Listen for view changes (zoom/resolution)
|
|
332
|
+
*/
|
|
333
|
+
onViewChange(handler: () => void): void;
|
|
334
|
+
offViewChange(handler: () => void): void;
|
|
335
|
+
/**
|
|
336
|
+
* The engine evaluates the meters→pixels clamp for ground-sized point
|
|
337
|
+
* symbols inside its own style layer, so meters-sized point symbols don't
|
|
338
|
+
* need view-change re-renders for size.
|
|
339
|
+
*/
|
|
340
|
+
readonly scalesGroundPointSymbolsNatively?: boolean;
|
|
341
|
+
/**
|
|
342
|
+
* Construct an {@link EditPointerDriver} for `TacticalDraw`'s edit
|
|
343
|
+
* controller. The controller subscribes to pointer events on the returned
|
|
344
|
+
* driver to wire handle drags (vertex / midpoint / translate / rotate).
|
|
345
|
+
*/
|
|
346
|
+
createEditPointerDriver(): EditPointerDriver;
|
|
347
|
+
/**
|
|
348
|
+
* Release all adapter-owned map resources. Adapters must not be used after
|
|
349
|
+
* destruction.
|
|
350
|
+
*/
|
|
351
|
+
destroy(): void;
|
|
352
|
+
/**
|
|
353
|
+
* Returns the features currently set on the layer — the adapter-side cache
|
|
354
|
+
* of the last grouped mutation, flattened in bottom-to-top group
|
|
355
|
+
* order. Empty array for an unknown layer. The private ordered group cache is
|
|
356
|
+
* not exposed. Callers must not mutate the returned array or features.
|
|
357
|
+
*/
|
|
358
|
+
getLayerFeatures(layerId: LayerId): readonly Feature[];
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
export { PointerEventInfo as S, fillPatternImageDimension as _, MapAdapter as a, EditPointerDriver as b, MapEventHandler as c, PickOptions as d, PixelCoordinate as f, HatchPattern as g, HATCH_PATTERNS as h, LayerId as i, PickEvent as l, TextStyleOptions as m, HandleStyle as n, MapCursor as o, StyleOptions as p, IconResource as r, MapEvent as s, FeatureGroup as t, PickHandler as u, fillPatternImageId as v, PointerCallback as x, makeFillPatternImage as y };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { a as MapAdapter, f as PixelCoordinate, i as LayerId } from "./map-adapter-meRo5VY2.mjs";
|
|
2
|
+
import { Feature } from "geojson";
|
|
3
|
+
|
|
4
|
+
//#region src/testing/feature-group-conformance.d.ts
|
|
5
|
+
interface FeatureGroupConformanceSubject {
|
|
6
|
+
adapter: MapAdapter;
|
|
7
|
+
layerId: LayerId;
|
|
8
|
+
engineFeatureIds(): Array<string | number | undefined>;
|
|
9
|
+
hasIconResource(id: string): boolean;
|
|
10
|
+
rendersIconFeature(id: string | number): boolean;
|
|
11
|
+
rendersFeatureStyle(id: string | number, channel: "fill" | "stroke"): boolean;
|
|
12
|
+
mutationCount(): number;
|
|
13
|
+
operationLog(): readonly string[];
|
|
14
|
+
}
|
|
15
|
+
declare function runFeatureGroupConformance(name: string, createSubject: () => FeatureGroupConformanceSubject): void;
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/testing/grouped-pick-conformance.d.ts
|
|
18
|
+
interface GroupedPickConformanceSubject {
|
|
19
|
+
adapter: MapAdapter;
|
|
20
|
+
layerId: LayerId;
|
|
21
|
+
pickAt(pixel: PixelCoordinate, nativeHitOrder: readonly Feature[]): string | null;
|
|
22
|
+
}
|
|
23
|
+
declare function runGroupedPickConformance(name: string, createSubject: () => GroupedPickConformanceSubject): void;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { type FeatureGroupConformanceSubject, type GroupedPickConformanceSubject, runFeatureGroupConformance, runGroupedPickConformance };
|
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
//#region src/testing/feature-group-conformance.ts
|
|
3
|
+
function runFeatureGroupConformance(name, createSubject) {
|
|
4
|
+
describe(`${name} feature-group conformance`, () => {
|
|
5
|
+
it("reconciles complete ordered group state, including pure reorders", () => {
|
|
6
|
+
const subject = createSubject();
|
|
7
|
+
const { adapter, layerId } = subject;
|
|
8
|
+
const symbol = iconResource("lower-symbol");
|
|
9
|
+
adapter.setLayerFeatureGroups(layerId, [group$1("lower", [polygon$1("lower-area"), iconPoint("lower-symbol-feature", symbol.id)], [symbol]), group$1("upper", [line$1("upper-line")])]);
|
|
10
|
+
expect(featureIds(adapter.getLayerFeatures(layerId))).toEqual([
|
|
11
|
+
"lower-area",
|
|
12
|
+
"lower-symbol-feature",
|
|
13
|
+
"upper-line"
|
|
14
|
+
]);
|
|
15
|
+
expect(subject.engineFeatureIds()).toEqual([
|
|
16
|
+
"lower-area",
|
|
17
|
+
"lower-symbol-feature",
|
|
18
|
+
"upper-line"
|
|
19
|
+
]);
|
|
20
|
+
expect(subject.rendersIconFeature("lower-symbol-feature")).toBe(true);
|
|
21
|
+
expect(subject.rendersFeatureStyle("lower-area", "fill")).toBe(true);
|
|
22
|
+
expect(subject.rendersFeatureStyle("upper-line", "stroke")).toBe(true);
|
|
23
|
+
adapter.setLayerFeatureGroups(layerId, [group$1("upper", [line$1("upper-line")]), group$1("lower", [polygon$1("lower-area"), iconPoint("lower-symbol-feature", symbol.id)], [symbol])]);
|
|
24
|
+
expect(subject.engineFeatureIds()).toEqual([
|
|
25
|
+
"upper-line",
|
|
26
|
+
"lower-area",
|
|
27
|
+
"lower-symbol-feature"
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
it("rejects invalid identities before cache or engine mutation", () => {
|
|
31
|
+
const invalidStates = [
|
|
32
|
+
["a missing group id", [group$1("", [point("a")], [iconResource("rejected")])]],
|
|
33
|
+
["a duplicate group id", [group$1("same", [point("a")]), group$1("same", [point("b")])]],
|
|
34
|
+
["a missing feature id", [group$1("a", [point(void 0)])]],
|
|
35
|
+
["a duplicate feature id", [group$1("a", [point("same")]), group$1("b", [point("same")])]]
|
|
36
|
+
];
|
|
37
|
+
for (const [label, invalidGroups] of invalidStates) {
|
|
38
|
+
const subject = createSubject();
|
|
39
|
+
const { adapter, layerId } = subject;
|
|
40
|
+
adapter.setLayerFeatureGroups(layerId, [group$1("valid", [point("kept")])]);
|
|
41
|
+
const mutationsBefore = subject.mutationCount();
|
|
42
|
+
expect(() => adapter.setLayerFeatureGroups(layerId, invalidGroups), label).toThrow();
|
|
43
|
+
expect(featureIds(adapter.getLayerFeatures(layerId))).toEqual(["kept"]);
|
|
44
|
+
expect(subject.engineFeatureIds()).toEqual(["kept"]);
|
|
45
|
+
expect(subject.hasIconResource("rejected")).toBe(false);
|
|
46
|
+
expect(subject.mutationCount()).toBe(mutationsBefore);
|
|
47
|
+
}
|
|
48
|
+
const unknownLayerSubject = createSubject();
|
|
49
|
+
unknownLayerSubject.adapter.removeLayer(unknownLayerSubject.layerId);
|
|
50
|
+
expect(() => unknownLayerSubject.adapter.setLayerFeatureGroups("unknown", [group$1("", [point("invalid")])])).toThrow();
|
|
51
|
+
});
|
|
52
|
+
it("preserves an empty group's stack position while reconciling icon resources", () => {
|
|
53
|
+
const subject = createSubject();
|
|
54
|
+
const { adapter, layerId } = subject;
|
|
55
|
+
const icon = iconResource("symbol");
|
|
56
|
+
adapter.setLayerFeatureGroups(layerId, [
|
|
57
|
+
group$1("lower", [point("lower")]),
|
|
58
|
+
group$1("recoverable", [iconPoint("symbol-feature", icon.id)], [icon]),
|
|
59
|
+
group$1("upper", [point("upper")])
|
|
60
|
+
]);
|
|
61
|
+
expect(subject.hasIconResource(icon.id)).toBe(true);
|
|
62
|
+
expect(subject.rendersIconFeature("symbol-feature")).toBe(true);
|
|
63
|
+
expect(subject.operationLog().slice(-2)).toEqual(["icons:symbol", "groups:lower,recoverable,upper"]);
|
|
64
|
+
adapter.setLayerFeatureGroups(layerId, [
|
|
65
|
+
group$1("lower", [point("lower")]),
|
|
66
|
+
group$1("recoverable", []),
|
|
67
|
+
group$1("upper", [point("upper")])
|
|
68
|
+
]);
|
|
69
|
+
expect(subject.engineFeatureIds()).toEqual(["lower", "upper"]);
|
|
70
|
+
expect(subject.hasIconResource(icon.id)).toBe(false);
|
|
71
|
+
adapter.setLayerFeatureGroups(layerId, [
|
|
72
|
+
group$1("lower", [point("lower")]),
|
|
73
|
+
group$1("recoverable", [iconPoint("replacement", icon.id)], [icon]),
|
|
74
|
+
group$1("upper", [point("upper")])
|
|
75
|
+
]);
|
|
76
|
+
expect(subject.engineFeatureIds()).toEqual([
|
|
77
|
+
"lower",
|
|
78
|
+
"replacement",
|
|
79
|
+
"upper"
|
|
80
|
+
]);
|
|
81
|
+
expect(subject.hasIconResource(icon.id)).toBe(true);
|
|
82
|
+
expect(subject.rendersIconFeature("replacement")).toBe(true);
|
|
83
|
+
expect(subject.operationLog().at(-1)).toBe("groups:lower,recoverable,upper");
|
|
84
|
+
});
|
|
85
|
+
it("returns a flattened read without exposing grouped state", () => {
|
|
86
|
+
const { adapter, layerId } = createSubject();
|
|
87
|
+
adapter.setLayerFeatureGroups(layerId, [
|
|
88
|
+
group$1("a", [point("a-1"), point("a-2")]),
|
|
89
|
+
group$1("empty", []),
|
|
90
|
+
group$1("b", [point("b-1")])
|
|
91
|
+
]);
|
|
92
|
+
const flattened = adapter.getLayerFeatures(layerId);
|
|
93
|
+
expect(featureIds(flattened)).toEqual([
|
|
94
|
+
"a-1",
|
|
95
|
+
"a-2",
|
|
96
|
+
"b-1"
|
|
97
|
+
]);
|
|
98
|
+
expect(flattened).not.toHaveProperty("groups");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function group$1(id, features, iconResources = []) {
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
features,
|
|
106
|
+
iconResources
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function point(id) {
|
|
110
|
+
return {
|
|
111
|
+
type: "Feature",
|
|
112
|
+
...id === void 0 ? {} : { id },
|
|
113
|
+
properties: {},
|
|
114
|
+
geometry: {
|
|
115
|
+
type: "Point",
|
|
116
|
+
coordinates: [0, 0]
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function iconPoint(id, iconId) {
|
|
121
|
+
return {
|
|
122
|
+
...point(id),
|
|
123
|
+
properties: { iconId }
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function line$1(id) {
|
|
127
|
+
return {
|
|
128
|
+
type: "Feature",
|
|
129
|
+
id,
|
|
130
|
+
properties: { style: {
|
|
131
|
+
strokeColor: "#000000",
|
|
132
|
+
strokeWidth: 2
|
|
133
|
+
} },
|
|
134
|
+
geometry: {
|
|
135
|
+
type: "LineString",
|
|
136
|
+
coordinates: [[0, 0], [1, 1]]
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function polygon$1(id) {
|
|
141
|
+
return {
|
|
142
|
+
type: "Feature",
|
|
143
|
+
id,
|
|
144
|
+
properties: { style: {
|
|
145
|
+
fillColor: "#ffffff",
|
|
146
|
+
strokeColor: "#000000",
|
|
147
|
+
strokeWidth: 2
|
|
148
|
+
} },
|
|
149
|
+
geometry: {
|
|
150
|
+
type: "Polygon",
|
|
151
|
+
coordinates: [[
|
|
152
|
+
[0, 0],
|
|
153
|
+
[1, 0],
|
|
154
|
+
[1, 1],
|
|
155
|
+
[0, 0]
|
|
156
|
+
]]
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function featureIds(features) {
|
|
161
|
+
return features.map((feature) => feature.id);
|
|
162
|
+
}
|
|
163
|
+
function iconResource(id) {
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
svg: "<svg/>",
|
|
167
|
+
width: 1,
|
|
168
|
+
height: 1,
|
|
169
|
+
anchor: [0, 0]
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/testing/grouped-pick-conformance.ts
|
|
174
|
+
function runGroupedPickConformance(name, createSubject) {
|
|
175
|
+
describe(`${name} grouped-pick conformance`, () => {
|
|
176
|
+
it("uses grouped desired-state order when native hit order disagrees", () => {
|
|
177
|
+
const subject = createSubject();
|
|
178
|
+
const lower = line("lower");
|
|
179
|
+
const higher = line("higher");
|
|
180
|
+
subject.adapter.setLayerFeatureGroups(subject.layerId, [group("lower", lower), group("higher", higher)]);
|
|
181
|
+
expect(subject.pickAt([0, 50], [lower, higher])).toBe("higher");
|
|
182
|
+
});
|
|
183
|
+
it.each([
|
|
184
|
+
[
|
|
185
|
+
"opaque",
|
|
186
|
+
{ fillColor: "#ff0000" },
|
|
187
|
+
"higher"
|
|
188
|
+
],
|
|
189
|
+
[
|
|
190
|
+
"translucent",
|
|
191
|
+
{ fillColor: "rgba(255, 0, 0, 0.5)" },
|
|
192
|
+
"lower"
|
|
193
|
+
],
|
|
194
|
+
[
|
|
195
|
+
"patterned",
|
|
196
|
+
{
|
|
197
|
+
fillColor: "#ff0000",
|
|
198
|
+
fillPattern: "hachure"
|
|
199
|
+
},
|
|
200
|
+
"lower"
|
|
201
|
+
]
|
|
202
|
+
])("%s higher fills resolve only genuinely visible marks", (_name, style, expected) => {
|
|
203
|
+
const subject = createSubject();
|
|
204
|
+
const lower = line("lower");
|
|
205
|
+
const higher = polygon("higher", style);
|
|
206
|
+
subject.adapter.setLayerFeatureGroups(subject.layerId, [group("lower", lower), group("higher", higher)]);
|
|
207
|
+
expect(subject.pickAt([0, 50], [lower, higher])).toBe(expected);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function group(id, feature) {
|
|
212
|
+
return {
|
|
213
|
+
id,
|
|
214
|
+
features: [feature],
|
|
215
|
+
iconResources: []
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function line(id) {
|
|
219
|
+
return {
|
|
220
|
+
type: "Feature",
|
|
221
|
+
id: `${id}:main:0`,
|
|
222
|
+
geometry: {
|
|
223
|
+
type: "LineString",
|
|
224
|
+
coordinates: [[-100, 50], [100, 50]]
|
|
225
|
+
},
|
|
226
|
+
properties: {}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function polygon(id, style) {
|
|
230
|
+
return {
|
|
231
|
+
type: "Feature",
|
|
232
|
+
id: `${id}:main:0`,
|
|
233
|
+
geometry: {
|
|
234
|
+
type: "Polygon",
|
|
235
|
+
coordinates: [[
|
|
236
|
+
[-100, 0],
|
|
237
|
+
[100, 0],
|
|
238
|
+
[100, 100],
|
|
239
|
+
[-100, 100],
|
|
240
|
+
[-100, 0]
|
|
241
|
+
]]
|
|
242
|
+
},
|
|
243
|
+
properties: { style }
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
//#endregion
|
|
247
|
+
export { runFeatureGroupConformance, runGroupedPickConformance };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orbat-mapper/tactical-draw",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.36",
|
|
4
4
|
"description": "Engine-agnostic draw/edit core for tactical control measures (MIL-STD-2525 / APP-6). The MapAdapter ABI and bring-your-own-engine entry point for the tactical-draw package family.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Orbat Mapper",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"type": "module",
|
|
27
27
|
"exports": {
|
|
28
28
|
".": "./dist/index.mjs",
|
|
29
|
+
"./testing": "./dist/testing.mjs",
|
|
29
30
|
"./package.json": "./package.json"
|
|
30
31
|
},
|
|
31
32
|
"sideEffects": false,
|
|
@@ -38,7 +39,15 @@
|
|
|
38
39
|
"access": "public"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
|
-
"@orbat-mapper/control-measures": "^0.2.0-alpha.
|
|
42
|
+
"@orbat-mapper/control-measures": "^0.2.0-alpha.26"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"vitest": ">=4.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"vitest": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
42
51
|
},
|
|
43
52
|
"devDependencies": {
|
|
44
53
|
"@arethetypeswrong/core": "^0.18.3",
|