@orbat-mapper/tactical-draw 0.2.0-alpha.29 → 0.2.0-alpha.30

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 CHANGED
@@ -739,6 +739,27 @@ interface InteractionStyle {
739
739
  type EditMode = "reshape" | "transform" | "labeldrag" | "delete";
740
740
  //#endregion
741
741
  //#region src/tactical-draw/session-events.d.ts
742
+ /** A gesture's phase within a stream of {@link TransientChangeEvent}s. */
743
+ type TransientPhase = "start" | "move" | "end";
744
+ /**
745
+ * An in-flight mutation of a graphic's working geometry during a gesture —
746
+ * streamed continuously while the gesture runs (`EditSession.onTransientChange`
747
+ * / `DrawSession.onTransientChange`), distinct from the once-per-completed-
748
+ * gesture `onChange`. Hosts use it for live feedback, e.g. a radius readout
749
+ * while dragging a circle handle.
750
+ */
751
+ interface TransientChangeEvent {
752
+ /** Gesture phase. `end` fires exactly once per started gesture — on commit AND on cancel/abort. */
753
+ readonly phase: TransientPhase;
754
+ /** The full working control points at this instant (lon/lat). Fresh array per emission. */
755
+ readonly points: readonly Position[];
756
+ /**
757
+ * Index of the live point in `points` — the dragged vertex in edit, the
758
+ * rubber-band point in draw — or `null` when the whole geometry moves at
759
+ * once (box translate / rotate / scale).
760
+ */
761
+ readonly pointIndex: number | null;
762
+ }
742
763
  /**
743
764
  * The reason a session (`TransformSession`, `EditSession`, or `DrawSession`)
744
765
  * settled, delivered to that session's `onSettled` handler (#191 / #194 /
@@ -912,6 +933,28 @@ interface EditSession {
912
933
  * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
913
934
  */
914
935
  onSettled(handler: (reason: SettleReason) => void): () => void;
936
+ /**
937
+ * Subscribe to the continuous stream of in-flight geometry mutations —
938
+ * vertex/midpoint drags and box scale/rotate/translate gestures — fired
939
+ * throughout the gesture rather than once at its end (contrast
940
+ * [[onChange]]). `start` fires at gesture begin, `move` on every pointer
941
+ * tick, and `end` fires exactly once when the gesture finishes by any
942
+ * path: commit, a click-slop no-op (a box body click that never becomes a
943
+ * drag), or session abort/teardown mid-drag. `end` fires **before** the
944
+ * gesture's [[onChange]] emission (and, on the commit path, before
945
+ * [[onCommit]]/[[onSettled]] too), so a listener observing both channels
946
+ * sees the transient stream close first.
947
+ *
948
+ * Label-placement drags (`labeldrag` mode) do not fire this — they move
949
+ * amplifier placements, not control points.
950
+ *
951
+ * Listener exceptions are collected; after every listener has run, they
952
+ * are rethrown (one if single, `AggregateError` otherwise) — mirrors
953
+ * [[onChange]]. Returns an unsubscribe function; registering after the
954
+ * session has settled returns a no-op unsubscribe and the listener never
955
+ * fires.
956
+ */
957
+ onTransientChange(listener: (event: TransientChangeEvent) => void): () => void;
915
958
  }
916
959
  /**
917
960
  * Delivered to `EditSession.onChange` listeners once per completed gesture.
@@ -1264,6 +1307,24 @@ interface DrawSession {
1264
1307
  * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
1265
1308
  */
1266
1309
  onSettled(handler: (reason: SettleReason) => void): () => void;
1310
+ /**
1311
+ * Subscribe to the continuous stream of rubber-band pointer ticks while the
1312
+ * draw is live — contrast [[onChange]], which fires once per committed
1313
+ * point. `start` fires on the first rubber-band tick, `move` on every
1314
+ * subsequent tick; a click that commits a point does not itself emit — the
1315
+ * next rubber-band tick after it does. `end` fires exactly once, when the
1316
+ * session settles (commit or abort).
1317
+ *
1318
+ * `points` is the committed points with the live rubber-band point spliced
1319
+ * in at its insertion index; `pointIndex` is that index.
1320
+ *
1321
+ * Listener exceptions are collected; after every listener has run, they
1322
+ * are rethrown (one if single, `AggregateError` otherwise) — mirrors
1323
+ * [[onChange]]. Returns an unsubscribe function; registering after the
1324
+ * session has settled returns a no-op unsubscribe and the listener never
1325
+ * fires.
1326
+ */
1327
+ onTransientChange(listener: (event: TransientChangeEvent) => void): () => void;
1267
1328
  }
1268
1329
  //#endregion
