@orbat-mapper/tactical-draw 0.2.0-alpha.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
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-hatch
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. `"hatch"`
80
+ * draws the "/" diagonal, `"reverse-hatch"` its "\" mirror, and `"cross-hatch"` unions
81
+ * both via `Math.max` coverage. `"horizontal"`/`"vertical"` draw the
82
+ * axis-aligned families at the same spacing, and `"dots"` a square lattice of
83
+ * dots (see {@link dotCoverage}).
84
+ */
85
+ declare function makeFillPatternImage(color: string, pattern: HatchPattern, pixelRatio?: number): {
86
+ width: number;
87
+ height: number;
88
+ data: Uint8ClampedArray;
89
+ };
90
+ //#endregion
91
+ //#region src/map-adapter.d.ts
92
+ type PixelCoordinate = [number, number];
93
+ type MapCursor = "" | "default" | "crosshair" | "grab" | "grabbing" | "pointer";
94
+ interface MapEvent {
95
+ type: string;
96
+ coordinate: Position;
97
+ pixel: PixelCoordinate;
98
+ originalEvent: unknown;
99
+ }
100
+ type MapEventHandler = (event: MapEvent) => void;
101
+ interface StyleOptions {
102
+ strokeColor?: string;
103
+ strokeWidth?: number;
104
+ strokeDash?: number[];
105
+ fillColor?: string;
106
+ pointFillColor?: string;
107
+ pointRadius?: number;
108
+ text?: TextStyleOptions;
109
+ }
110
+ /**
111
+ * Text (label) style for a layer. When `labelProperty` is set, adapters also
112
+ * read per-feature label properties: `rotation` (radians, rendered as
113
+ * `π − rotation`), the size anchor's `textSizeMeters` (ground) or
114
+ * `textSizePixels` (screen) (ADR-0028), and `textAnchor` (`"start"` = text
115
+ * begins at the label point, `"end"` = text ends there; absent = centered).
116
+ * A ground-anchored label renders at
117
+ * `clamp(textSizeMeters / resolution, textSizeMinPixels, textSizeMaxPixels)`;
118
+ * a screen-anchored one at a constant `textSizePixels`, unclamped.
119
+ */
120
+ interface TextStyleOptions {
121
+ text?: string;
122
+ labelProperty?: string;
123
+ font?: string;
124
+ fillColor?: string;
125
+ strokeColor?: string;
126
+ strokeWidth?: number;
127
+ rotation?: number;
128
+ /**
129
+ * Lower edge of the ground-anchored label clamp band (ADR-0028): text never
130
+ * renders below this pixel size, however far the view zooms out.
131
+ * @default 8
132
+ */
133
+ textSizeMinPixels?: number;
134
+ /**
135
+ * Upper edge of the ground-anchored label clamp band (ADR-0028): text never
136
+ * renders above this pixel size, however far the view zooms in.
137
+ * @default 24
138
+ */
139
+ textSizeMaxPixels?: number;
140
+ }
141
+ /**
142
+ * Per-feature style for edit handles (vertex / midpoint / translate / rotate).
143
+ * The producer (`TacticalDraw`) rides this on `properties.style`; the adapter
144
+ * reads it back when rendering the handle, so it is part of the adapter
145
+ * contract rather than the tactical-draw authoring surface. `pointRadius` is a
146
+ * UI affordance not present on `ControlMeasureStyle`. See ADR-0006.
147
+ */
148
+ interface HandleStyle {
149
+ strokeColor?: string;
150
+ strokeWidth?: number;
151
+ strokeDash?: readonly number[];
152
+ fillColor?: string;
153
+ /**
154
+ * How `fillColor` paints a filled part's interior. Absent or `"solid"` is
155
+ * today's flat fill; `"hatch"`/`"reverse-hatch"`/`"cross-hatch"`/`"horizontal"`/`"vertical"`/`"dots"` request the shared pattern texture
156
+ * ({@link makeFillPatternImage}). Alpha rides in `fillColor` — there is no
157
+ * separate opacity field. Adapters that cannot rasterize a pattern degrade
158
+ * to solid. Per-feature only: layer-level `StyleOptions` has no equivalent.
159
+ */
160
+ fillPattern?: "solid" | HatchPattern;
161
+ pointRadius?: number;
162
+ }
163
+ type LayerId = string;
164
+ /** Canonical SVG resource shared by point features through `iconId`. */
165
+ interface IconResource {
166
+ id: string;
167
+ svg: string;
168
+ width: number;
169
+ height: number;
170
+ anchor: [number, number];
171
+ /**
172
+ * Direct visible mark used to rank icon picks, in unscaled canvas coordinates.
173
+ * Hits elsewhere in the full canvas measure their distance from this rectangle;
174
+ * omit it to preserve whole-canvas distance-zero behavior.
175
+ */
176
+ coreRect?: {
177
+ x: number;
178
+ y: number;
179
+ width: number;
180
+ height: number;
181
+ };
182
+ }
183
+ /**
184
+ * One stable, atomic unit in a logical layer's bottom-to-top paint stack.
185
+ *
186
+ * Features retain their renderer-defined order inside the group. An empty
187
+ * group is valid and keeps its identity and stack position.
188
+ */
189
+ interface FeatureGroup {
190
+ id: string;
191
+ features: readonly Feature[];
192
+ iconResources: readonly IconResource[];
193
+ /**
194
+ * Monotonic stamp identifying this group's rendered content, when the
195
+ * producer supplies one. Two groups with the same `id` and the same
196
+ * `revision` carry byte-identical `features` and `iconResources`, so an
197
+ * adapter may skip any diff or upload work for them. A missing `revision`
198
+ * means "unknown" — adapters must fall back to their own comparison and
199
+ * must never treat two absent stamps as equal.
200
+ */
201
+ revision?: number;
202
+ }
203
+ /**
204
+ * Click-style hit event delivered by {@link MapAdapter.onPick}.
205
+ *
206
+ * `id` is the control measure id, already split off the feature id by the
207
+ * adapter (see `controlMeasureIdFromFeature`).
208
+ *
209
+ * `originalEvent` is whatever the engine surfaces — OpenLayers normalises to
210
+ * `PointerEvent`, MapLibre delivers `MouseEvent`, Leaflet delivers
211
+ * `MouseEvent | TouchEvent`. Do not narrow to `PointerEvent` at the consumer.
212
+ */
213
+ interface PickEvent {
214
+ id: string;
215
+ feature: Feature;
216
+ pixel: PixelCoordinate;
217
+ coordinate: Position;
218
+ originalEvent: MouseEvent | PointerEvent | TouchEvent;
219
+ }
220
+ type PickHandler = (event: PickEvent) => void;
221
+ interface PickOptions {
222
+ signal?: AbortSignal;
223
+ }
224
+ interface MapAdapter {
225
+ /**
226
+ * Add a vector layer to the map.
227
+ * @returns unique layer ID
228
+ */
229
+ addVectorLayer(options?: {
230
+ style?: StyleOptions;
231
+ }): LayerId;
232
+ /**
233
+ * Remove a layer from the map.
234
+ */
235
+ removeLayer(layerId: LayerId): void;
236
+ /**
237
+ * Set the style for a specific layer.
238
+ */
239
+ setLayerStyle(layerId: LayerId, style: StyleOptions): void;
240
+ /**
241
+ * Reconcile one complete bottom-to-top desired state for a logical layer.
242
+ *
243
+ * Group IDs must be non-empty and unique within the layer. Every feature
244
+ * must have an ID that is unique across all groups in the layer. Invalid
245
+ * desired state throws synchronously before cache, resource, or engine
246
+ * mutation.
247
+ */
248
+ setLayerFeatureGroups(layerId: LayerId, groups: readonly FeatureGroup[]): void;
249
+ /**
250
+ * Subscribe to click-style "pick" events for a specific layer. Basemap and
251
+ * other-layer clicks never fire the handler.
252
+ *
253
+ * For control-measure features, adapters must set `PickEvent.id` to the
254
+ * originating control-measure id (not the rendered part's feature id).
255
+ * Features that do not satisfy the control-measure feature-id contract must
256
+ * be ignored rather than dispatched.
257
+ *
258
+ * Bound to the engine's tap-suppressing click event (OL `singleclick`,
259
+ * MapLibre `click`, Leaflet `click`) so the handler does not fire at the
260
+ * end of a pan or pinch-zoom gesture.
261
+ *
262
+ * Hit tolerance is pointer-type aware: 4 px for mouse/pen, 12 px for touch.
263
+ * Outline-only polygon interiors additionally have bounded inward selection
264
+ * reach from visible viewport-clipped boundaries: 24 px for mouse/pen and
265
+ * 36 px for touch. A visible solid or patterned fill owns its full interior.
266
+ * For grouped desired state, comparable hits follow authoritative group
267
+ * order even when the engine reports another native order. A fully opaque
268
+ * solid fill in a higher group suppresses lower candidates beneath its
269
+ * interior; translucent and patterned fills leave visible lower marks
270
+ * eligible.
271
+ *
272
+ * Returns an unsubscribe function. The returned function and the optional
273
+ * `signal` are both honoured; either teardown path stops further events,
274
+ * and both are idempotent.
275
+ */
276
+ onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
277
+ /**
278
+ * Map Event Listeners
279
+ *
280
+ * Ordering contract: a `"click"` handler and the `onPick` handlers for the
281
+ * same user click MUST observe the pick first — adapters deliver picks and
282
+ * `"click"` from the same underlying engine event, in registration order.
283
+ * The draw/edit controllers register their pick subscriptions before their
284
+ * click handler and read the stashed pick inside it (click-away close /
285
+ * close-then-pick re-dispatch); an adapter that fires `"click"` from an
286
+ * earlier engine event than its picks breaks that contract (e.g. OL
287
+ * `click` vs `singleclick`).
288
+ */
289
+ on(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
290
+ off(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
291
+ /**
292
+ * Projection / Coordinate Utilities
293
+ */
294
+ toLonLat(coordinate: number[]): Position;
295
+ fromLonLat(position: Position): number[];
296
+ getPixelFromCoordinate(position: Position): PixelCoordinate | null;
297
+ /**
298
+ * Convert a viewport pixel to longitude/latitude. Optional so third-party
299
+ * adapters compiled against an older ABI remain valid.
300
+ */
301
+ getCoordinateFromPixel?(pixel: PixelCoordinate): Position | null;
302
+ /**
303
+ * Get current viewport size in CSS pixels.
304
+ */
305
+ getViewportSize(): {
306
+ width: number;
307
+ height: number;
308
+ };
309
+ /**
310
+ * Pan the map by pixel delta.
311
+ * Positive delta means desired on-screen feature shift (right/down).
312
+ */
313
+ panByPixels(delta: PixelCoordinate, options?: {
314
+ durationMs?: number;
315
+ }): void;
316
+ /**
317
+ * Set the map viewport cursor. Pass an empty string to restore engine default styling.
318
+ */
319
+ setCursor(cursor: MapCursor): void;
320
+ /**
321
+ * Toggle the engine's native double-click-to-zoom gesture. `TacticalDraw`
322
+ * disables this for the lifetime of a variable-length draw so the commit
323
+ * `dblclick` doesn't also zoom the map.
324
+ */
325
+ setDoubleClickZoomEnabled(enabled: boolean): void;
326
+ /**
327
+ * Get current view resolution (meters per pixel at center or generally).
328
+ * returns undefined if not supported or not ready.
329
+ */
330
+ getResolution(): number | undefined;
331
+ /**
332
+ * Get current map zoom level
333
+ */
334
+ getZoom(): number | undefined;
335
+ /**
336
+ * Get current view bearing, radians — the clockwise-from-north compass
337
+ * direction that screen-up points to (MapLibre-bearing semantics). `0` for
338
+ * engines/views that don't rotate.
339
+ */
340
+ getBearing(): number;
341
+ /**
342
+ * Listen for view changes (zoom/resolution)
343
+ */
344
+ onViewChange(handler: () => void): void;
345
+ offViewChange(handler: () => void): void;
346
+ /**
347
+ * The engine evaluates the meters→pixels clamp for ground-sized point
348
+ * symbols inside its own style layer, so meters-sized point symbols don't
349
+ * need view-change re-renders for size.
350
+ */
351
+ readonly scalesGroundPointSymbolsNatively?: boolean;
352
+ /**
353
+ * Construct an {@link EditPointerDriver} for `TacticalDraw`'s edit
354
+ * controller. The controller subscribes to pointer events on the returned
355
+ * driver to wire handle drags (vertex / midpoint / translate / rotate).
356
+ */
357
+ createEditPointerDriver(): EditPointerDriver;
358
+ /**
359
+ * Release all adapter-owned map resources. Adapters must not be used after
360
+ * destruction.
361
+ */
362
+ destroy(): void;
363
+ /**
364
+ * Returns the features currently set on the layer — the adapter-side cache
365
+ * of the last grouped mutation, flattened in bottom-to-top group
366
+ * order. Empty array for an unknown layer. The private ordered group cache is
367
+ * not exposed. Callers must not mutate the returned array or features.
368
+ */
369
+ getLayerFeatures(layerId: LayerId): readonly Feature[];
370
+ }
371
+ //#endregion
372
+ 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-84irgVJR.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 };
@@ -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: "hatch"
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.9",
3
+ "version": "0.2.0",
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.8"
42
+ "@orbat-mapper/control-measures": "^0.2.0"
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",