1269
1330
  //#region src/tactical-draw/snapping.d.ts
@@ -1465,9 +1526,13 @@ type DrawMeasureDraft<K extends ControlMeasureKind = ControlMeasureKind> = {
1465
1526
  interface DrawOptions {
1466
1527
  signal?: AbortSignal;
1467
1528
  /**
1468
- * Called synchronously once when a variable-length draw starts, before any
1469
- * pointer event is processed. The session lets a host wire a Done/Cancel
1470
- * toolbar to the active draw. Ignored for fixed-length kinds.
1529
+ * Called synchronously once when a draw starts, before any pointer event
1530
+ * is processed. The session lets a host wire a Done/Cancel toolbar to the
1531
+ * active draw and subscribe to live control-point / transient-geometry
1532
+ * feedback. Surfaced for both variable- and fixed-length kinds — a
1533
+ * fixed-length session auto-commits on the last required click, so its
1534
+ * main value is `onTransientChange` (e.g. a live radius readout) and
1535
+ * `abort()`.
1471
1536
  */
1472
1537
  onSession?: (session: DrawSession) => void;
1473
1538
  /**
@@ -1666,17 +1731,19 @@ declare class TacticalDraw {
1666
1731
  */
1667
1732
  private trackTransformSession;
1668
1733
  /**
1669
- * Start an interactive draw. Fixed-length kinds commit automatically on the
1670
- * last required click. Variable-length kinds surface a `DrawSession` via
1671
- * `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
1672
- * click on the last added control point. All paths reject with
1673
- * `TacticalDrawAbortError` on signal / Escape / destroy / `session.abort()`.
1734
+ * Start an interactive draw. Both fixed- and variable-length kinds surface
1735
+ * a `DrawSession` via `opts.onSession`. Fixed-length kinds commit
1736
+ * automatically on the last required click; variable-length kinds commit
1737
+ * on `session.commit()`, `dblclick`, or a second click on the last added
1738
+ * control point. All paths reject with `TacticalDrawAbortError` on
1739
+ * signal / Escape / destroy / `session.abort()`.
1674
1740
  *
1675
- * For a variable-length draw, `DrawSession.onCommit` is the single delivery
1676
- * channel for the committed snapshot and `DrawSession.onSettled` is the
1677
- * lifecycle signal for when the session is over and why (#197) — together
1678
- * they make the returned promise non-load-bearing for hosts that adopt them.
1679
- * Fixed-length kinds have no session and settle only through the promise.
1741
+ * `DrawSession.onCommit` is the single delivery channel for the committed
1742
+ * snapshot and `DrawSession.onSettled` is the lifecycle signal for when the
1743
+ * session is over and why (#197) — together they make the returned promise
1744
+ * non-load-bearing for hosts that adopt them. `DrawSession.onTransientChange`
1745
+ * streams live rubber-band geometry while the draw is in flight, for both
1746
+ * kinds of session.
1680
1747
  */
1681
1748
  draw(draft: DrawPointSymbolDraft, options?: DrawOptions): Promise<GraphicSnapshot<PointSymbol>>;
1682
1749
  draw<K extends ControlMeasureKind>(draft: DrawMeasureDraft<K>, options?: DrawOptions): Promise<GraphicSnapshot<ControlMeasure<K>>>;
@@ -1964,4 +2031,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
1964
2031
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
1965
2032
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
1966
2033
  //#endregion
1967
- export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GuideStyle, HATCH_PATTERNS, type HandleStyle, type HatchPattern, type IconResource, type InteractionHit, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
2034
+ export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GuideStyle, HATCH_PATTERNS, type HandleStyle, type HatchPattern, type IconResource, type InteractionHit, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, type TransientChangeEvent, type TransientPhase, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
package/dist/index.mjs CHANGED
@@ -1220,156 +1220,6 @@ function createSnapResolver(adapter, getGraphics, getGeometryFeatures, getOption
1220
1220
  };
1221
1221
  }
1222
1222
  //#endregion
1223
- //#region src/tactical-draw/draw-controller.ts
1224
- const PREVIEW_CM_ID$1 = "__td_preview__";
1225
- const PREVIEW_CURSOR$1 = "crosshair";
1226
- /**
1227
- * Drive an interactive fixed-length draw against a `MapAdapter`.
1228
- *
1229
- * Caller is the `TacticalDraw` façade. The controller owns:
1230
- * - control-point accumulation via `click`
1231
- * - rubber-band preview via `pointermove`
1232
- * - Escape cancellation via `keydown`
1233
- * - cursor capture / restore for the duration of the draw
1234
- * - host `AbortSignal` plumbing and reason mapping
1235
- *
1236
- * Auto-commits when the user clicks the final required point — fixed-length
1237
- * kinds need no host UI.
1238
- */
1239
- function runFixedLengthDraw(options) {
1240
- const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled, snap } = options;
1241
- const metadata = getControlMeasureMetadata(kind);
1242
- const rule = metadata.rule;
1243
- const min = metadata.minCoordinates;
1244
- const max = metadata.maxCoordinates;
1245
- let required;
1246
- if (rule) required = rule.minimumUserPoints;
1247
- else if (typeof min === "number" && typeof max === "number" && min === max) required = min;
1248
- else throw new Error(`runFixedLengthDraw: kind "${kind}" is not fixed-length (min=${min}, max=${max})`);
1249
- const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
1250
- const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
1251
- const isAreaKind = metadata.geometry === "area";
1252
- const committed = [];
1253
- let rubberBand;
1254
- let ctx;
1255
- const savedCursor = "";
1256
- const handleClick = (event) => {
1257
- if (ctx.settled) return;
1258
- committed.push(snappedDrawPosition(snap, event));
1259
- rubberBand = void 0;
1260
- if (committed.length >= required) {
1261
- ctx.commit(buildSnapshot);
1262
- return;
1263
- }
1264
- renderPreview();
1265
- };
1266
- const handlePointerMove = (event) => {
1267
- if (ctx.settled) return;
1268
- if (committed.length === 0) return;
1269
- rubberBand = snappedDrawPosition(snap, event);
1270
- renderPreview();
1271
- };
1272
- function renderPreview() {
1273
- const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
1274
- if (raw.length === 0) {
1275
- ctx.writeLayer(previewLayer, []);
1276
- ctx.writeLayer(guideLayer, []);
1277
- return;
1278
- }
1279
- const previewThreshold = rule?.minimumPreviewPoints ?? required;
1280
- const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, measureTextAmplifiers, renderOpts);
1281
- ctx.writeLayer(previewLayer, features);
1282
- const guidePoints = rule?.guidePoints ? rule.guidePoints(raw) : raw;
1283
- const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind) : [];
1284
- ctx.writeLayer(guideLayer, guideFeatures);
1285
- }
1286
- function rescaleOnViewChange() {
1287
- if (!measureRendersFromViewState({
1288
- kind,
1289
- options: measureOptions
1290
- })) return;
1291
- renderPreview();
1292
- }
1293
- function buildSnapshot() {
1294
- const finalPoints = roundControlPoints(rule ? rule.derive(committed) : committed);
1295
- const captured = withCommittedAdapterMeasureOptions({
1296
- id: generateId(),
1297
- kind,
1298
- controlPoints: finalPoints,
1299
- ...measureOptions !== void 0 ? { options: measureOptions } : {},
1300
- ...measureStyle !== void 0 ? { style: measureStyle } : {},
1301
- ...measureProperties !== void 0 ? { properties: measureProperties } : {},
1302
- ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1303
- });
1304
- const measure = sizeAnchor === "screen" ? captured : bakePixelSizeToGround(captured, adapter);
1305
- return {
1306
- graphic: cloneControlMeasure(measure),
1307
- render: renderMeasureForAdapter(adapter, measure, renderOpts)
1308
- };
1309
- }
1310
- return runInteraction({
1311
- adapter,
1312
- signal,
1313
- layers: [previewLayer, guideLayer],
1314
- onViewChange: rescaleOnViewChange,
1315
- onSettled,
1316
- attach(c) {
1317
- ctx = c;
1318
- c.onTeardown(() => {
1319
- adapter.off("click", handleClick);
1320
- adapter.off("pointermove", handlePointerMove);
1321
- adapter.setCursor(savedCursor);
1322
- });
1323
- adapter.setCursor(PREVIEW_CURSOR$1);
1324
- adapter.on("click", handleClick);
1325
- adapter.on("pointermove", handlePointerMove);
1326
- }
1327
- });
1328
- }
1329
- /**
1330
- * Produce preview features for the partial geometry. Tries the full renderer
1331
- * first so the preview looks like the real symbol once enough points are in;
1332
- * falls back to a plain Point/LineString outline when the kind's generator
1333
- * cannot produce a shape yet (under-min count).
1334
- */
1335
- function buildPreviewFeatures$1(adapter, kind, points, measureOptions, measureStyle, measureTextAmplifiers, renderOpts) {
1336
- const measure = {
1337
- id: PREVIEW_CM_ID$1,
1338
- kind,
1339
- controlPoints: points,
1340
- ...measureOptions !== void 0 ? { options: measureOptions } : {},
1341
- ...measureStyle !== void 0 ? { style: measureStyle } : {},
1342
- ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1343
- };
1344
- try {
1345
- return renderMeasureForAdapter(adapter, measure, {
1346
- ...renderOpts,
1347
- validationMode: "throw"
1348
- }).features;
1349
- } catch {
1350
- return outlinePreview$1(points);
1351
- }
1352
- }
1353
- function outlinePreview$1(points) {
1354
- const features = [];
1355
- for (let i = 0; i < points.length; i++) {
1356
- const pt = {
1357
- type: "Feature",
1358
- id: `${PREVIEW_CM_ID$1}:vertex:${i}`,
1359
- geometry: {
1360
- type: "Point",
1361
- coordinates: points[i]
1362
- },
1363
- properties: {
1364
- part: "vertex",
1365
- index: i
1366
- }
1367
- };
1368
- features.push(pt);
1369
- }
1370
- return features;
1371
- }
1372
- //#endregion
1373
1223
  //#region src/tactical-draw/session-events.ts
1374
1224
  /**
1375
1225
  * Map a settle-time error to its `SettleReason`: a `TacticalDrawAbortError`
@@ -1514,10 +1364,13 @@ function createChangeEmitter(options) {
1514
1364
  };
1515
1365
  }
1516
1366
  /**
1517
- * Subscribe/emit machinery shared by `TransformSession.onCommit`,
1518
- * `EditSession.onCommit`, and `DrawSession.onCommit`: a listener set with
1519
- * synchronous, exception-collecting emission (single rethrow, `AggregateError`
1520
- * otherwise). Unlike `createSettleEmitter`, `emit` may be called more than
1367
+ * Channel-neutral subscribe/emit machinery: a listener set with synchronous,
1368
+ * exception-collecting emission (single rethrow, `AggregateError` otherwise).
1369
+ * Named for its original `onCommit` channel (`TransformSession` /
1370
+ * `EditSession` / `DrawSession`), it also carries the `onTransientChange`
1371
+ * streams and the fixed/variable draw controllers' `onChange` — any
1372
+ * multi-emit channel with no latch. Unlike `createSettleEmitter`, `emit` may
1373
+ * be called more than
1521
1374
  * once — a transform's `setGraphics` can commit several removal batches before
1522
1375
  * the close batch — and there is no latch or late-registration path:
1523
1376
  * registration-time `ctx.settled` guards stay in the controllers, mirroring
@@ -1546,6 +1399,232 @@ function createCommitEmitter(listenerErrorsMessage) {
1546
1399
  };
1547
1400
  }
1548
1401
  //#endregion
1402
+ //#region src/tactical-draw/draw-controller.ts
1403
+ const PREVIEW_CM_ID$1 = "__td_preview__";
1404
+ const PREVIEW_CURSOR$1 = "crosshair";
1405
+ /**
1406
+ * Drive an interactive fixed-length draw against a `MapAdapter`.
1407
+ *
1408
+ * Caller is the `TacticalDraw` façade. The controller owns:
1409
+ * - control-point accumulation via `click`
1410
+ * - rubber-band preview via `pointermove`
1411
+ * - Escape cancellation via `keydown`
1412
+ * - cursor capture / restore for the duration of the draw
1413
+ * - host `AbortSignal` plumbing and reason mapping
1414
+ *
1415
+ * Auto-commits when the user clicks the final required point — fixed-length
1416
+ * kinds need no host UI.
1417
+ */
1418
+ function runFixedLengthDraw(options) {
1419
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled, onSession, snap } = options;
1420
+ const metadata = getControlMeasureMetadata(kind);
1421
+ const rule = metadata.rule;
1422
+ const min = metadata.minCoordinates;
1423
+ const max = metadata.maxCoordinates;
1424
+ let required;
1425
+ if (rule) required = rule.minimumUserPoints;
1426
+ else if (typeof min === "number" && typeof max === "number" && min === max) required = min;
1427
+ else throw new Error(`runFixedLengthDraw: kind "${kind}" is not fixed-length (min=${min}, max=${max})`);
1428
+ const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
1429
+ const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
1430
+ const isAreaKind = metadata.geometry === "area";
1431
+ const committed = [];
1432
+ let rubberBand;
1433
+ let ctx;
1434
+ const savedCursor = "";
1435
+ const changeEmitter = createCommitEmitter("DrawSession.onChange listener errors");
1436
+ const commitEmitter = createCommitEmitter("DrawSession.onCommit listener errors");
1437
+ const settleEmitter = createSettleEmitter("DrawSession.onSettled listener errors");
1438
+ const transientEmitter = createCommitEmitter("DrawSession.onTransientChange listener errors");
1439
+ /** Latch: `true` once the first rubber-band tick has fired `"start"`, until `"end"` fires on settle. */
1440
+ let transientStarted = false;
1441
+ function canCommitNow() {
1442
+ return !ctx.settled && committed.length >= required;
1443
+ }
1444
+ const session = {
1445
+ get controlPoints() {
1446
+ return committed.map((p) => [...p]);
1447
+ },
1448
+ get canCommit() {
1449
+ return canCommitNow();
1450
+ },
1451
+ get minControlPoints() {
1452
+ return required;
1453
+ },
1454
+ get maxControlPoints() {
1455
+ return required;
1456
+ },
1457
+ commit() {
1458
+ if (!canCommitNow()) return false;
1459
+ ctx.commit(buildSnapshot);
1460
+ return true;
1461
+ },
1462
+ abort() {
1463
+ if (ctx.settled) return;
1464
+ ctx.fail(new TacticalDrawAbortError("session"));
1465
+ },
1466
+ onChange(listener) {
1467
+ if (ctx.settled) return () => {};
1468
+ return changeEmitter.register(listener);
1469
+ },
1470
+ onCommit(listener) {
1471
+ if (ctx.settled) return () => {};
1472
+ return commitEmitter.register(listener);
1473
+ },
1474
+ onSettled(handler) {
1475
+ return settleEmitter.register(handler);
1476
+ },
1477
+ onTransientChange(listener) {
1478
+ if (ctx.settled) return () => {};
1479
+ return transientEmitter.register(listener);
1480
+ }
1481
+ };
1482
+ function fireChange() {
1483
+ if (ctx.settled) return;
1484
+ changeEmitter.emit(session);
1485
+ }
1486
+ const handleClick = (event) => {
1487
+ if (ctx.settled) return;
1488
+ committed.push(snappedDrawPosition(snap, event));
1489
+ rubberBand = void 0;
1490
+ if (committed.length >= required) {
1491
+ ctx.commit(buildSnapshot);
1492
+ return;
1493
+ }
1494
+ renderPreview();
1495
+ try {
1496
+ fireChange();
1497
+ } catch (err) {
1498
+ ctx.fail(err);
1499
+ }
1500
+ };
1501
+ const handlePointerMove = (event) => {
1502
+ if (ctx.settled) return;
1503
+ if (committed.length === 0) return;
1504
+ rubberBand = snappedDrawPosition(snap, event);
1505
+ renderPreview();
1506
+ const points = [...committed, rubberBand];
1507
+ const phase = transientStarted ? "move" : "start";
1508
+ transientStarted = true;
1509
+ transientEmitter.emit({
1510
+ phase,
1511
+ points,
1512
+ pointIndex: committed.length
1513
+ });
1514
+ };
1515
+ function renderPreview() {
1516
+ const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
1517
+ if (raw.length === 0) {
1518
+ ctx.writeLayer(previewLayer, []);
1519
+ ctx.writeLayer(guideLayer, []);
1520
+ return;
1521
+ }
1522
+ const previewThreshold = rule?.minimumPreviewPoints ?? required;
1523
+ const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, measureTextAmplifiers, renderOpts);
1524
+ ctx.writeLayer(previewLayer, features);
1525
+ const guidePoints = rule?.guidePoints ? rule.guidePoints(raw) : raw;
1526
+ const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind) : [];
1527
+ ctx.writeLayer(guideLayer, guideFeatures);
1528
+ }
1529
+ function rescaleOnViewChange() {
1530
+ if (!measureRendersFromViewState({
1531
+ kind,
1532
+ options: measureOptions
1533
+ })) return;
1534
+ renderPreview();
1535
+ }
1536
+ function buildSnapshot() {
1537
+ const finalPoints = roundControlPoints(rule ? rule.derive(committed) : committed);
1538
+ const captured = withCommittedAdapterMeasureOptions({
1539
+ id: generateId(),
1540
+ kind,
1541
+ controlPoints: finalPoints,
1542
+ ...measureOptions !== void 0 ? { options: measureOptions } : {},
1543
+ ...measureStyle !== void 0 ? { style: measureStyle } : {},
1544
+ ...measureProperties !== void 0 ? { properties: measureProperties } : {},
1545
+ ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1546
+ });
1547
+ const measure = sizeAnchor === "screen" ? captured : bakePixelSizeToGround(captured, adapter);
1548
+ return {
1549
+ graphic: cloneControlMeasure(measure),
1550
+ render: renderMeasureForAdapter(adapter, measure, renderOpts)
1551
+ };
1552
+ }
1553
+ return runInteraction({
1554
+ adapter,
1555
+ signal,
1556
+ layers: [previewLayer, guideLayer],
1557
+ onViewChange: rescaleOnViewChange,
1558
+ onSettled: (settlement) => {
1559
+ if (onSettled) onSettled();
1560
+ if (transientStarted) {
1561
+ transientStarted = false;
1562
+ transientEmitter.emit({
1563
+ phase: "end",
1564
+ points: committed.map((p) => [...p]),
1565
+ pointIndex: null
1566
+ });
1567
+ }
1568
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
1569
+ },
1570
+ attach(c) {
1571
+ ctx = c;
1572
+ c.onTeardown(() => {
1573
+ adapter.off("click", handleClick);
1574
+ adapter.off("pointermove", handlePointerMove);
1575
+ adapter.setCursor(savedCursor);
1576
+ });
1577
+ adapter.setCursor(PREVIEW_CURSOR$1);
1578
+ adapter.on("click", handleClick);
1579
+ adapter.on("pointermove", handlePointerMove);
1580
+ if (onSession) onSession(session);
1581
+ }
1582
+ });
1583
+ }
1584
+ /**
1585
+ * Produce preview features for the partial geometry. Tries the full renderer
1586
+ * first so the preview looks like the real symbol once enough points are in;
1587
+ * falls back to a plain Point/LineString outline when the kind's generator
1588
+ * cannot produce a shape yet (under-min count).
1589
+ */
1590
+ function buildPreviewFeatures$1(adapter, kind, points, measureOptions, measureStyle, measureTextAmplifiers, renderOpts) {
1591
+ const measure = {
1592
+ id: PREVIEW_CM_ID$1,
1593
+ kind,
1594
+ controlPoints: points,
1595
+ ...measureOptions !== void 0 ? { options: measureOptions } : {},
1596
+ ...measureStyle !== void 0 ? { style: measureStyle } : {},
1597
+ ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1598
+ };
1599
+ try {
1600
+ return renderMeasureForAdapter(adapter, measure, {
1601
+ ...renderOpts,
1602
+ validationMode: "throw"
1603
+ }).features;
1604
+ } catch {
1605
+ return outlinePreview$1(points);
1606
+ }
1607
+ }
1608
+ function outlinePreview$1(points) {
1609
+ const features = [];
1610
+ for (let i = 0; i < points.length; i++) {
1611
+ const pt = {
1612
+ type: "Feature",
1613
+ id: `${PREVIEW_CM_ID$1}:vertex:${i}`,
1614
+ geometry: {
1615
+ type: "Point",
1616
+ coordinates: points[i]
1617
+ },
1618
+ properties: {
1619
+ part: "vertex",
1620
+ index: i
1621
+ }
1622
+ };
1623
+ features.push(pt);
1624
+ }
1625
+ return features;
1626
+ }
1627
+ //#endregion
1549
1628
  //#region src/tactical-draw/draw-controller-variable.ts
1550
1629
  const PREVIEW_CM_ID = "__td_preview__";
1551
1630
  const PREVIEW_CURSOR = "crosshair";
@@ -1603,9 +1682,12 @@ function runVariableLengthDraw(options) {
1603
1682
  let rubberBand;
1604
1683
  let ctx;
1605
1684
  const savedCursor = "";
1606
- const changeListeners = /* @__PURE__ */ new Set();
1685
+ const changeEmitter = createCommitEmitter("DrawSession.onChange listener errors");
1607
1686
  const commitEmitter = createCommitEmitter("DrawSession.onCommit listener errors");
1608
1687
  const settleEmitter = createSettleEmitter("DrawSession.onSettled listener errors");
1688
+ const transientEmitter = createCommitEmitter("DrawSession.onTransientChange listener errors");
1689
+ /** Latch: `true` once the first rubber-band tick has fired `"start"`, until `"end"` fires on settle. */
1690
+ let transientStarted = false;
1609
1691
  function canCommitNow() {
1610
1692
  const n = committed.length;
1611
1693
  if (typeof max === "number" && n > max) return false;
@@ -1638,10 +1720,7 @@ function runVariableLengthDraw(options) {
1638
1720
  },
1639
1721
  onChange(listener) {
1640
1722
  if (ctx.settled) return () => {};
1641
- changeListeners.add(listener);
1642
- return () => {
1643
- changeListeners.delete(listener);
1644
- };
1723
+ return changeEmitter.register(listener);
1645
1724
  },
1646
1725
  onCommit(listener) {
1647
1726
  if (ctx.settled) return () => {};
@@ -1649,18 +1728,15 @@ function runVariableLengthDraw(options) {
1649
1728
  },
1650
1729
  onSettled(handler) {
1651
1730
  return settleEmitter.register(handler);
1731
+ },
1732
+ onTransientChange(listener) {
1733
+ if (ctx.settled) return () => {};
1734
+ return transientEmitter.register(listener);
1652
1735
  }
1653
1736
  };
1654
1737
  function fireChange() {
1655
1738
  if (ctx.settled) return;
1656
- const errors = [];
1657
- for (const listener of [...changeListeners]) try {
1658
- listener(session);
1659
- } catch (err) {
1660
- errors.push(err);
1661
- }
1662
- if (errors.length === 1) throw errors[0];
1663
- if (errors.length > 1) throw new AggregateError(errors, "DrawSession.onChange listener errors");
1739
+ changeEmitter.emit(session);
1664
1740
  }
1665
1741
  const handleClick = (event) => {
1666
1742
  if (ctx.settled) return;
@@ -1707,6 +1783,16 @@ function runVariableLengthDraw(options) {
1707
1783
  if (committed.length === 0) return;
1708
1784
  rubberBand = snappedDrawPosition(snap, event);
1709
1785
  renderPreview();
1786
+ const idx = insertionIndex(committed.length);
1787
+ const points = [...committed];
1788
+ points.splice(idx, 0, rubberBand);
1789
+ const phase = transientStarted ? "move" : "start";
1790
+ transientStarted = true;
1791
+ transientEmitter.emit({
1792
+ phase,
1793
+ points,
1794
+ pointIndex: idx
1795
+ });
1710
1796
  };
1711
1797
  function renderPreview() {
1712
1798
  let points;
@@ -1751,6 +1837,14 @@ function runVariableLengthDraw(options) {
1751
1837
  onViewChange: rescaleOnViewChange,
1752
1838
  onSettled: (settlement) => {
1753
1839
  if (onSettled) onSettled();
1840
+ if (transientStarted) {
1841
+ transientStarted = false;
1842
+ transientEmitter.emit({
1843
+ phase: "end",
1844
+ points: committed.map((p) => [...p]),
1845
+ pointIndex: null
1846
+ });
1847
+ }
1754
1848
  settleFromInteraction(settlement, commitEmitter, settleEmitter);
1755
1849
  },
1756
1850
  attach(c) {
@@ -3065,6 +3159,35 @@ function runEdit(opts) {
3065
3159
  });
3066
3160
  const commitEmitter = createCommitEmitter("EditSession.onCommit listener errors");
3067
3161
  const settleEmitter = createSettleEmitter("EditSession.onSettled listener errors");
3162
+ const transientEmitter = createCommitEmitter("EditSession.onTransientChange listener errors");
3163
+ /**
3164
+ * Latch for the "start"/"end" pairing `emitTransient` enforces: `start`
3165
+ * no-ops (and doesn't re-latch) while a gesture is already open; `end`
3166
+ * no-ops when there is no open gesture. `move` always passes through — the
3167
+ * latch only guards against a duplicate/missing bookend, per the
3168
+ * `onTransientChange` contract (`end` fires exactly once per started
3169
+ * gesture).
3170
+ */
3171
+ let transientGestureActive = false;
3172
+ /**
3173
+ * Emit one tick of the `EditSession.onTransientChange` stream. `points` is
3174
+ * defensively copied so a listener can't observe (or corrupt) the live
3175
+ * working-point array.
3176
+ */
3177
+ function emitTransient(phase, points, pointIndex) {
3178
+ if (phase === "start") {
3179
+ if (transientGestureActive) return;
3180
+ transientGestureActive = true;
3181
+ } else if (phase === "end") {
3182
+ if (!transientGestureActive) return;
3183
+ transientGestureActive = false;
3184
+ }
3185
+ transientEmitter.emit({
3186
+ phase,
3187
+ points: points.map((p) => [...p]),
3188
+ pointIndex
3189
+ });
3190
+ }
3068
3191
  /**
3069
3192
  * The transform box's rotation, in radians, session-scoped (ADR-0017 decision
3070
3193
  * #2): starts at `0` (axis-aligned) and accumulates every committed rotate
@@ -3616,6 +3739,10 @@ function runEdit(opts) {
3616
3739
  },
3617
3740
  onSettled(handler) {
3618
3741
  return settleEmitter.register(handler);
3742
+ },
3743
+ onTransientChange(listener) {
3744
+ if (ctx.settled) return () => {};
3745
+ return transientEmitter.register(listener);
3619
3746
  }
3620
3747
  };
3621
3748
  let activeDrag = null;
@@ -3735,6 +3862,7 @@ function runEdit(opts) {
3735
3862
  preDragPoints,
3736
3863
  controlPointIndex: hit.kind === "midpoint" ? hit.index + 1 : hit.index
3737
3864
  };
3865
+ emitTransient("start", fsm.kind === "midpoint" ? initialPoints : preDragPoints, activeDrag.controlPointIndex);
3738
3866
  if (fsm.kind === "midpoint") {
3739
3867
  workingPoints.splice(0, workingPoints.length, ...initialPoints);
3740
3868
  renderPreview();
@@ -3773,6 +3901,7 @@ function runEdit(opts) {
3773
3901
  },
3774
3902
  preDragOptions
3775
3903
  };
3904
+ emitTransient("start", preDragPoints, null);
3776
3905
  dragInFlight = true;
3777
3906
  driver.setDragPanEnabled(false);
3778
3907
  }
@@ -3786,6 +3915,7 @@ function runEdit(opts) {
3786
3915
  renderPreview();
3787
3916
  renderHandlesLayer();
3788
3917
  recomputeDirty();
3918
+ emitTransient("move", snapshot.points, activeDrag.controlPointIndex);
3789
3919
  }
3790
3920
  function paintBoxDragSnapshot(pixel, info, constrainRotation = false) {
3791
3921
  if (!activeBoxDrag) return;
@@ -3797,6 +3927,7 @@ function runEdit(opts) {
3797
3927
  renderPreview();
3798
3928
  renderHandlesLayer();
3799
3929
  recomputeDirty();
3930
+ emitTransient("move", snapshot.points, null);
3800
3931
  }
3801
3932
  const onDriverMove = (pixel, info) => {
3802
3933
  if (ctx.settled) return;
@@ -3836,6 +3967,7 @@ function runEdit(opts) {
3836
3967
  }
3837
3968
  if (activeDrag) {
3838
3969
  if (ctx.settled) {
3970
+ emitTransient("end", workingPoints, activeDrag.controlPointIndex);
3839
3971
  activeDrag = null;
3840
3972
  dragInFlight = false;
3841
3973
  driver.setDragPanEnabled(true);
@@ -3845,12 +3977,14 @@ function runEdit(opts) {
3845
3977
  activeDrag = null;
3846
3978
  driver.setDragPanEnabled(true);
3847
3979
  const next = fsm.end(snappedPixel(pixel, info, "reshape", controlPointIndex));
3980
+ emitTransient("end", next, controlPointIndex);
3848
3981
  replaceWorkingPoints(preDragPoints);
3849
3982
  commitGesture(next);
3850
3983
  return;
3851
3984
  }
3852
3985
  if (activeBoxDrag) {
3853
3986
  if (ctx.settled) {
3987
+ emitTransient("end", workingPoints, null);
3854
3988
  activeBoxDrag = null;
3855
3989
  dragInFlight = false;
3856
3990
  driver.setDragPanEnabled(true);
@@ -3862,6 +3996,7 @@ function runEdit(opts) {
3862
3996
  if (fsm.kind === "body" && withinClickSlop(pixel, downPixel)) {
3863
3997
  replaceWorkingPoints(preDragPoints);
3864
3998
  workingAmplifierPlacements = preDragAmplifierPlacements;
3999
+ emitTransient("end", preDragPoints, null);
3865
4000
  renderPreview();
3866
4001
  renderHandlesLayer();
3867
4002
  recomputeDirty();
@@ -3878,6 +4013,7 @@ function runEdit(opts) {
3878
4013
  replaceWorkingPoints(preDragPoints);
3879
4014
  workingAmplifierPlacements = preDragAmplifierPlacements;
3880
4015
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
4016
+ emitTransient("end", finalSnapshot.points, null);
3881
4017
  commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver), preDragBox.angle);
3882
4018
  return;
3883
4019
  }
@@ -3911,6 +4047,7 @@ function runEdit(opts) {
3911
4047
  emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
3912
4048
  extraTeardown: () => {
3913
4049
  if (activeDrag !== null || activeBoxDrag !== null || labels.dragging) {
4050
+ emitTransient("end", workingPoints, activeDrag?.controlPointIndex ?? null);
3914
4051
  driver.setDragPanEnabled(true);
3915
4052
  activeDrag = null;
3916
4053
  activeBoxDrag = null;
@@ -5516,6 +5653,7 @@ var TacticalDraw = class {
5516
5653
  guide: options?.guide,
5517
5654
  sizeAnchor: options?.sizeAnchor,
5518
5655
  onSettled,
5656
+ onSession: this.trackSession(options?.onSession),
5519
5657
  snap: this.resolveSnap
5520
5658
  }) : runVariableLengthDraw({
5521
5659
  adapter: this.adapter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.29",
3
+ "version": "0.2.0-alpha.30",
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",
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@orbat-mapper/control-measures": "^0.2.0-alpha.21"
41
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.22"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@arethetypeswrong/core": "^0.18.3",