@orbat-mapper/tactical-draw 0.8.1 → 0.9.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.
package/dist/index.mjs CHANGED
@@ -485,6 +485,7 @@ var BaseMapAdapter = class {
485
485
  if (!this.layers.has(layerId)) return;
486
486
  const nextGroups = groups.map((group) => ({
487
487
  id: group.id,
488
+ ...group.pin ? { pin: group.pin } : {},
488
489
  features: [...group.features],
489
490
  iconResources: [...group.iconResources],
490
491
  ...group.imageOverlays ? { imageOverlays: [...group.imageOverlays] } : {},
@@ -655,8 +656,9 @@ const DEFAULT_TEXT_SIZE_MAX_PIXELS = 24;
655
656
  * Within the band the text scales with its graphic; past either edge it pins
656
657
  * to the bound, so zooming out never yields unreadable smudges and zooming in
657
658
  * never yields screen-covering text. Shared by the adapters that resolve text
658
- * size in code (OpenLayers, Leaflet); the MapLibre adapter evaluates the same
659
- * clamp inside its per-zoom-stop style expression.
659
+ * size in code (OpenLayers, Leaflet); the MapLibre adapter reproduces the same
660
+ * clamp as a native camera expression whose stops are the two clamp
661
+ * boundaries, so it stays exact at fractional zooms.
660
662
  */
661
663
  function resolveGroundTextSizePixels(textSizeMeters, resolution, text) {
662
664
  const min = text?.textSizeMinPixels ?? 8;
@@ -827,33 +829,791 @@ var PointerCallbackHub = class {
827
829
  }
828
830
  };
829
831
  //#endregion
832
+ //#region src/tactical-draw/abort.ts
833
+ const ABORT_REASONS = new Set([
834
+ "escape",
835
+ "signal",
836
+ "preempted",
837
+ "destroyed",
838
+ "session",
839
+ "removed"
840
+ ]);
841
+ /**
842
+ * Façade abort error. Extends `DOMException` with `name: "AbortError"` so it
843
+ * still matches platform `AbortSignal` semantics and `instanceof DOMException`.
844
+ */
845
+ var TacticalDrawAbortError = class TacticalDrawAbortError extends DOMException {
846
+ reason;
847
+ constructor(reason, options) {
848
+ if (!ABORT_REASONS.has(reason)) throw new TypeError(`Invalid TacticalDrawAbortReason: ${String(reason)}`);
849
+ super(options?.message ?? `TacticalDraw aborted (${reason})`, "AbortError");
850
+ Object.setPrototypeOf(this, TacticalDrawAbortError.prototype);
851
+ this.reason = reason;
852
+ if (options && "cause" in options) Object.defineProperty(this, "cause", {
853
+ value: options.cause,
854
+ writable: true,
855
+ configurable: true,
856
+ enumerable: false
857
+ });
858
+ }
859
+ };
860
+ function isTacticalDrawAbortError(error) {
861
+ return error instanceof TacticalDrawAbortError;
862
+ }
863
+ /**
864
+ * Swallow façade `TacticalDrawAbortError` *and* plain DOM `AbortError`s;
865
+ * rethrow every other value unchanged. Designed for use as
866
+ * `promise.catch(ignoreAbort)`.
867
+ */
868
+ function ignoreAbort(error) {
869
+ if (isTacticalDrawAbortError(error)) return;
870
+ if (error instanceof DOMException && error.name === "AbortError") return;
871
+ throw error;
872
+ }
873
+ /**
874
+ * Compose the façade's internal abort controller with an optional host
875
+ * `AbortSignal`. Host aborts surface as `reason: "signal"` with the host's
876
+ * `signal.reason` on `error.cause`. An already-aborted host signal triggers
877
+ * a next-microtask abort (never synchronous), so callers can register await
878
+ * handlers before the rejection fires.
879
+ */
880
+ function combineWithHostSignal(hostSignal) {
881
+ const controller = new AbortController();
882
+ let disposed = false;
883
+ const abortFromHost = () => {
884
+ if (controller.signal.aborted) return;
885
+ const cause = hostSignal?.reason;
886
+ controller.abort(new TacticalDrawAbortError("signal", { cause }));
887
+ };
888
+ const dispose = () => {
889
+ if (disposed) return;
890
+ disposed = true;
891
+ if (hostSignal) hostSignal.removeEventListener("abort", abortFromHost);
892
+ };
893
+ if (hostSignal) if (hostSignal.aborted) queueMicrotask(abortFromHost);
894
+ else hostSignal.addEventListener("abort", abortFromHost, { once: true });
895
+ return {
896
+ signal: controller.signal,
897
+ abort(reason, cause) {
898
+ if (controller.signal.aborted) {
899
+ dispose();
900
+ return;
901
+ }
902
+ const options = arguments.length > 1 ? { cause } : void 0;
903
+ controller.abort(new TacticalDrawAbortError(reason, options));
904
+ dispose();
905
+ },
906
+ dispose
907
+ };
908
+ }
909
+ //#endregion
910
+ //#region src/tactical-draw/session-events.ts
911
+ /**
912
+ * Map a settle-time error to its `SettleReason`: a `TacticalDrawAbortError`
913
+ * replays its own `reason` verbatim; anything else (a `produce` throw from
914
+ * `ctx.commit`) is the commit-time snapshot build failing, reported as
915
+ * `"error"`.
916
+ */
917
+ function settleReasonFromError(error) {
918
+ return isTacticalDrawAbortError(error) ? error.reason : "error";
919
+ }
920
+ /**
921
+ * Fan a `runInteraction` settlement out to a session's commit + settle
922
+ * emitters — the shared body of the `onSettled` wrappers in the edit and draw
923
+ * controllers (transform emits its close-path settle from `commitClose`
924
+ * instead, so it does not use this). On the commit path, emit the snapshot
925
+ * then the `"close"` settle in a try/finally so a throwing `onCommit` listener
926
+ * cannot starve the settle emission; on the reject path, emit the settle with
927
+ * the reason derived from the error. Callers run their own facade
928
+ * slot-clearing (`onSettled()`) before this so a listener observes a cleared
929
+ * `td.activeSession`.
930
+ */
931
+ function settleFromInteraction(settlement, commitEmitter, settleEmitter) {
932
+ if (settlement.status === "committed") try {
933
+ commitEmitter.emit(settlement.value);
934
+ } finally {
935
+ settleEmitter.emit("close");
936
+ }
937
+ else settleEmitter.emit(settleReasonFromError(settlement.error));
938
+ }
939
+ /**
940
+ * Subscribe/emit machinery shared by `TransformSession.onSettled`,
941
+ * `EditSession.onSettled`, and `DrawSession.onSettled` (#191 / #194 / #197):
942
+ * a settle-exactly-once latch, synchronous emission to every listener with
943
+ * exception collection (single rethrow, `AggregateError` otherwise when more
944
+ * than one listener throws), and a late-registration path that delivers the
945
+ * latched reason on a microtask exactly once when a handler subscribes after
946
+ * the session has already settled.
947
+ *
948
+ * `listenerErrorsMessage` becomes the `AggregateError` message — pass a
949
+ * session-specific string (e.g. `"EditSession.onSettled listener errors"`).
950
+ */
951
+ function createSettleEmitter(listenerErrorsMessage) {
952
+ const listeners = /* @__PURE__ */ new Set();
953
+ /** Set exactly once, by `emit` — also the "has settled" latch and the reason replayed to late registrations. */
954
+ let settledReason = null;
955
+ return {
956
+ emit(reason) {
957
+ if (settledReason !== null) return;
958
+ settledReason = reason;
959
+ const current = [...listeners];
960
+ listeners.clear();
961
+ const errors = [];
962
+ for (const listener of current) try {
963
+ listener(reason);
964
+ } catch (err) {
965
+ errors.push(err);
966
+ }
967
+ if (errors.length === 1) throw errors[0];
968
+ if (errors.length > 1) throw new AggregateError(errors, listenerErrorsMessage);
969
+ },
970
+ register(handler) {
971
+ if (settledReason !== null) {
972
+ const reason = settledReason;
973
+ let cancelled = false;
974
+ queueMicrotask(() => {
975
+ if (!cancelled) handler(reason);
976
+ });
977
+ return () => {
978
+ cancelled = true;
979
+ };
980
+ }
981
+ listeners.add(handler);
982
+ return () => {
983
+ listeners.delete(handler);
984
+ };
985
+ }
986
+ };
987
+ }
988
+ /**
989
+ * The deferred reject/close/abort change-emit protocol (issue #49 contract)
990
+ * shared by `EditSession.onChange`, `TransformSession.onChange`, and
991
+ * `GraphicEditSession.onChange`. Owns the listener `Set` (like
992
+ * `createCommitEmitter.register`) and, per emission:
993
+ *
994
+ * - builds one event by spreading the controller-supplied `payload` plus the
995
+ * injected `reject()` / `close()` / `abort()` recorders, and delivers that
996
+ * same instance to every listener in registration order;
997
+ * - collects listener exceptions (single → rethrow as-is, multiple →
998
+ * `AggregateError` with `listenerErrorsMessage`);
999
+ * - applies decisions after all listeners run, priority `abort` > `close` >
1000
+ * `reject`, with `reject` further gated on `!isSettled()` and
1001
+ * `stillApplicable()`;
1002
+ * - runs `onAccepted` (when the change survived) *before* rethrowing listener
1003
+ * errors — unifying every controller on edit's history-before-throw ordering
1004
+ * — and returns whether the change was accepted.
1005
+ *
1006
+ * Registration-time `isSettled()` guards stay in the controllers' `onChange`
1007
+ * wrappers, mirroring `createCommitEmitter`.
1008
+ */
1009
+ function createChangeEmitter(options) {
1010
+ const listeners = /* @__PURE__ */ new Set();
1011
+ return {
1012
+ register(listener) {
1013
+ listeners.add(listener);
1014
+ return () => {
1015
+ listeners.delete(listener);
1016
+ };
1017
+ },
1018
+ emit(payload, emission) {
1019
+ if (options.isSettled()) return false;
1020
+ let rejectRequested = false;
1021
+ let closeRequested = false;
1022
+ let abortRequested = false;
1023
+ const event = {
1024
+ ...payload,
1025
+ reject() {
1026
+ rejectRequested = true;
1027
+ },
1028
+ close() {
1029
+ closeRequested = true;
1030
+ },
1031
+ abort() {
1032
+ abortRequested = true;
1033
+ }
1034
+ };
1035
+ const errors = [];
1036
+ for (const listener of [...listeners]) try {
1037
+ listener(event);
1038
+ } catch (err) {
1039
+ errors.push(err);
1040
+ }
1041
+ const applicable = emission.stillApplicable?.() ?? true;
1042
+ if (abortRequested) options.abort();
1043
+ else if (closeRequested) options.close();
1044
+ else if (rejectRequested && !options.isSettled() && applicable) emission.rollback();
1045
+ const accepted = !options.isSettled() && !rejectRequested && applicable;
1046
+ if (accepted) emission.onAccepted?.();
1047
+ if (errors.length === 1) throw errors[0];
1048
+ if (errors.length > 1) throw new AggregateError(errors, options.listenerErrorsMessage);
1049
+ return accepted;
1050
+ }
1051
+ };
1052
+ }
1053
+ /**
1054
+ * Channel-neutral subscribe/emit machinery: a listener set with synchronous,
1055
+ * exception-collecting emission (single rethrow, `AggregateError` otherwise).
1056
+ * Named for its original `onCommit` channel (`TransformSession` /
1057
+ * `EditSession` / `DrawSession`), it also carries the `onTransientChange`
1058
+ * streams and the fixed/variable draw controllers' `onChange` — any
1059
+ * multi-emit channel with no latch. Unlike `createSettleEmitter`, `emit` may
1060
+ * be called more than
1061
+ * once — a transform's `setGraphics` can commit several removal batches before
1062
+ * the close batch — and there is no latch or late-registration path:
1063
+ * registration-time `ctx.settled` guards stay in the controllers, mirroring
1064
+ * `onChange`.
1065
+ */
1066
+ function createCommitEmitter(listenerErrorsMessage) {
1067
+ const listeners = /* @__PURE__ */ new Set();
1068
+ return {
1069
+ emit(value) {
1070
+ const current = [...listeners];
1071
+ const errors = [];
1072
+ for (const listener of current) try {
1073
+ listener(value);
1074
+ } catch (err) {
1075
+ errors.push(err);
1076
+ }
1077
+ if (errors.length === 1) throw errors[0];
1078
+ if (errors.length > 1) throw new AggregateError(errors, listenerErrorsMessage);
1079
+ },
1080
+ register(listener) {
1081
+ listeners.add(listener);
1082
+ return () => {
1083
+ listeners.delete(listener);
1084
+ };
1085
+ }
1086
+ };
1087
+ }
1088
+ //#endregion
1089
+ //#region src/pin-presentation.ts
1090
+ function validateOverride(patch) {
1091
+ for (const key of ["mode", "occlusion"]) if (Object.hasOwn(patch, key)) throw new TypeError(`Pin setting ${key} is view-wide only`);
1092
+ }
1093
+ const DEFAULT_PIN_PRESENTATION = Object.freeze({
1094
+ enabled: false,
1095
+ mode: "screen",
1096
+ liftPixels: 72,
1097
+ heightMeters: 250,
1098
+ sizePixels: 32,
1099
+ occlusion: "none",
1100
+ stem: Object.freeze({
1101
+ color: "#172033",
1102
+ width: 2,
1103
+ opacity: 1
1104
+ }),
1105
+ dot: Object.freeze({
1106
+ color: "#172033",
1107
+ radius: 3,
1108
+ opacity: 1
1109
+ }),
1110
+ halo: Object.freeze({
1111
+ color: "#ffffff",
1112
+ width: 1.5,
1113
+ opacity: 1
1114
+ }),
1115
+ symbolHalo: Object.freeze({
1116
+ color: "#ffffff",
1117
+ width: 2,
1118
+ opacity: .8
1119
+ }),
1120
+ plate: Object.freeze({
1121
+ enabled: false,
1122
+ color: "#ffffff",
1123
+ borderColor: "#64748b",
1124
+ borderWidth: 1,
1125
+ padding: 4,
1126
+ radius: 4,
1127
+ opacity: .95
1128
+ })
1129
+ });
1130
+ const sections = [
1131
+ "stem",
1132
+ "dot",
1133
+ "halo",
1134
+ "symbolHalo",
1135
+ "plate"
1136
+ ];
1137
+ /**
1138
+ * Whether `value` is an acceptable number for the pin setting named `key`.
1139
+ * Exported so a host's input handling can reject a value with the same rules
1140
+ * the store enforces, rather than re-encoding the bounds and drifting from it.
1141
+ */
1142
+ function isValidPinNumber(key, value) {
1143
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && !(key === "sizePixels" && value === 0) && !(key === "opacity" && value > 1);
1144
+ }
1145
+ /** Hex colors keep validation identical in browsers, workers and document hosts. */
1146
+ function validateSection(input, template, path) {
1147
+ for (const [key, value] of Object.entries(input)) {
1148
+ if (value === void 0) continue;
1149
+ if (!Object.hasOwn(template, key)) throw new TypeError(`Unknown pin setting ${path}${key}`);
1150
+ const expected = template[key];
1151
+ if (typeof expected === "number") {
1152
+ if (!isValidPinNumber(key, value)) throw new TypeError(`Invalid pin setting ${path}${key}`);
1153
+ } else if (typeof expected === "boolean") {
1154
+ if (typeof value !== "boolean") throw new TypeError(`Invalid pin setting ${path}${key}`);
1155
+ } else if (key === "mode" || key === "occlusion") {
1156
+ if (!(key === "mode" ? ["screen", "world"] : ["none", "terrain"]).includes(String(value))) throw new TypeError(`Invalid pin setting ${key}`);
1157
+ } else if (typeof expected === "string") {
1158
+ if (typeof value !== "string" || !/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value)) throw new TypeError(`Pin color ${path}${key} must be a hex color`);
1159
+ } else {
1160
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`Invalid pin setting ${key}`);
1161
+ validateSection(value, expected, `${key}.`);
1162
+ }
1163
+ }
1164
+ }
1165
+ function resolvePinPresentation(...patches) {
1166
+ const result = structuredClone(DEFAULT_PIN_PRESENTATION);
1167
+ for (const patch of patches) {
1168
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("Invalid pin presentation");
1169
+ validateSection(patch, DEFAULT_PIN_PRESENTATION, "");
1170
+ for (const [key, value] of Object.entries(patch)) {
1171
+ if (value === void 0) continue;
1172
+ if (sections.includes(key)) {
1173
+ const target = result[key];
1174
+ Object.assign(target, Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0)));
1175
+ } else Object.assign(result, { [key]: value });
1176
+ }
1177
+ }
1178
+ return result;
1179
+ }
1180
+ /** Replacing patches makes omitted override properties inherit again. */
1181
+ var PinPresentationStore = class {
1182
+ state = {
1183
+ defaults: {},
1184
+ overrides: {}
1185
+ };
1186
+ emitter = createCommitEmitter("PinPresentationStore.onChange listener errors");
1187
+ getState() {
1188
+ return structuredClone(this.state);
1189
+ }
1190
+ resolve(id) {
1191
+ return resolvePinPresentation(this.state.defaults, Object.hasOwn(this.state.overrides, id) ? this.state.overrides[id] : {});
1192
+ }
1193
+ replace(state) {
1194
+ if (!state || !state.overrides || typeof state.overrides !== "object" || Array.isArray(state.overrides)) throw new TypeError("Invalid pin state");
1195
+ if (!Object.is(state.defaults, this.state.defaults)) {
1196
+ resolvePinPresentation(state.defaults);
1197
+ for (const patch of Object.values(state.overrides)) {
1198
+ validateOverride(patch);
1199
+ resolvePinPresentation(state.defaults, patch);
1200
+ }
1201
+ } else for (const [id, patch] of Object.entries(state.overrides)) if (!Object.is(patch, this.state.overrides[id])) {
1202
+ validateOverride(patch);
1203
+ resolvePinPresentation(state.defaults, patch);
1204
+ }
1205
+ this.state = structuredClone(state);
1206
+ this.emitter.emit(this.getState());
1207
+ }
1208
+ setDefaults(patch) {
1209
+ this.replace({
1210
+ ...this.state,
1211
+ defaults: patch
1212
+ });
1213
+ }
1214
+ setOverride(id, patch) {
1215
+ this.replace({
1216
+ ...this.state,
1217
+ overrides: {
1218
+ ...this.state.overrides,
1219
+ [id]: patch
1220
+ }
1221
+ });
1222
+ }
1223
+ resetOverride(id) {
1224
+ const overrides = { ...this.state.overrides };
1225
+ delete overrides[id];
1226
+ this.replace({
1227
+ ...this.state,
1228
+ overrides
1229
+ });
1230
+ }
1231
+ onChange(listener) {
1232
+ return this.emitter.register(listener);
1233
+ }
1234
+ };
1235
+ //#endregion
830
1236
  //#region src/point-symbol.ts
831
1237
  function isPointSymbol(graphic) {
832
1238
  return typeof graphic === "object" && graphic !== null && "kind" in graphic && graphic.kind === "point-symbol";
833
1239
  }
834
- function clonePointSymbol(symbol) {
835
- return structuredClone(symbol);
1240
+ function clonePointSymbol(symbol) {
1241
+ return structuredClone(symbol);
1242
+ }
1243
+ /**
1244
+ * Replace a point-symbol size value as a user-driven resize. Ground-size display
1245
+ * bounds scale with the value so they continue to constrain zoom changes rather
1246
+ * than preventing the resize itself.
1247
+ */
1248
+ function resizePointSymbolSize(size, value) {
1249
+ if (size.unit === "pixels") return {
1250
+ value,
1251
+ unit: "pixels"
1252
+ };
1253
+ const scale = value / size.value;
1254
+ if (!Number.isFinite(scale) || scale <= 0) return {
1255
+ ...size,
1256
+ value
1257
+ };
1258
+ return {
1259
+ ...size,
1260
+ value,
1261
+ ...size.minPixels !== void 0 ? { minPixels: size.minPixels * scale } : {},
1262
+ ...size.maxPixels !== void 0 ? { maxPixels: size.maxPixels * scale } : {}
1263
+ };
1264
+ }
1265
+ //#endregion
1266
+ //#region src/point-symbol-size.ts
1267
+ function effectivePointSymbolPixelSize(symbol, resolution) {
1268
+ if (symbol.size.unit === "pixels") return symbol.size.value;
1269
+ if (resolution === void 0 || resolution <= 0) return symbol.size.value;
1270
+ const px = symbol.size.value / resolution;
1271
+ const { minPixels, maxPixels } = symbol.size;
1272
+ return Math.max(Number.isFinite(minPixels) && minPixels > 0 ? minPixels : 0, Math.min(Number.isFinite(maxPixels) && maxPixels > 0 ? maxPixels : Infinity, px));
1273
+ }
1274
+ function pointSymbolPixelScale(symbol, render, resolution) {
1275
+ if (!Number.isFinite(render.octagonSize) || render.octagonSize <= 0) throw new TypeError("PointSymbolRender.octagonSize must be a positive finite number");
1276
+ return effectivePointSymbolPixelSize(symbol, resolution) / render.octagonSize;
1277
+ }
1278
+ //#endregion
1279
+ //#region src/tactical-draw/transform-box.ts
1280
+ /**
1281
+ * The uniform scale factor and rotation delta a box implies relative to a
1282
+ * pre-drag box, given the gesture kind. A `body` translate reports neither
1283
+ * (1, 0). Consumed both by point symbols (size/rotation) and by Text graphics
1284
+ * (folding the delta into rotation/size options); the delta is pivot-
1285
+ * independent so either can apply it without knowing the box center.
1286
+ */
1287
+ function boxScaleRotation(kind, box, preDragBox) {
1288
+ return {
1289
+ scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
1290
+ rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
1291
+ };
1292
+ }
1293
+ function rotate(x, y, angle) {
1294
+ const cos = Math.cos(angle);
1295
+ const sin = Math.sin(angle);
1296
+ return [x * cos - y * sin, x * sin + y * cos];
1297
+ }
1298
+ /**
1299
+ * Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
1300
+ * Rotates the points by `-angle` about their centroid, takes the axis-aligned
1301
+ * bounding box in that de-rotated frame, then re-expresses the result as an
1302
+ * oriented box at `angle` — so the box always has exactly the requested tilt,
1303
+ * sized to just contain the (de-rotated) points. Half-dimensions are floored
1304
+ * at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
1305
+ * pickable box. Returns `null` for an empty list — there is nothing to fit.
1306
+ *
1307
+ * `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
1308
+ * the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
1309
+ * `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
1310
+ * the rendered geometry. Everything downstream that reads a fitted box
1311
+ * (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
1312
+ * `halfWidth`/`halfHeight` alone, so the padding flows through to corner
1313
+ * placement, the rotate grip, and picking automatically — no other function
1314
+ * needs to know about it.
1315
+ */
1316
+ function fitTransformBox(pixels, angle, padding = 0) {
1317
+ if (pixels.length === 0) return null;
1318
+ let cx = 0;
1319
+ let cy = 0;
1320
+ for (const p of pixels) {
1321
+ cx += p[0];
1322
+ cy += p[1];
1323
+ }
1324
+ cx /= pixels.length;
1325
+ cy /= pixels.length;
1326
+ let minX = Infinity;
1327
+ let minY = Infinity;
1328
+ let maxX = -Infinity;
1329
+ let maxY = -Infinity;
1330
+ for (const p of pixels) {
1331
+ const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
1332
+ if (rx < minX) minX = rx;
1333
+ if (rx > maxX) maxX = rx;
1334
+ if (ry < minY) minY = ry;
1335
+ if (ry > maxY) maxY = ry;
1336
+ }
1337
+ const localCenterX = (minX + maxX) / 2;
1338
+ const localCenterY = (minY + maxY) / 2;
1339
+ const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
1340
+ const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
1341
+ const [ox, oy] = rotate(localCenterX, localCenterY, angle);
1342
+ return {
1343
+ center: [cx + ox, cy + oy],
1344
+ halfWidth,
1345
+ halfHeight,
1346
+ angle
1347
+ };
1348
+ }
1349
+ /**
1350
+ * The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
1351
+ * bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
1352
+ * `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
1353
+ * and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
1354
+ * anchor is always the opposite corner, `(index + 2) % 4`.
1355
+ */
1356
+ function boxCorners(box) {
1357
+ const { center, halfWidth, halfHeight, angle } = box;
1358
+ return [
1359
+ [-halfWidth, -halfHeight],
1360
+ [halfWidth, -halfHeight],
1361
+ [halfWidth, halfHeight],
1362
+ [-halfWidth, halfHeight]
1363
+ ].map(([x, y]) => {
1364
+ const [rx, ry] = rotate(x, y, angle);
1365
+ return [center[0] + rx, center[1] + ry];
1366
+ });
1367
+ }
1368
+ /**
1369
+ * Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
1370
+ * above the top-edge midpoint, along the box's local "up" direction, so the
1371
+ * grip stays glued to the box (rotates with it) rather than floating at a
1372
+ * fixed screen offset. The label transform box passes its own tighter
1373
+ * clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
1374
+ */
1375
+ function rotateGripPixel(box, offset = 28) {
1376
+ const { center, halfHeight, angle } = box;
1377
+ const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
1378
+ return [center[0] + rx, center[1] + ry];
1379
+ }
1380
+ /**
1381
+ * Point-in-oriented-rectangle test, used to start a body (translate) drag
1382
+ * when the pointer goes down inside the box but not on a corner/rotate grip.
1383
+ * De-rotates `pixel` into the box's local frame about `box.center` and
1384
+ * compares against the half-extents.
1385
+ */
1386
+ function pointInBox(box, pixel) {
1387
+ const { center, halfWidth, halfHeight, angle } = box;
1388
+ const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
1389
+ return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
1390
+ }
1391
+ /** Plain 2D distance between two pixel coordinates. */
1392
+ function pixelDistance$1(a, b) {
1393
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
1394
+ }
1395
+ /**
1396
+ * Pick what part of the box, if any, `pixel` hits — corners first, then the
1397
+ * rotate grip, then plain containment (body drag). Corners and the rotate
1398
+ * grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
1399
+ * click just outside the box never starts a body drag. Returns `null` when
1400
+ * nothing matches.
1401
+ */
1402
+ function pickBoxHandle(box, pixel, tolerance) {
1403
+ const corners = boxCorners(box);
1404
+ for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
1405
+ kind: "scale",
1406
+ index: i
1407
+ };
1408
+ if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
1409
+ if (pointInBox(box, pixel)) return { kind: "body" };
1410
+ return null;
1411
+ }
1412
+ //#endregion
1413
+ //#region src/point-symbol-offset.ts
1414
+ const EPSILON = 1e-7;
1415
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
1416
+ /** Trim from the symbol attachment end, including across a junction when necessary. */
1417
+ function padAnchor(path, padding) {
1418
+ if (!Number.isFinite(padding) || padding <= 0) return path;
1419
+ const trimmed = [...path];
1420
+ while (trimmed.length > 1) {
1421
+ const start = trimmed.shift();
1422
+ const end = trimmed[0];
1423
+ const length = distance(start, end);
1424
+ if (length > padding) {
1425
+ const fraction = padding / length;
1426
+ trimmed.unshift([start[0] + (end[0] - start[0]) * fraction, start[1] + (end[1] - start[1]) * fraction]);
1427
+ return trimmed;
1428
+ }
1429
+ padding -= length;
1430
+ }
1431
+ return [];
1432
+ }
1433
+ /** Pure layout: projection input/output is in CSS pixels, including document contexts. */
1434
+ function resolvePointSymbolOffset(symbol, render, view) {
1435
+ const actual = view.getPixelFromCoordinate(symbol.position);
1436
+ if (!actual) return null;
1437
+ const scale = pointSymbolPixelScale(symbol, render, view.getResolution());
1438
+ const angle = symbol.rotation - view.getBearing();
1439
+ const metadata = render.offset;
1440
+ const registration = metadata?.ordinaryRegistration ?? render.octagonAnchor;
1441
+ const shift = rotate((registration.x - render.anchor.x) * scale, (registration.y - render.anchor.y) * scale, angle);
1442
+ const ordinaryDisplay = [actual[0] + shift[0], actual[1] + shift[1]];
1443
+ const layout = {
1444
+ render,
1445
+ resource: pointSymbolResource(render),
1446
+ bodyOpacity: (symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
1447
+ rotation: angle,
1448
+ actual,
1449
+ display: ordinaryDisplay,
1450
+ imageAnchor: actual,
1451
+ ordinaryDisplay,
1452
+ path: [],
1453
+ kind: "ordinary",
1454
+ status: "ok",
1455
+ scale,
1456
+ connectorStyle: {
1457
+ strokeColor: symbol.offset?.style?.strokeColor ?? metadata?.strokeColor ?? "#000000",
1458
+ opacity: (symbol.offset?.style?.opacity ?? symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
1459
+ strokeWidth: symbol.offset?.style?.strokeWidth ?? (metadata?.strokeWidth ?? 4) * scale,
1460
+ strokeDash: [...symbol.offset?.style?.strokeDash ?? []]
1461
+ }
1462
+ };
1463
+ if (!symbol.offset) return layout;
1464
+ const { placement, connector, anchorPadding = 0 } = symbol.offset;
1465
+ if (!metadata || connector !== "direct" && !metadata.indicator) return {
1466
+ ...layout,
1467
+ status: "unsupported-presentation"
1468
+ };
1469
+ const display = placement.mode === "ground" ? view.getPixelFromCoordinate(placement.position) : [ordinaryDisplay[0] + placement.offset[0], ordinaryDisplay[1] + placement.offset[1]];
1470
+ if (!display) return {
1471
+ ...layout,
1472
+ status: "unsupported-presentation"
1473
+ };
1474
+ if (distance(display, ordinaryDisplay) < EPSILON) return layout;
1475
+ const body = metadata.body;
1476
+ const offset = rotate((metadata.attachment.x - body.anchor.x) * scale, (metadata.attachment.y - body.anchor.y) * scale, angle);
1477
+ const attachment = [display[0] + offset[0], display[1] + offset[1]];
1478
+ const resolved = {
1479
+ ...layout,
1480
+ render: body,
1481
+ resource: pointSymbolResource(body),
1482
+ display,
1483
+ imageAnchor: display,
1484
+ attachment
1485
+ };
1486
+ if (connector === "direct") return {
1487
+ ...resolved,
1488
+ kind: "direct",
1489
+ path: padAnchor(distance(attachment, actual) < EPSILON ? [] : [attachment, actual], anchorPadding)
1490
+ };
1491
+ const indicator = metadata.indicator;
1492
+ const rawDirection = rotate(indicator.direction.x, indicator.direction.y, angle);
1493
+ const norm = Math.hypot(...rawDirection);
1494
+ const direction = [rawDirection[0] / norm, rawDirection[1] / norm];
1495
+ const dx = actual[0] - attachment[0], dy = actual[1] - attachment[1];
1496
+ const forward = dx * direction[0] + dy * direction[1];
1497
+ if (Math.abs(dx * direction[1] - dy * direction[0]) < EPSILON && forward >= (indicator.minLength ?? 0) * scale) return {
1498
+ ...resolved,
1499
+ direction,
1500
+ kind: "indicator-only",
1501
+ path: padAnchor([attachment, actual], anchorPadding)
1502
+ };
1503
+ const length = symbol.offset.indicatorLength !== void 0 ? symbol.offset.indicatorLength * render.octagonSize : indicator.length;
1504
+ const junction = [attachment[0] + direction[0] * length * scale, attachment[1] + direction[1] * length * scale];
1505
+ return {
1506
+ ...resolved,
1507
+ direction,
1508
+ junction,
1509
+ kind: "with-extension",
1510
+ status: "ok",
1511
+ path: padAnchor(distance(junction, actual) < EPSILON ? [attachment, junction] : [
1512
+ attachment,
1513
+ junction,
1514
+ actual
1515
+ ], anchorPadding)
1516
+ };
836
1517
  }
837
1518
  /**
838
- * Replace a point-symbol size value as a user-driven resize. Ground-size display
839
- * bounds scale with the value so they continue to constrain zoom changes rather
840
- * than preventing the resize itself.
1519
+ * Authored indicator length that puts the junction at `point`: the forward
1520
+ * projection onto the attachment ray, expressed as a multiple of the displayed
1521
+ * octagon size and clamped to a non-negative length.
841
1522
  */
842
- function resizePointSymbolSize(size, value) {
843
- if (size.unit === "pixels") return {
844
- value,
845
- unit: "pixels"
1523
+ function indicatorLengthAt(point, attachment, direction, scale, octagonSize) {
1524
+ const forward = (point[0] - attachment[0]) * direction[0] + (point[1] - attachment[1]) * direction[1];
1525
+ return Math.max(0, forward / (scale * octagonSize));
1526
+ }
1527
+ /** Set body placement without changing the authoritative coordinate. */
1528
+ function placePointSymbol(symbol, render, view, display, mode = symbol.offset?.placement.mode ?? "screen-relative") {
1529
+ if (!render.offset) throw new TypeError("This renderer does not support offset placement");
1530
+ const ordinary = resolvePointSymbolOffset({
1531
+ ...symbol,
1532
+ offset: void 0
1533
+ }, render, view);
1534
+ if (!ordinary) throw new RangeError("Point symbol cannot be projected in this view");
1535
+ const next = clonePointSymbol(symbol);
1536
+ const coordinate = mode === "ground" ? view.getCoordinateFromPixel?.(display) : void 0;
1537
+ if (mode === "ground" && !coordinate) throw new RangeError("Ground placement requires inverse projection");
1538
+ next.offset = {
1539
+ ...next.offset,
1540
+ connector: next.offset?.connector ?? (render.offset.indicator ? "auto" : "direct"),
1541
+ placement: mode === "ground" ? {
1542
+ mode,
1543
+ position: [...coordinate]
1544
+ } : {
1545
+ mode,
1546
+ offset: [display[0] - ordinary.display[0], display[1] - ordinary.display[1]]
1547
+ }
846
1548
  };
847
- const scale = value / size.value;
848
- if (!Number.isFinite(scale) || scale <= 0) return {
849
- ...size,
850
- value
1549
+ return next;
1550
+ }
1551
+ function convertPointSymbolPlacement(symbol, render, view, mode) {
1552
+ const layout = resolvePointSymbolOffset(symbol, render, view);
1553
+ if (!layout || layout.status === "unsupported-presentation") throw new TypeError("Offset presentation is unavailable");
1554
+ return placePointSymbol(symbol, render, view, layout.display, mode);
1555
+ }
1556
+ /** Explicit alignment, also used with hysteresis by pointer editing. */
1557
+ function alignPointSymbolIndicator(symbol, render, view) {
1558
+ const layout = resolvePointSymbolOffset(symbol, render, view);
1559
+ if (!layout?.attachment || !layout.direction || !render.offset?.indicator) return symbol;
1560
+ const d = layout.direction;
1561
+ const delta = [layout.actual[0] - layout.attachment[0], layout.actual[1] - layout.attachment[1]];
1562
+ const forward = Math.max((render.offset.indicator.minLength ?? 0) * layout.scale, delta[0] * d[0] + delta[1] * d[1]);
1563
+ return placePointSymbol(symbol, render, view, [layout.display[0] + delta[0] - forward * d[0], layout.display[1] + delta[1] - forward * d[1]]);
1564
+ }
1565
+ function movePointSymbolLocation(symbol, render, view, position) {
1566
+ const before = resolvePointSymbolOffset(symbol, render, view);
1567
+ let next = {
1568
+ ...clonePointSymbol(symbol),
1569
+ position: [...position]
851
1570
  };
1571
+ if (!symbol.offset || !before || before.status === "unsupported-presentation") return next;
1572
+ next = placePointSymbol(next, render, view, before.display);
1573
+ const after = resolvePointSymbolOffset(next, render, view);
1574
+ if (before.junction && after?.attachment && after.direction && symbol.offset.indicatorLength !== void 0) next.offset.indicatorLength = indicatorLengthAt(before.junction, after.attachment, after.direction, after.scale, render.octagonSize);
1575
+ return next;
1576
+ }
1577
+ /** Canonical resource metadata shared by native adapters and external exporters. */
1578
+ function pointSymbolResource(render) {
852
1579
  return {
853
- ...size,
854
- value,
855
- ...size.minPixels !== void 0 ? { minPixels: size.minPixels * scale } : {},
856
- ...size.maxPixels !== void 0 ? { maxPixels: size.maxPixels * scale } : {}
1580
+ id: render.resourceId,
1581
+ svg: render.svg,
1582
+ width: render.width,
1583
+ height: render.height,
1584
+ anchor: [render.anchor.x, render.anchor.y],
1585
+ coreRect: {
1586
+ x: render.octagonAnchor.x - render.octagonSize / 2,
1587
+ y: render.octagonAnchor.y - render.octagonSize / 2,
1588
+ width: render.octagonSize,
1589
+ height: render.octagonSize
1590
+ }
1591
+ };
1592
+ }
1593
+ /**
1594
+ * Convert a resolved CSS-pixel layout to output units (e.g. 25.4 / 96 for mm).
1595
+ * Scales positions, body scale, line width and dashes together, including the
1596
+ * already-resolved offset and padding. Canonical resources, direction, rotation
1597
+ * and opacity stay unchanged. Does not mutate the input or authored symbol.
1598
+ */
1599
+ function scalePointSymbolLayout(layout, factor) {
1600
+ if (!Number.isFinite(factor) || factor <= 0) throw new RangeError("Layout scale factor must be positive and finite");
1601
+ const point = ([x, y]) => [x * factor, y * factor];
1602
+ return {
1603
+ ...layout,
1604
+ actual: point(layout.actual),
1605
+ display: point(layout.display),
1606
+ imageAnchor: point(layout.imageAnchor),
1607
+ ordinaryDisplay: point(layout.ordinaryDisplay),
1608
+ attachment: layout.attachment && point(layout.attachment),
1609
+ junction: layout.junction && point(layout.junction),
1610
+ path: layout.path.map(point),
1611
+ scale: layout.scale * factor,
1612
+ connectorStyle: {
1613
+ ...layout.connectorStyle,
1614
+ strokeWidth: layout.connectorStyle.strokeWidth * factor,
1615
+ strokeDash: layout.connectorStyle.strokeDash.map((length) => length * factor)
1616
+ }
857
1617
  };
858
1618
  }
859
1619
  //#endregion
@@ -953,97 +1713,19 @@ var ActiveInteractionTracker = class {
953
1713
  this.currentSession = null;
954
1714
  return true;
955
1715
  }
956
- return false;
957
- }
958
- get session() {
959
- return this.currentSession;
960
- }
961
- /** True while an interaction is mid-construction (between `begin` and `set`). */
962
- get isStarting() {
963
- return this.starting;
964
- }
965
- get isActive() {
966
- return this.starting || this.handle !== null && !this.handle.settled;
967
- }
968
- };
969
- //#endregion
970
- //#region src/tactical-draw/abort.ts
971
- const ABORT_REASONS = new Set([
972
- "escape",
973
- "signal",
974
- "preempted",
975
- "destroyed",
976
- "session",
977
- "removed"
978
- ]);
979
- /**
980
- * Façade abort error. Extends `DOMException` with `name: "AbortError"` so it
981
- * still matches platform `AbortSignal` semantics and `instanceof DOMException`.
982
- */
983
- var TacticalDrawAbortError = class TacticalDrawAbortError extends DOMException {
984
- reason;
985
- constructor(reason, options) {
986
- if (!ABORT_REASONS.has(reason)) throw new TypeError(`Invalid TacticalDrawAbortReason: ${String(reason)}`);
987
- super(options?.message ?? `TacticalDraw aborted (${reason})`, "AbortError");
988
- Object.setPrototypeOf(this, TacticalDrawAbortError.prototype);
989
- this.reason = reason;
990
- if (options && "cause" in options) Object.defineProperty(this, "cause", {
991
- value: options.cause,
992
- writable: true,
993
- configurable: true,
994
- enumerable: false
995
- });
1716
+ return false;
1717
+ }
1718
+ get session() {
1719
+ return this.currentSession;
1720
+ }
1721
+ /** True while an interaction is mid-construction (between `begin` and `set`). */
1722
+ get isStarting() {
1723
+ return this.starting;
1724
+ }
1725
+ get isActive() {
1726
+ return this.starting || this.handle !== null && !this.handle.settled;
996
1727
  }
997
1728
  };
998
- function isTacticalDrawAbortError(error) {
999
- return error instanceof TacticalDrawAbortError;
1000
- }
1001
- /**
1002
- * Swallow façade `TacticalDrawAbortError` *and* plain DOM `AbortError`s;
1003
- * rethrow every other value unchanged. Designed for use as
1004
- * `promise.catch(ignoreAbort)`.
1005
- */
1006
- function ignoreAbort(error) {
1007
- if (isTacticalDrawAbortError(error)) return;
1008
- if (error instanceof DOMException && error.name === "AbortError") return;
1009
- throw error;
1010
- }
1011
- /**
1012
- * Compose the façade's internal abort controller with an optional host
1013
- * `AbortSignal`. Host aborts surface as `reason: "signal"` with the host's
1014
- * `signal.reason` on `error.cause`. An already-aborted host signal triggers
1015
- * a next-microtask abort (never synchronous), so callers can register await
1016
- * handlers before the rejection fires.
1017
- */
1018
- function combineWithHostSignal(hostSignal) {
1019
- const controller = new AbortController();
1020
- let disposed = false;
1021
- const abortFromHost = () => {
1022
- if (controller.signal.aborted) return;
1023
- const cause = hostSignal?.reason;
1024
- controller.abort(new TacticalDrawAbortError("signal", { cause }));
1025
- };
1026
- const dispose = () => {
1027
- if (disposed) return;
1028
- disposed = true;
1029
- if (hostSignal) hostSignal.removeEventListener("abort", abortFromHost);
1030
- };
1031
- if (hostSignal) if (hostSignal.aborted) queueMicrotask(abortFromHost);
1032
- else hostSignal.addEventListener("abort", abortFromHost, { once: true });
1033
- return {
1034
- signal: controller.signal,
1035
- abort(reason, cause) {
1036
- if (controller.signal.aborted) {
1037
- dispose();
1038
- return;
1039
- }
1040
- const options = arguments.length > 1 ? { cause } : void 0;
1041
- controller.abort(new TacticalDrawAbortError(reason, options));
1042
- dispose();
1043
- },
1044
- dispose
1045
- };
1046
- }
1047
1729
  //#endregion
1048
1730
  //#region src/tactical-draw/measure-options.ts
1049
1731
  const DEFAULT_LABEL_SIZE_PIXELS = DEFAULT_LABEL_HEIGHT_CSS_PIXELS;
@@ -1625,238 +2307,59 @@ function createSnapResolver(adapter, getGraphics, getGeometryFeatures, getOption
1625
2307
  let indexedCandidates = [];
1626
2308
  let indexedFeatures;
1627
2309
  let indexedGeometryPaths = [];
1628
- const builtInCandidates = () => {
1629
- const graphics = getGraphics();
1630
- if (graphics !== indexedGraphics) {
1631
- indexedGraphics = graphics;
1632
- indexedCandidates = graphicCandidates(graphics);
1633
- }
1634
- return indexedCandidates;
1635
- };
1636
- const geometryPaths = () => {
1637
- const features = getGeometryFeatures();
1638
- if (features !== indexedFeatures) {
1639
- indexedFeatures = features;
1640
- indexedGeometryPaths = indexGeometryFeatures(features);
1641
- }
1642
- return indexedGeometryPaths;
1643
- };
1644
- return (request) => {
1645
- const options = getOptions();
1646
- const clear = () => options?.onSnap?.(null);
1647
- if (!options?.enabled || options.bypass?.(request.originalEvent)) {
1648
- clear();
1649
- return null;
1650
- }
1651
- const tolerance = options.tolerancePixels ?? DEFAULT_TOLERANCE_PIXELS;
1652
- if (!Number.isFinite(tolerance) || tolerance < 0) {
1653
- clear();
1654
- return null;
1655
- }
1656
- const candidateLists = [];
1657
- if (options.sources?.graphics !== false) candidateLists.push(builtInCandidates());
1658
- const graphicGeometry = options.sources?.graphicGeometry;
1659
- if (graphicGeometry) candidateLists.push(generatedGeometryCandidates(adapter, geometryPaths(), request, graphicGeometry));
1660
- const external = options.sources?.external?.(request);
1661
- if (external) candidateLists.push(external);
1662
- let best = null;
1663
- for (const list of candidateLists) for (const candidate of list) {
1664
- if (isSelfCandidate(candidate, request)) continue;
1665
- const pixel = adapter.getPixelFromCoordinate(candidate.coordinate);
1666
- if (!pixel) continue;
1667
- const distancePixels = Math.hypot(pixel[0] - request.pixel[0], pixel[1] - request.pixel[1]);
1668
- if (distancePixels > tolerance) continue;
1669
- const priority = candidate.priority ?? 0;
1670
- const bestPriority = best?.candidate.priority ?? 0;
1671
- if (best && (distancePixels > best.distancePixels || distancePixels === best.distancePixels && priority <= bestPriority)) continue;
1672
- best = {
1673
- coordinate: [...candidate.coordinate],
1674
- pixel: [...pixel],
1675
- candidate,
1676
- distancePixels
1677
- };
1678
- }
1679
- options.onSnap?.(best);
1680
- return best;
1681
- };
1682
- }
1683
- //#endregion
1684
- //#region src/tactical-draw/session-events.ts
1685
- /**
1686
- * Map a settle-time error to its `SettleReason`: a `TacticalDrawAbortError`
1687
- * replays its own `reason` verbatim; anything else (a `produce` throw from
1688
- * `ctx.commit`) is the commit-time snapshot build failing, reported as
1689
- * `"error"`.
1690
- */
1691
- function settleReasonFromError(error) {
1692
- return isTacticalDrawAbortError(error) ? error.reason : "error";
1693
- }
1694
- /**
1695
- * Fan a `runInteraction` settlement out to a session's commit + settle
1696
- * emitters — the shared body of the `onSettled` wrappers in the edit and draw
1697
- * controllers (transform emits its close-path settle from `commitClose`
1698
- * instead, so it does not use this). On the commit path, emit the snapshot
1699
- * then the `"close"` settle in a try/finally so a throwing `onCommit` listener
1700
- * cannot starve the settle emission; on the reject path, emit the settle with
1701
- * the reason derived from the error. Callers run their own facade
1702
- * slot-clearing (`onSettled()`) before this so a listener observes a cleared
1703
- * `td.activeSession`.
1704
- */
1705
- function settleFromInteraction(settlement, commitEmitter, settleEmitter) {
1706
- if (settlement.status === "committed") try {
1707
- commitEmitter.emit(settlement.value);
1708
- } finally {
1709
- settleEmitter.emit("close");
1710
- }
1711
- else settleEmitter.emit(settleReasonFromError(settlement.error));
1712
- }
1713
- /**
1714
- * Subscribe/emit machinery shared by `TransformSession.onSettled`,
1715
- * `EditSession.onSettled`, and `DrawSession.onSettled` (#191 / #194 / #197):
1716
- * a settle-exactly-once latch, synchronous emission to every listener with
1717
- * exception collection (single rethrow, `AggregateError` otherwise when more
1718
- * than one listener throws), and a late-registration path that delivers the
1719
- * latched reason on a microtask exactly once when a handler subscribes after
1720
- * the session has already settled.
1721
- *
1722
- * `listenerErrorsMessage` becomes the `AggregateError` message — pass a
1723
- * session-specific string (e.g. `"EditSession.onSettled listener errors"`).
1724
- */
1725
- function createSettleEmitter(listenerErrorsMessage) {
1726
- const listeners = /* @__PURE__ */ new Set();
1727
- /** Set exactly once, by `emit` — also the "has settled" latch and the reason replayed to late registrations. */
1728
- let settledReason = null;
1729
- return {
1730
- emit(reason) {
1731
- if (settledReason !== null) return;
1732
- settledReason = reason;
1733
- const current = [...listeners];
1734
- listeners.clear();
1735
- const errors = [];
1736
- for (const listener of current) try {
1737
- listener(reason);
1738
- } catch (err) {
1739
- errors.push(err);
1740
- }
1741
- if (errors.length === 1) throw errors[0];
1742
- if (errors.length > 1) throw new AggregateError(errors, listenerErrorsMessage);
1743
- },
1744
- register(handler) {
1745
- if (settledReason !== null) {
1746
- const reason = settledReason;
1747
- let cancelled = false;
1748
- queueMicrotask(() => {
1749
- if (!cancelled) handler(reason);
1750
- });
1751
- return () => {
1752
- cancelled = true;
1753
- };
1754
- }
1755
- listeners.add(handler);
1756
- return () => {
1757
- listeners.delete(handler);
1758
- };
1759
- }
1760
- };
1761
- }
1762
- /**
1763
- * The deferred reject/close/abort change-emit protocol (issue #49 contract)
1764
- * shared by `EditSession.onChange`, `TransformSession.onChange`, and
1765
- * `GraphicEditSession.onChange`. Owns the listener `Set` (like
1766
- * `createCommitEmitter.register`) and, per emission:
1767
- *
1768
- * - builds one event by spreading the controller-supplied `payload` plus the
1769
- * injected `reject()` / `close()` / `abort()` recorders, and delivers that
1770
- * same instance to every listener in registration order;
1771
- * - collects listener exceptions (single → rethrow as-is, multiple →
1772
- * `AggregateError` with `listenerErrorsMessage`);
1773
- * - applies decisions after all listeners run, priority `abort` > `close` >
1774
- * `reject`, with `reject` further gated on `!isSettled()` and
1775
- * `stillApplicable()`;
1776
- * - runs `onAccepted` (when the change survived) *before* rethrowing listener
1777
- * errors — unifying every controller on edit's history-before-throw ordering
1778
- * — and returns whether the change was accepted.
1779
- *
1780
- * Registration-time `isSettled()` guards stay in the controllers' `onChange`
1781
- * wrappers, mirroring `createCommitEmitter`.
1782
- */
1783
- function createChangeEmitter(options) {
1784
- const listeners = /* @__PURE__ */ new Set();
1785
- return {
1786
- register(listener) {
1787
- listeners.add(listener);
1788
- return () => {
1789
- listeners.delete(listener);
1790
- };
1791
- },
1792
- emit(payload, emission) {
1793
- if (options.isSettled()) return false;
1794
- let rejectRequested = false;
1795
- let closeRequested = false;
1796
- let abortRequested = false;
1797
- const event = {
1798
- ...payload,
1799
- reject() {
1800
- rejectRequested = true;
1801
- },
1802
- close() {
1803
- closeRequested = true;
1804
- },
1805
- abort() {
1806
- abortRequested = true;
1807
- }
1808
- };
1809
- const errors = [];
1810
- for (const listener of [...listeners]) try {
1811
- listener(event);
1812
- } catch (err) {
1813
- errors.push(err);
1814
- }
1815
- const applicable = emission.stillApplicable?.() ?? true;
1816
- if (abortRequested) options.abort();
1817
- else if (closeRequested) options.close();
1818
- else if (rejectRequested && !options.isSettled() && applicable) emission.rollback();
1819
- const accepted = !options.isSettled() && !rejectRequested && applicable;
1820
- if (accepted) emission.onAccepted?.();
1821
- if (errors.length === 1) throw errors[0];
1822
- if (errors.length > 1) throw new AggregateError(errors, options.listenerErrorsMessage);
1823
- return accepted;
2310
+ const builtInCandidates = () => {
2311
+ const graphics = getGraphics();
2312
+ if (graphics !== indexedGraphics) {
2313
+ indexedGraphics = graphics;
2314
+ indexedCandidates = graphicCandidates(graphics);
1824
2315
  }
2316
+ return indexedCandidates;
1825
2317
  };
1826
- }
1827
- /**
1828
- * Channel-neutral subscribe/emit machinery: a listener set with synchronous,
1829
- * exception-collecting emission (single rethrow, `AggregateError` otherwise).
1830
- * Named for its original `onCommit` channel (`TransformSession` /
1831
- * `EditSession` / `DrawSession`), it also carries the `onTransientChange`
1832
- * streams and the fixed/variable draw controllers' `onChange` — any
1833
- * multi-emit channel with no latch. Unlike `createSettleEmitter`, `emit` may
1834
- * be called more than
1835
- * once — a transform's `setGraphics` can commit several removal batches before
1836
- * the close batch — and there is no latch or late-registration path:
1837
- * registration-time `ctx.settled` guards stay in the controllers, mirroring
1838
- * `onChange`.
1839
- */
1840
- function createCommitEmitter(listenerErrorsMessage) {
1841
- const listeners = /* @__PURE__ */ new Set();
1842
- return {
1843
- emit(value) {
1844
- const current = [...listeners];
1845
- const errors = [];
1846
- for (const listener of current) try {
1847
- listener(value);
1848
- } catch (err) {
1849
- errors.push(err);
1850
- }
1851
- if (errors.length === 1) throw errors[0];
1852
- if (errors.length > 1) throw new AggregateError(errors, listenerErrorsMessage);
1853
- },
1854
- register(listener) {
1855
- listeners.add(listener);
1856
- return () => {
1857
- listeners.delete(listener);
2318
+ const geometryPaths = () => {
2319
+ const features = getGeometryFeatures();
2320
+ if (features !== indexedFeatures) {
2321
+ indexedFeatures = features;
2322
+ indexedGeometryPaths = indexGeometryFeatures(features);
2323
+ }
2324
+ return indexedGeometryPaths;
2325
+ };
2326
+ return (request) => {
2327
+ const options = getOptions();
2328
+ const clear = () => options?.onSnap?.(null);
2329
+ if (!options?.enabled || options.bypass?.(request.originalEvent)) {
2330
+ clear();
2331
+ return null;
2332
+ }
2333
+ const tolerance = options.tolerancePixels ?? DEFAULT_TOLERANCE_PIXELS;
2334
+ if (!Number.isFinite(tolerance) || tolerance < 0) {
2335
+ clear();
2336
+ return null;
2337
+ }
2338
+ const candidateLists = [];
2339
+ if (options.sources?.graphics !== false) candidateLists.push(builtInCandidates());
2340
+ const graphicGeometry = options.sources?.graphicGeometry;
2341
+ if (graphicGeometry) candidateLists.push(generatedGeometryCandidates(adapter, geometryPaths(), request, graphicGeometry));
2342
+ const external = options.sources?.external?.(request);
2343
+ if (external) candidateLists.push(external);
2344
+ let best = null;
2345
+ for (const list of candidateLists) for (const candidate of list) {
2346
+ if (isSelfCandidate(candidate, request)) continue;
2347
+ const pixel = adapter.getPixelFromCoordinate(candidate.coordinate);
2348
+ if (!pixel) continue;
2349
+ const distancePixels = Math.hypot(pixel[0] - request.pixel[0], pixel[1] - request.pixel[1]);
2350
+ if (distancePixels > tolerance) continue;
2351
+ const priority = candidate.priority ?? 0;
2352
+ const bestPriority = best?.candidate.priority ?? 0;
2353
+ if (best && (distancePixels > best.distancePixels || distancePixels === best.distancePixels && priority <= bestPriority)) continue;
2354
+ best = {
2355
+ coordinate: [...candidate.coordinate],
2356
+ pixel: [...pixel],
2357
+ candidate,
2358
+ distancePixels
1858
2359
  };
1859
2360
  }
2361
+ options.onSnap?.(best);
2362
+ return best;
1860
2363
  };
1861
2364
  }
1862
2365
  //#endregion
@@ -2557,181 +3060,47 @@ function runEditInteraction(spec) {
2557
3060
  value
2558
3061
  });
2559
3062
  return value;
2560
- });
2561
- },
2562
- fail(error) {
2563
- if (!interaction.settled) runBeforeTeardown({
2564
- status: "rejected",
2565
- error
2566
- });
2567
- interaction.fail(error);
2568
- },
2569
- writeLayer: interaction.writeLayer,
2570
- onTeardown: interaction.onTeardown
2571
- };
2572
- const onDown = (pixel, info) => {
2573
- suppressClickAway = false;
2574
- spec.onDown(pixel, info);
2575
- };
2576
- const unsubDown = driver.onPointerDown(onDown);
2577
- const unsubMove = driver.onPointerMove(spec.onMove);
2578
- const unsubUp = driver.onPointerUp(spec.onUp);
2579
- const unsubPreview = adapter.onPick(previewLayer, onPreviewPick);
2580
- const unsubHandles = adapter.onPick(handlesLayer, onHandlesPick);
2581
- const unsubGraphics = adapter.onPick(graphicsLayer, onGraphicsPick);
2582
- adapter.on("click", onClick);
2583
- interaction.onTeardown(() => {
2584
- runBeforeTeardown({
2585
- status: "rejected",
2586
- error: void 0
2587
- });
2588
- adapter.off("click", onClick);
2589
- unsubPreview();
2590
- unsubHandles();
2591
- unsubGraphics();
2592
- unsubDown();
2593
- unsubMove();
2594
- unsubUp();
2595
- spec.extraTeardown?.();
2596
- driver.dispose();
2597
- }, spec.onAbort);
2598
- spec.attach(controllerContext, driver, shell);
2599
- }
2600
- });
2601
- }
2602
- //#endregion
2603
- //#region src/tactical-draw/transform-box.ts
2604
- /**
2605
- * The uniform scale factor and rotation delta a box implies relative to a
2606
- * pre-drag box, given the gesture kind. A `body` translate reports neither
2607
- * (1, 0). Consumed both by point symbols (size/rotation) and by Text graphics
2608
- * (folding the delta into rotation/size options); the delta is pivot-
2609
- * independent so either can apply it without knowing the box center.
2610
- */
2611
- function boxScaleRotation(kind, box, preDragBox) {
2612
- return {
2613
- scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
2614
- rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
2615
- };
2616
- }
2617
- function rotate(x, y, angle) {
2618
- const cos = Math.cos(angle);
2619
- const sin = Math.sin(angle);
2620
- return [x * cos - y * sin, x * sin + y * cos];
2621
- }
2622
- /**
2623
- * Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
2624
- * Rotates the points by `-angle` about their centroid, takes the axis-aligned
2625
- * bounding box in that de-rotated frame, then re-expresses the result as an
2626
- * oriented box at `angle` — so the box always has exactly the requested tilt,
2627
- * sized to just contain the (de-rotated) points. Half-dimensions are floored
2628
- * at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
2629
- * pickable box. Returns `null` for an empty list — there is nothing to fit.
2630
- *
2631
- * `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
2632
- * the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
2633
- * `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
2634
- * the rendered geometry. Everything downstream that reads a fitted box
2635
- * (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
2636
- * `halfWidth`/`halfHeight` alone, so the padding flows through to corner
2637
- * placement, the rotate grip, and picking automatically — no other function
2638
- * needs to know about it.
2639
- */
2640
- function fitTransformBox(pixels, angle, padding = 0) {
2641
- if (pixels.length === 0) return null;
2642
- let cx = 0;
2643
- let cy = 0;
2644
- for (const p of pixels) {
2645
- cx += p[0];
2646
- cy += p[1];
2647
- }
2648
- cx /= pixels.length;
2649
- cy /= pixels.length;
2650
- let minX = Infinity;
2651
- let minY = Infinity;
2652
- let maxX = -Infinity;
2653
- let maxY = -Infinity;
2654
- for (const p of pixels) {
2655
- const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
2656
- if (rx < minX) minX = rx;
2657
- if (rx > maxX) maxX = rx;
2658
- if (ry < minY) minY = ry;
2659
- if (ry > maxY) maxY = ry;
2660
- }
2661
- const localCenterX = (minX + maxX) / 2;
2662
- const localCenterY = (minY + maxY) / 2;
2663
- const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
2664
- const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
2665
- const [ox, oy] = rotate(localCenterX, localCenterY, angle);
2666
- return {
2667
- center: [cx + ox, cy + oy],
2668
- halfWidth,
2669
- halfHeight,
2670
- angle
2671
- };
2672
- }
2673
- /**
2674
- * The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
2675
- * bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
2676
- * `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
2677
- * and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
2678
- * anchor is always the opposite corner, `(index + 2) % 4`.
2679
- */
2680
- function boxCorners(box) {
2681
- const { center, halfWidth, halfHeight, angle } = box;
2682
- return [
2683
- [-halfWidth, -halfHeight],
2684
- [halfWidth, -halfHeight],
2685
- [halfWidth, halfHeight],
2686
- [-halfWidth, halfHeight]
2687
- ].map(([x, y]) => {
2688
- const [rx, ry] = rotate(x, y, angle);
2689
- return [center[0] + rx, center[1] + ry];
2690
- });
2691
- }
2692
- /**
2693
- * Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
2694
- * above the top-edge midpoint, along the box's local "up" direction, so the
2695
- * grip stays glued to the box (rotates with it) rather than floating at a
2696
- * fixed screen offset. The label transform box passes its own tighter
2697
- * clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
2698
- */
2699
- function rotateGripPixel(box, offset = 28) {
2700
- const { center, halfHeight, angle } = box;
2701
- const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
2702
- return [center[0] + rx, center[1] + ry];
2703
- }
2704
- /**
2705
- * Point-in-oriented-rectangle test, used to start a body (translate) drag
2706
- * when the pointer goes down inside the box but not on a corner/rotate grip.
2707
- * De-rotates `pixel` into the box's local frame about `box.center` and
2708
- * compares against the half-extents.
2709
- */
2710
- function pointInBox(box, pixel) {
2711
- const { center, halfWidth, halfHeight, angle } = box;
2712
- const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
2713
- return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
2714
- }
2715
- /** Plain 2D distance between two pixel coordinates. */
2716
- function pixelDistance$1(a, b) {
2717
- return Math.hypot(a[0] - b[0], a[1] - b[1]);
2718
- }
2719
- /**
2720
- * Pick what part of the box, if any, `pixel` hits — corners first, then the
2721
- * rotate grip, then plain containment (body drag). Corners and the rotate
2722
- * grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
2723
- * click just outside the box never starts a body drag. Returns `null` when
2724
- * nothing matches.
2725
- */
2726
- function pickBoxHandle(box, pixel, tolerance) {
2727
- const corners = boxCorners(box);
2728
- for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
2729
- kind: "scale",
2730
- index: i
2731
- };
2732
- if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
2733
- if (pointInBox(box, pixel)) return { kind: "body" };
2734
- return null;
3063
+ });
3064
+ },
3065
+ fail(error) {
3066
+ if (!interaction.settled) runBeforeTeardown({
3067
+ status: "rejected",
3068
+ error
3069
+ });
3070
+ interaction.fail(error);
3071
+ },
3072
+ writeLayer: interaction.writeLayer,
3073
+ onTeardown: interaction.onTeardown
3074
+ };
3075
+ const onDown = (pixel, info) => {
3076
+ suppressClickAway = false;
3077
+ spec.onDown(pixel, info);
3078
+ };
3079
+ const unsubDown = driver.onPointerDown(onDown);
3080
+ const unsubMove = driver.onPointerMove(spec.onMove);
3081
+ const unsubUp = driver.onPointerUp(spec.onUp);
3082
+ const unsubPreview = adapter.onPick(previewLayer, onPreviewPick);
3083
+ const unsubHandles = adapter.onPick(handlesLayer, onHandlesPick);
3084
+ const unsubGraphics = adapter.onPick(graphicsLayer, onGraphicsPick);
3085
+ adapter.on("click", onClick);
3086
+ interaction.onTeardown(() => {
3087
+ runBeforeTeardown({
3088
+ status: "rejected",
3089
+ error: void 0
3090
+ });
3091
+ adapter.off("click", onClick);
3092
+ unsubPreview();
3093
+ unsubHandles();
3094
+ unsubGraphics();
3095
+ unsubDown();
3096
+ unsubMove();
3097
+ unsubUp();
3098
+ spec.extraTeardown?.();
3099
+ driver.dispose();
3100
+ }, spec.onAbort);
3101
+ spec.attach(controllerContext, driver, shell);
3102
+ }
3103
+ });
2735
3104
  }
2736
3105
  //#endregion
2737
3106
  //#region src/tactical-draw/handles.ts
@@ -5078,335 +5447,115 @@ function runEdit(opts) {
5078
5447
  onMove: onDriverMove,
5079
5448
  onUp: onDriverUp,
5080
5449
  ownsGraphic: (id) => id === measure.id,
5081
- close: () => {
5082
- ctx.commit(rebuildRendered);
5083
- },
5084
- onForeignPick: onClickAwayGraphicsPick,
5085
- historySettle: () => history.settle(),
5086
- facadeOnSettled: onSettled,
5087
- emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
5088
- extraTeardown: () => {
5089
- if (activeDrag !== null || activeOptionDrag !== null || activeBoxDrag !== null || labels.dragging) {
5090
- emitTransient("end", workingPoints, activeDrag?.controlPointIndex ?? null);
5091
- driver.setDragPanEnabled(true);
5092
- activeDrag = null;
5093
- activeOptionDrag = null;
5094
- activeBoxDrag = null;
5095
- labels.endDrag();
5096
- dragInFlight = false;
5097
- }
5098
- },
5099
- attach(interaction, createdDriver, createdShell) {
5100
- ctx = interaction;
5101
- driver = createdDriver;
5102
- shell = createdShell;
5103
- renderWorkingGraphic();
5104
- renderHandlesLayer();
5105
- recomputeDirty();
5106
- if (onSession) onSession(session);
5107
- }
5108
- });
5109
- return {
5110
- promise: core.promise,
5111
- get settled() {
5112
- return core.settled;
5113
- },
5114
- usesGraphicStack: true,
5115
- measureId: measure.id,
5116
- abort(reason, cause) {
5117
- core.abort(reason, cause);
5118
- },
5119
- notifyExternalMeasure(next) {
5120
- if (core.settled) return;
5121
- if (stylesEqual(currentStyle, next.style)) return;
5122
- currentStyle = next.style;
5123
- renderWorkingGraphic();
5124
- }
5125
- };
5126
- }
5127
- /**
5128
- * Validate and normalise an `EditOptions.modes` / `EditSession.setModes` input:
5129
- * throws `TypeError` synchronously for an unknown mode, collapses `delete` to
5130
- * an exclusive set, dedupes, and defaults to `["reshape", "transform"]`.
5131
- * Exported so the façade can hoist this validation *before* `guardAndPreempt`
5132
- * — an invalid mode must not tear down whatever edit/draw is already active
5133
- * (see the `td.edit` regression test for issue tracked alongside ADR-0017).
5134
- */
5135
- function normaliseModes(modes) {
5136
- if (!modes || modes.length === 0) return ["reshape", "transform"];
5137
- for (const m of modes) if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
5138
- if (modes.includes("delete")) return ["delete"];
5139
- const seen = /* @__PURE__ */ new Set();
5140
- const out = [];
5141
- for (const m of modes) if (!seen.has(m)) {
5142
- seen.add(m);
5143
- out.push(m);
5144
- }
5145
- return out;
5146
- }
5147
- function stylesEqual(a, b) {
5148
- if (a === b) return true;
5149
- if (!a || !b) return false;
5150
- const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
5151
- for (const key of keys) if (a[key] !== b[key]) return false;
5152
- return true;
5153
- }
5154
- function pointsEqualWithinTolerance(a, b, epsilon) {
5155
- if (a.length !== b.length) return false;
5156
- for (let i = 0; i < a.length; i++) {
5157
- const pa = a[i];
5158
- const pb = b[i];
5159
- if (pa.length !== pb.length) return false;
5160
- for (let k = 0; k < pa.length; k++) if (Math.abs((pa[k] ?? 0) - (pb[k] ?? 0)) > epsilon) return false;
5161
- }
5162
- return true;
5163
- }
5164
- //#endregion
5165
- //#region src/tactical-draw/errors.ts
5166
- /**
5167
- * Error thrown when any `TacticalDraw` method is called after `destroy()`.
5168
- * See PRD #40 / issue #45.
5169
- */
5170
- var TacticalDrawDestroyedError = class TacticalDrawDestroyedError extends Error {
5171
- constructor(message = "TacticalDraw instance has been destroyed") {
5172
- super(message);
5173
- this.name = "TacticalDrawDestroyedError";
5174
- Object.setPrototypeOf(this, TacticalDrawDestroyedError.prototype);
5175
- }
5176
- };
5177
- var PointSymbolCapabilityRequiredError = class extends Error {
5178
- constructor() {
5179
- super("TacticalDraw: point-symbol capability is not configured");
5180
- this.name = "PointSymbolCapabilityRequiredError";
5181
- }
5182
- };
5183
- var ImageCapabilityRequiredError = class ImageCapabilityRequiredError extends Error {
5184
- constructor() {
5185
- super("TacticalDraw: image capability is not configured");
5186
- this.name = "ImageCapabilityRequiredError";
5187
- Object.setPrototypeOf(this, ImageCapabilityRequiredError.prototype);
5188
- }
5189
- };
5190
- //#endregion
5191
- //#region src/point-symbol-size.ts
5192
- function effectivePointSymbolPixelSize(symbol, resolution) {
5193
- if (symbol.size.unit === "pixels") return symbol.size.value;
5194
- if (resolution === void 0 || resolution <= 0) return symbol.size.value;
5195
- const px = symbol.size.value / resolution;
5196
- const { minPixels, maxPixels } = symbol.size;
5197
- return Math.max(Number.isFinite(minPixels) && minPixels > 0 ? minPixels : 0, Math.min(Number.isFinite(maxPixels) && maxPixels > 0 ? maxPixels : Infinity, px));
5198
- }
5199
- function pointSymbolPixelScale(symbol, render, resolution) {
5200
- if (!Number.isFinite(render.octagonSize) || render.octagonSize <= 0) throw new TypeError("PointSymbolRender.octagonSize must be a positive finite number");
5201
- return effectivePointSymbolPixelSize(symbol, resolution) / render.octagonSize;
5202
- }
5203
- //#endregion
5204
- //#region src/point-symbol-offset.ts
5205
- const EPSILON = 1e-7;
5206
- const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
5207
- /** Trim from the symbol attachment end, including across a junction when necessary. */
5208
- function padAnchor(path, padding) {
5209
- if (!Number.isFinite(padding) || padding <= 0) return path;
5210
- const trimmed = [...path];
5211
- while (trimmed.length > 1) {
5212
- const start = trimmed.shift();
5213
- const end = trimmed[0];
5214
- const length = distance(start, end);
5215
- if (length > padding) {
5216
- const fraction = padding / length;
5217
- trimmed.unshift([start[0] + (end[0] - start[0]) * fraction, start[1] + (end[1] - start[1]) * fraction]);
5218
- return trimmed;
5219
- }
5220
- padding -= length;
5221
- }
5222
- return [];
5223
- }
5224
- /** Pure layout: projection input/output is in CSS pixels, including document contexts. */
5225
- function resolvePointSymbolOffset(symbol, render, view) {
5226
- const actual = view.getPixelFromCoordinate(symbol.position);
5227
- if (!actual) return null;
5228
- const scale = pointSymbolPixelScale(symbol, render, view.getResolution());
5229
- const angle = symbol.rotation - view.getBearing();
5230
- const metadata = render.offset;
5231
- const registration = metadata?.ordinaryRegistration ?? render.octagonAnchor;
5232
- const shift = rotate((registration.x - render.anchor.x) * scale, (registration.y - render.anchor.y) * scale, angle);
5233
- const ordinaryDisplay = [actual[0] + shift[0], actual[1] + shift[1]];
5234
- const layout = {
5235
- render,
5236
- resource: pointSymbolResource(render),
5237
- bodyOpacity: (symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
5238
- rotation: angle,
5239
- actual,
5240
- display: ordinaryDisplay,
5241
- imageAnchor: actual,
5242
- ordinaryDisplay,
5243
- path: [],
5244
- kind: "ordinary",
5245
- status: "ok",
5246
- scale,
5247
- connectorStyle: {
5248
- strokeColor: symbol.offset?.style?.strokeColor ?? metadata?.strokeColor ?? "#000000",
5249
- opacity: (symbol.offset?.style?.opacity ?? symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
5250
- strokeWidth: symbol.offset?.style?.strokeWidth ?? (metadata?.strokeWidth ?? 4) * scale,
5251
- strokeDash: [...symbol.offset?.style?.strokeDash ?? []]
5252
- }
5253
- };
5254
- if (!symbol.offset) return layout;
5255
- const { placement, connector, anchorPadding = 0 } = symbol.offset;
5256
- if (!metadata || connector !== "direct" && !metadata.indicator) return {
5257
- ...layout,
5258
- status: "unsupported-presentation"
5259
- };
5260
- const display = placement.mode === "ground" ? view.getPixelFromCoordinate(placement.position) : [ordinaryDisplay[0] + placement.offset[0], ordinaryDisplay[1] + placement.offset[1]];
5261
- if (!display) return {
5262
- ...layout,
5263
- status: "unsupported-presentation"
5264
- };
5265
- if (distance(display, ordinaryDisplay) < EPSILON) return layout;
5266
- const body = metadata.body;
5267
- const offset = rotate((metadata.attachment.x - body.anchor.x) * scale, (metadata.attachment.y - body.anchor.y) * scale, angle);
5268
- const attachment = [display[0] + offset[0], display[1] + offset[1]];
5269
- const resolved = {
5270
- ...layout,
5271
- render: body,
5272
- resource: pointSymbolResource(body),
5273
- display,
5274
- imageAnchor: display,
5275
- attachment
5276
- };
5277
- if (connector === "direct") return {
5278
- ...resolved,
5279
- kind: "direct",
5280
- path: padAnchor(distance(attachment, actual) < EPSILON ? [] : [attachment, actual], anchorPadding)
5281
- };
5282
- const indicator = metadata.indicator;
5283
- const rawDirection = rotate(indicator.direction.x, indicator.direction.y, angle);
5284
- const norm = Math.hypot(...rawDirection);
5285
- const direction = [rawDirection[0] / norm, rawDirection[1] / norm];
5286
- const dx = actual[0] - attachment[0], dy = actual[1] - attachment[1];
5287
- const forward = dx * direction[0] + dy * direction[1];
5288
- if (Math.abs(dx * direction[1] - dy * direction[0]) < EPSILON && forward >= (indicator.minLength ?? 0) * scale) return {
5289
- ...resolved,
5290
- direction,
5291
- kind: "indicator-only",
5292
- path: padAnchor([attachment, actual], anchorPadding)
5293
- };
5294
- const length = symbol.offset.indicatorLength !== void 0 ? symbol.offset.indicatorLength * render.octagonSize : indicator.length;
5295
- const junction = [attachment[0] + direction[0] * length * scale, attachment[1] + direction[1] * length * scale];
5450
+ close: () => {
5451
+ ctx.commit(rebuildRendered);
5452
+ },
5453
+ onForeignPick: onClickAwayGraphicsPick,
5454
+ historySettle: () => history.settle(),
5455
+ facadeOnSettled: onSettled,
5456
+ emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
5457
+ extraTeardown: () => {
5458
+ if (activeDrag !== null || activeOptionDrag !== null || activeBoxDrag !== null || labels.dragging) {
5459
+ emitTransient("end", workingPoints, activeDrag?.controlPointIndex ?? null);
5460
+ driver.setDragPanEnabled(true);
5461
+ activeDrag = null;
5462
+ activeOptionDrag = null;
5463
+ activeBoxDrag = null;
5464
+ labels.endDrag();
5465
+ dragInFlight = false;
5466
+ }
5467
+ },
5468
+ attach(interaction, createdDriver, createdShell) {
5469
+ ctx = interaction;
5470
+ driver = createdDriver;
5471
+ shell = createdShell;
5472
+ renderWorkingGraphic();
5473
+ renderHandlesLayer();
5474
+ recomputeDirty();
5475
+ if (onSession) onSession(session);
5476
+ }
5477
+ });
5296
5478
  return {
5297
- ...resolved,
5298
- direction,
5299
- junction,
5300
- kind: "with-extension",
5301
- status: "ok",
5302
- path: padAnchor(distance(junction, actual) < EPSILON ? [attachment, junction] : [
5303
- attachment,
5304
- junction,
5305
- actual
5306
- ], anchorPadding)
5479
+ promise: core.promise,
5480
+ get settled() {
5481
+ return core.settled;
5482
+ },
5483
+ usesGraphicStack: true,
5484
+ measureId: measure.id,
5485
+ abort(reason, cause) {
5486
+ core.abort(reason, cause);
5487
+ },
5488
+ notifyExternalMeasure(next) {
5489
+ if (core.settled) return;
5490
+ if (stylesEqual(currentStyle, next.style)) return;
5491
+ currentStyle = next.style;
5492
+ renderWorkingGraphic();
5493
+ }
5307
5494
  };
5308
5495
  }
5309
5496
  /**
5310
- * Authored indicator length that puts the junction at `point`: the forward
5311
- * projection onto the attachment ray, expressed as a multiple of the displayed
5312
- * octagon size and clamped to a non-negative length.
5497
+ * Validate and normalise an `EditOptions.modes` / `EditSession.setModes` input:
5498
+ * throws `TypeError` synchronously for an unknown mode, collapses `delete` to
5499
+ * an exclusive set, dedupes, and defaults to `["reshape", "transform"]`.
5500
+ * Exported so the façade can hoist this validation *before* `guardAndPreempt`
5501
+ * — an invalid mode must not tear down whatever edit/draw is already active
5502
+ * (see the `td.edit` regression test for issue tracked alongside ADR-0017).
5313
5503
  */
5314
- function indicatorLengthAt(point, attachment, direction, scale, octagonSize) {
5315
- const forward = (point[0] - attachment[0]) * direction[0] + (point[1] - attachment[1]) * direction[1];
5316
- return Math.max(0, forward / (scale * octagonSize));
5317
- }
5318
- /** Set body placement without changing the authoritative coordinate. */
5319
- function placePointSymbol(symbol, render, view, display, mode = symbol.offset?.placement.mode ?? "screen-relative") {
5320
- if (!render.offset) throw new TypeError("This renderer does not support offset placement");
5321
- const ordinary = resolvePointSymbolOffset({
5322
- ...symbol,
5323
- offset: void 0
5324
- }, render, view);
5325
- if (!ordinary) throw new RangeError("Point symbol cannot be projected in this view");
5326
- const next = clonePointSymbol(symbol);
5327
- const coordinate = mode === "ground" ? view.getCoordinateFromPixel?.(display) : void 0;
5328
- if (mode === "ground" && !coordinate) throw new RangeError("Ground placement requires inverse projection");
5329
- next.offset = {
5330
- ...next.offset,
5331
- connector: next.offset?.connector ?? (render.offset.indicator ? "auto" : "direct"),
5332
- placement: mode === "ground" ? {
5333
- mode,
5334
- position: [...coordinate]
5335
- } : {
5336
- mode,
5337
- offset: [display[0] - ordinary.display[0], display[1] - ordinary.display[1]]
5338
- }
5339
- };
5340
- return next;
5341
- }
5342
- function convertPointSymbolPlacement(symbol, render, view, mode) {
5343
- const layout = resolvePointSymbolOffset(symbol, render, view);
5344
- if (!layout || layout.status === "unsupported-presentation") throw new TypeError("Offset presentation is unavailable");
5345
- return placePointSymbol(symbol, render, view, layout.display, mode);
5346
- }
5347
- /** Explicit alignment, also used with hysteresis by pointer editing. */
5348
- function alignPointSymbolIndicator(symbol, render, view) {
5349
- const layout = resolvePointSymbolOffset(symbol, render, view);
5350
- if (!layout?.attachment || !layout.direction || !render.offset?.indicator) return symbol;
5351
- const d = layout.direction;
5352
- const delta = [layout.actual[0] - layout.attachment[0], layout.actual[1] - layout.attachment[1]];
5353
- const forward = Math.max((render.offset.indicator.minLength ?? 0) * layout.scale, delta[0] * d[0] + delta[1] * d[1]);
5354
- return placePointSymbol(symbol, render, view, [layout.display[0] + delta[0] - forward * d[0], layout.display[1] + delta[1] - forward * d[1]]);
5504
+ function normaliseModes(modes) {
5505
+ if (!modes || modes.length === 0) return ["reshape", "transform"];
5506
+ for (const m of modes) if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
5507
+ if (modes.includes("delete")) return ["delete"];
5508
+ const seen = /* @__PURE__ */ new Set();
5509
+ const out = [];
5510
+ for (const m of modes) if (!seen.has(m)) {
5511
+ seen.add(m);
5512
+ out.push(m);
5513
+ }
5514
+ return out;
5355
5515
  }
5356
- function movePointSymbolLocation(symbol, render, view, position) {
5357
- const before = resolvePointSymbolOffset(symbol, render, view);
5358
- let next = {
5359
- ...clonePointSymbol(symbol),
5360
- position: [...position]
5361
- };
5362
- if (!symbol.offset || !before || before.status === "unsupported-presentation") return next;
5363
- next = placePointSymbol(next, render, view, before.display);
5364
- const after = resolvePointSymbolOffset(next, render, view);
5365
- if (before.junction && after?.attachment && after.direction && symbol.offset.indicatorLength !== void 0) next.offset.indicatorLength = indicatorLengthAt(before.junction, after.attachment, after.direction, after.scale, render.octagonSize);
5366
- return next;
5516
+ function stylesEqual(a, b) {
5517
+ if (a === b) return true;
5518
+ if (!a || !b) return false;
5519
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
5520
+ for (const key of keys) if (a[key] !== b[key]) return false;
5521
+ return true;
5367
5522
  }
5368
- /** Canonical resource metadata shared by native adapters and external exporters. */
5369
- function pointSymbolResource(render) {
5370
- return {
5371
- id: render.resourceId,
5372
- svg: render.svg,
5373
- width: render.width,
5374
- height: render.height,
5375
- anchor: [render.anchor.x, render.anchor.y],
5376
- coreRect: {
5377
- x: render.octagonAnchor.x - render.octagonSize / 2,
5378
- y: render.octagonAnchor.y - render.octagonSize / 2,
5379
- width: render.octagonSize,
5380
- height: render.octagonSize
5381
- }
5382
- };
5523
+ function pointsEqualWithinTolerance(a, b, epsilon) {
5524
+ if (a.length !== b.length) return false;
5525
+ for (let i = 0; i < a.length; i++) {
5526
+ const pa = a[i];
5527
+ const pb = b[i];
5528
+ if (pa.length !== pb.length) return false;
5529
+ for (let k = 0; k < pa.length; k++) if (Math.abs((pa[k] ?? 0) - (pb[k] ?? 0)) > epsilon) return false;
5530
+ }
5531
+ return true;
5383
5532
  }
5533
+ //#endregion
5534
+ //#region src/tactical-draw/errors.ts
5384
5535
  /**
5385
- * Convert a resolved CSS-pixel layout to output units (e.g. 25.4 / 96 for mm).
5386
- * Scales positions, body scale, line width and dashes together, including the
5387
- * already-resolved offset and padding. Canonical resources, direction, rotation
5388
- * and opacity stay unchanged. Does not mutate the input or authored symbol.
5536
+ * Error thrown when any `TacticalDraw` method is called after `destroy()`.
5537
+ * See PRD #40 / issue #45.
5389
5538
  */
5390
- function scalePointSymbolLayout(layout, factor) {
5391
- if (!Number.isFinite(factor) || factor <= 0) throw new RangeError("Layout scale factor must be positive and finite");
5392
- const point = ([x, y]) => [x * factor, y * factor];
5393
- return {
5394
- ...layout,
5395
- actual: point(layout.actual),
5396
- display: point(layout.display),
5397
- imageAnchor: point(layout.imageAnchor),
5398
- ordinaryDisplay: point(layout.ordinaryDisplay),
5399
- attachment: layout.attachment && point(layout.attachment),
5400
- junction: layout.junction && point(layout.junction),
5401
- path: layout.path.map(point),
5402
- scale: layout.scale * factor,
5403
- connectorStyle: {
5404
- ...layout.connectorStyle,
5405
- strokeWidth: layout.connectorStyle.strokeWidth * factor,
5406
- strokeDash: layout.connectorStyle.strokeDash.map((length) => length * factor)
5407
- }
5408
- };
5409
- }
5539
+ var TacticalDrawDestroyedError = class TacticalDrawDestroyedError extends Error {
5540
+ constructor(message = "TacticalDraw instance has been destroyed") {
5541
+ super(message);
5542
+ this.name = "TacticalDrawDestroyedError";
5543
+ Object.setPrototypeOf(this, TacticalDrawDestroyedError.prototype);
5544
+ }
5545
+ };
5546
+ var PointSymbolCapabilityRequiredError = class extends Error {
5547
+ constructor() {
5548
+ super("TacticalDraw: point-symbol capability is not configured");
5549
+ this.name = "PointSymbolCapabilityRequiredError";
5550
+ }
5551
+ };
5552
+ var ImageCapabilityRequiredError = class ImageCapabilityRequiredError extends Error {
5553
+ constructor() {
5554
+ super("TacticalDraw: image capability is not configured");
5555
+ this.name = "ImageCapabilityRequiredError";
5556
+ Object.setPrototypeOf(this, ImageCapabilityRequiredError.prototype);
5557
+ }
5558
+ };
5410
5559
  //#endregion
5411
5560
  //#region src/tactical-draw/point-symbol-rendering.ts
5412
5561
  /** Complete rotated logical footprint for selection and transform chrome. */
@@ -6626,6 +6775,8 @@ function runPointSymbolEdit(opts) {
6626
6775
  const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, settleToGraphics, publishWorkingGraphic, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
6627
6776
  let modes = normalisePointModes(opts.modes ?? (symbol.offset ? ["transform", "offset"] : ["transform"]));
6628
6777
  let usesGraphicStack = wasOnGraphicsLayer;
6778
+ let pinEditing = !!opts.renderPin && opts.modes === void 0;
6779
+ let pinDrag = null;
6629
6780
  let working = clonePointSymbol(symbol);
6630
6781
  let dirty = false;
6631
6782
  let box = null;
@@ -6650,6 +6801,23 @@ function runPointSymbolEdit(opts) {
6650
6801
  });
6651
6802
  const render = () => {
6652
6803
  if (ctx.settled) return;
6804
+ if (pinEditing && opts.renderPin && opts.canRenderPin?.()) {
6805
+ const group = opts.renderPin(working);
6806
+ publishWorkingGraphic(group);
6807
+ ctx.writeLayer(previewLayer, usesGraphicStack ? [] : [group]);
6808
+ ctx.writeLayer(handlesLayer, group.pin ? [{
6809
+ id: `${handleIdPrefix}:pin`,
6810
+ features: [],
6811
+ iconResources: [],
6812
+ pin: {
6813
+ ...group.pin,
6814
+ highlightOnly: true
6815
+ }
6816
+ }] : []);
6817
+ currentLayout = null;
6818
+ box = null;
6819
+ return;
6820
+ }
6653
6821
  const plan = renderPointSymbolForAdapter(adapter, capability, working);
6654
6822
  currentLayout = plan.layout;
6655
6823
  const nextBox = symbolBox(adapter, working, plan.render, interactionStyle?.boxPadding);
@@ -6792,6 +6960,7 @@ function runPointSymbolEdit(opts) {
6792
6960
  setModes(next) {
6793
6961
  if (ctx.settled) return;
6794
6962
  modes = normalisePointModes(next);
6963
+ pinEditing = false;
6795
6964
  render();
6796
6965
  },
6797
6966
  updateGraphic(updater) {
@@ -6815,6 +6984,18 @@ function runPointSymbolEdit(opts) {
6815
6984
  };
6816
6985
  const pointerAngle = (pixel, anchor) => Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
6817
6986
  const onDown = (pixel, info) => {
6987
+ if (pinEditing && opts.canRenderPin?.() && !ctx.settled) {
6988
+ const footprint = adapter.getPinFootprint?.(working.id);
6989
+ if (footprint && adapter.pickPin?.(working.id, pixel, info?.hitTolerance ?? 12)) {
6990
+ pinDrag = {
6991
+ start: clonePointSymbol(working),
6992
+ down: [...pixel],
6993
+ attachment: footprint.attachment
6994
+ };
6995
+ driver.setDragPanEnabled(false);
6996
+ }
6997
+ return;
6998
+ }
6818
6999
  if (ctx.settled || !box || !currentLayout || modes.includes("delete")) return;
6819
7000
  let offsetMode = modes.includes("offset");
6820
7001
  if (!offsetMode && !modes.includes("transform")) return;
@@ -6849,6 +7030,13 @@ function runPointSymbolEdit(opts) {
6849
7030
  driver.setDragPanEnabled(false);
6850
7031
  };
6851
7032
  const transformed = (pixel, info) => {
7033
+ if (pinDrag) {
7034
+ const position = adapter.movePinFromPixel?.(working.id, [pinDrag.attachment[0] + pixel[0] - pinDrag.down[0], pinDrag.attachment[1] + pixel[1] - pinDrag.down[1]]);
7035
+ return position ? {
7036
+ ...clonePointSymbol(pinDrag.start),
7037
+ position
7038
+ } : null;
7039
+ }
6852
7040
  if (!drag) return null;
6853
7041
  let next = clonePointSymbol(drag.start);
6854
7042
  const intrinsic = drag.render;
@@ -6905,6 +7093,16 @@ function runPointSymbolEdit(opts) {
6905
7093
  render();
6906
7094
  };
6907
7095
  const onUp = (pixel, info) => {
7096
+ if (pinDrag) {
7097
+ const next = transformed(pixel, info), start = pinDrag.start, down = pinDrag.down;
7098
+ pinDrag = null;
7099
+ driver.setDragPanEnabled(true);
7100
+ shell.suppressNextClickAway();
7101
+ working = clonePointSymbol(start);
7102
+ if (next && !withinClickSlop(pixel, down)) apply(next, "gesture", "Move unit");
7103
+ else render();
7104
+ return;
7105
+ }
6908
7106
  if (!drag) return;
6909
7107
  const currentDrag = drag;
6910
7108
  const next = transformed(pixel, info);
@@ -6927,7 +7125,9 @@ function runPointSymbolEdit(opts) {
6927
7125
  previewLayer,
6928
7126
  handlesLayer,
6929
7127
  closeOnClickAway,
6930
- onViewChange: render,
7128
+ onViewChange: () => {
7129
+ if (!pinEditing || !opts.canRenderPin?.()) render();
7130
+ },
6931
7131
  onBeforeTeardown: (settlement) => {
6932
7132
  settleToGraphics(settlement.status === "committed" ? settlement.value.graphic : void 0);
6933
7133
  },
@@ -6952,6 +7152,7 @@ function runPointSymbolEdit(opts) {
6952
7152
  });
6953
7153
  return {
6954
7154
  promise: handle.promise,
7155
+ refreshPresentation: render,
6955
7156
  get settled() {
6956
7157
  return handle.settled;
6957
7158
  },
@@ -7449,6 +7650,9 @@ var TacticalDraw = class {
7449
7650
  * `render()` so outlines track geometry, style, and dropped graphics.
7450
7651
  */
7451
7652
  highlightedIds = [];
7653
+ pinBodies = /* @__PURE__ */ new WeakMap();
7654
+ pins = new PinPresentationStore();
7655
+ unsubscribePins;
7452
7656
  /**
7453
7657
  * Whether the highlight layer currently holds any outline. Lets
7454
7658
  * `renderHighlights` skip a redundant empty write on every view change /
@@ -7484,6 +7688,54 @@ var TacticalDraw = class {
7484
7688
  this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(this.baseGuideStyle()));
7485
7689
  this.adapter.setLayerStyle(this.highlightLayer, guideToStyleOptions(this.baseHighlightStyle()));
7486
7690
  this.adapter.onViewChange(this.handleViewChange);
7691
+ this.unsubscribePins = this.pins.onChange(() => {
7692
+ this.invalidateGraphicGroupCache(false);
7693
+ this.refreshActiveEditPresentation();
7694
+ this.reconcileGraphicsStack();
7695
+ this.renderHighlights();
7696
+ });
7697
+ }
7698
+ setPinPresentation(defaults) {
7699
+ this.assertAlive();
7700
+ this.pins.setDefaults(defaults);
7701
+ }
7702
+ setPinOverride(id, patch) {
7703
+ this.assertAlive();
7704
+ this.pins.setOverride(id, patch);
7705
+ }
7706
+ resetPinOverride(id) {
7707
+ this.assertAlive();
7708
+ this.pins.resetOverride(id);
7709
+ }
7710
+ getPinPresentationCapability(id) {
7711
+ return this.adapter.getPinPresentationCapability?.(id) ?? {
7712
+ status: "unsupported",
7713
+ reason: "This map adapter uses ordinary point-symbol presentation"
7714
+ };
7715
+ }
7716
+ onPinPresentationStatusChange(listener) {
7717
+ this.assertAlive();
7718
+ return this.adapter.onPinPresentationStatusChange?.(listener) ?? (() => {});
7719
+ }
7720
+ getPinPresentationState() {
7721
+ return this.pins.getState();
7722
+ }
7723
+ /**
7724
+ * Let an in-flight edit session re-read pin presentation, when it is a kind
7725
+ * of session that renders pins at all. Both the presentation-store change
7726
+ * handler and the adapter-capability change in `handleViewChange` need it.
7727
+ */
7728
+ refreshActiveEditPresentation() {
7729
+ if (this.activeEdit && "refreshPresentation" in this.activeEdit) this.activeEdit.refreshPresentation();
7730
+ }
7731
+ /**
7732
+ * Whether a point symbol should render as a billboard pin rather than an
7733
+ * ordinary point symbol: its own presentation opts in, and the adapter
7734
+ * currently supports rendering pins. Pass an already-resolved
7735
+ * `presentation` to skip re-resolving it when a caller has one in hand.
7736
+ */
7737
+ graphicUsesPinPresentation(graphic, presentation) {
7738
+ return (presentation ?? this.pins.resolve(graphic.id)).enabled && this.adapter.getPinPresentationCapability?.().status === "supported";
7487
7739
  }
7488
7740
  /** Change the default shortcut for subsequent drags, including in an active edit. */
7489
7741
  setOffsetDragModifier(modifier) {
@@ -7523,9 +7775,10 @@ var TacticalDraw = class {
7523
7775
  if (cached.dependence === "none") return cached.group;
7524
7776
  if (cached.dependence === "resolution" && Object.is(cached.resolution, this.adapter.getResolution())) return cached.group;
7525
7777
  }
7526
- const dependence = graphicViewDependence(graphic, this.adapter.scalesGroundPointSymbolsNatively === true);
7778
+ const presentation = isPointSymbol(graphic) ? this.pins.resolve(graphic.id) : void 0;
7779
+ const dependence = presentation && this.graphicUsesPinPresentation(graphic, presentation) ? "none" : graphicViewDependence(graphic, this.adapter.scalesGroundPointSymbolsNatively === true);
7527
7780
  const resolution = dependence === "none" ? void 0 : this.adapter.getResolution();
7528
- const group = this.buildGraphicGroup(graphic);
7781
+ const group = this.buildGraphicGroup(graphic, presentation);
7529
7782
  this.graphicGroupCache.set(graphic, {
7530
7783
  group,
7531
7784
  dependence,
@@ -7538,10 +7791,49 @@ var TacticalDraw = class {
7538
7791
  * drawn from the module-scoped {@link nextGraphicGroupRevision} counter (see
7539
7792
  * its doc comment for why it isn't a per-instance field).
7540
7793
  */
7541
- buildGraphicGroup(graphic) {
7794
+ buildGraphicGroup(graphic, resolved) {
7542
7795
  const revision = ++nextGraphicGroupRevision;
7543
7796
  if (isPointSymbol(graphic)) {
7544
7797
  if (!this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
7798
+ const presentation = resolved ?? this.pins.resolve(graphic.id);
7799
+ if (this.graphicUsesPinPresentation(graphic, presentation)) {
7800
+ const body = this.pinBodies.get(graphic) ?? this.pointSymbols.render({
7801
+ ...graphic,
7802
+ graphicModifiers: {
7803
+ ...graphic.graphicModifiers,
7804
+ direction: void 0
7805
+ }
7806
+ });
7807
+ this.pinBodies.set(graphic, body);
7808
+ const resource = pointSymbolResource(body);
7809
+ return {
7810
+ id: graphic.id,
7811
+ revision,
7812
+ features: [{
7813
+ type: "Feature",
7814
+ id: `${graphic.id}:pin`,
7815
+ geometry: {
7816
+ type: "Point",
7817
+ coordinates: [...graphic.position]
7818
+ },
7819
+ properties: {
7820
+ graphicId: graphic.id,
7821
+ graphicKind: "point-symbol",
7822
+ pinPresentation: true
7823
+ }
7824
+ }],
7825
+ iconResources: [],
7826
+ pin: {
7827
+ id: graphic.id,
7828
+ position: graphic.position,
7829
+ resource,
7830
+ octagonSize: body.octagonSize,
7831
+ presentation,
7832
+ opacity: (graphic.style?.opacity ?? 1) * (graphic.style?.overallOpacity ?? 1),
7833
+ ...graphic.graphicModifiers?.direction !== void 0 ? { direction: graphic.rotation + graphic.graphicModifiers.direction } : {}
7834
+ }
7835
+ };
7836
+ }
7545
7837
  const plan = renderPointSymbolForAdapter(this.adapter, this.pointSymbols, graphic);
7546
7838
  return {
7547
7839
  id: graphic.id,
@@ -7577,7 +7869,8 @@ var TacticalDraw = class {
7577
7869
  * View changes are handled per entry by {@link renderGraphicGroup} so
7578
7870
  * resolution-independent graphics remain cache hits.
7579
7871
  */
7580
- invalidateGraphicGroupCache() {
7872
+ invalidateGraphicGroupCache(clearBodies = true) {
7873
+ if (clearBodies) this.pinBodies = /* @__PURE__ */ new WeakMap();
7581
7874
  this.graphicGroupCache = /* @__PURE__ */ new WeakMap();
7582
7875
  }
7583
7876
  /** Refresh capability-dependent renders before any caller-visible state is mutated. */
@@ -7619,8 +7912,16 @@ var TacticalDraw = class {
7619
7912
  * point symbols and transform members already present in the host stack do
7620
7913
  * the same. Graphics without a host slot remain on preview.
7621
7914
  */
7915
+ pinSupport;
7622
7916
  handleViewChange = () => {
7623
7917
  if (this.destroyed) return;
7918
+ const support = this.adapter.getPinPresentationCapability?.().status;
7919
+ if (support !== this.pinSupport) {
7920
+ this.pinSupport = support;
7921
+ this.refreshActiveEditPresentation();
7922
+ this.invalidateGraphicGroupCache();
7923
+ if (this.hasRendered) this.reconcileGraphicsStack();
7924
+ }
7624
7925
  const resolution = this.adapter.getResolution();
7625
7926
  const resolutionChanged = !Object.is(resolution, this.lastViewResolution);
7626
7927
  this.lastViewResolution = resolution;
@@ -7630,7 +7931,7 @@ var TacticalDraw = class {
7630
7931
  if (!resolutionChanged && !this.lastRenderedHasViewDependent) return;
7631
7932
  const excluded = this.graphicsStackExcludedIds();
7632
7933
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
7633
- if (!visible.some((g) => graphicViewDependence(g, nativePointSymbolScaling) !== "none")) return;
7934
+ if (!visible.some((g) => this.renderGraphicGroup(g).pin ? false : graphicViewDependence(g, nativePointSymbolScaling) !== "none")) return;
7634
7935
  this.reconcileGraphicsStack(visible);
7635
7936
  };
7636
7937
  /**
@@ -7714,7 +8015,7 @@ var TacticalDraw = class {
7714
8015
  }
7715
8016
  }
7716
8017
  this.lastRenderedMeasures = measures.slice();
7717
- this.lastRenderedHasViewDependent = this.lastRenderedMeasures.some((g) => graphicViewDependence(g, this.adapter.scalesGroundPointSymbolsNatively === true) === "view");
8018
+ this.lastRenderedHasViewDependent = this.lastRenderedMeasures.some((g) => !this.renderGraphicGroup(g).pin && graphicViewDependence(g, this.adapter.scalesGroundPointSymbolsNatively === true) === "view");
7718
8019
  this.graphicStack.acknowledgeHostRender(measures);
7719
8020
  if (activeEdit && "syncHostMembership" in activeEdit && measures.some((graphic) => graphic.id === activeEdit.measureId && graphic.kind === activeEdit.graphicKind)) activeEdit.syncHostMembership(true);
7720
8021
  (this.activeTransform ?? this.activeTransformSessionRef)?.syncHostGraphics(measures);
@@ -7769,10 +8070,26 @@ var TacticalDraw = class {
7769
8070
  const excluded = this.excludedInteractionIds();
7770
8071
  const bearing = this.adapter.getBearing();
7771
8072
  const features = [];
8073
+ const pinGroups = [];
7772
8074
  for (const id of this.highlightedIds) {
7773
8075
  if (excluded.has(id)) continue;
7774
8076
  const graphic = this.lastRenderedMeasures.find((g) => g.id === id);
7775
8077
  if (!graphic) continue;
8078
+ if (isPointSymbol(graphic)) {
8079
+ const pin = this.renderGraphicGroup(graphic).pin;
8080
+ if (pin) {
8081
+ pinGroups.push({
8082
+ id: `__td_highlight__:${id}`,
8083
+ features: [],
8084
+ iconResources: [],
8085
+ pin: {
8086
+ ...pin,
8087
+ highlightOnly: true
8088
+ }
8089
+ });
8090
+ continue;
8091
+ }
8092
+ }
7776
8093
  const box = this.highlightBoxForGraphic(graphic, bearing, padding);
7777
8094
  if (!box) continue;
7778
8095
  let invertFailed = false;
@@ -7787,12 +8104,12 @@ var TacticalDraw = class {
7787
8104
  const feature = boxOutlineFeature(box, invert, `__td_highlight__:${id}`, style);
7788
8105
  if (!invertFailed) features.push(feature);
7789
8106
  }
7790
- this.adapter.setLayerFeatureGroups(this.highlightLayer, features.map((feature) => ({
8107
+ this.adapter.setLayerFeatureGroups(this.highlightLayer, [...pinGroups, ...features.map((feature) => ({
7791
8108
  id: String(feature.id),
7792
8109
  features: [feature],
7793
8110
  iconResources: []
7794
- })));
7795
- this.hasHighlightOutlines = features.length > 0;
8111
+ }))]);
8112
+ this.hasHighlightOutlines = features.length > 0 || pinGroups.length > 0;
7796
8113
  }
7797
8114
  /**
7798
8115
  * Fit the passive-highlight transform box for one graphic: the point-symbol
@@ -8066,6 +8383,10 @@ var TacticalDraw = class {
8066
8383
  adapter: this.adapter,
8067
8384
  capability: this.pointSymbols,
8068
8385
  symbol: measure,
8386
+ ...this.graphicUsesPinPresentation(measure) ? {
8387
+ renderPin: (symbol) => this.buildGraphicGroup(symbol),
8388
+ canRenderPin: () => this.graphicUsesPinPresentation(measure)
8389
+ } : {},
8069
8390
  offsetDragModifier: () => options?.offsetDragModifier ?? this.offsetDragModifier,
8070
8391
  graphicsLayer: this.graphicsLayer,
8071
8392
  previewLayer: this.previewLayer,
@@ -8432,6 +8753,7 @@ var TacticalDraw = class {
8432
8753
  * the first call every public method throws `TacticalDrawDestroyedError`.
8433
8754
  */
8434
8755
  destroy() {
8756
+ this.unsubscribePins();
8435
8757
  if (this.destroyed) return;
8436
8758
  this.destroyed = true;
8437
8759
  if (this.activeInteraction.isActive) {
@@ -8706,32 +9028,22 @@ function fillPatternImageDimension(pixelRatio) {
8706
9028
  return 2 ** Math.ceil(Math.log2(target));
8707
9029
  }
8708
9030
  /**
8709
- * Parse a hex (`#rgb`, `#rrggbb`, `#rrggbbaa`) or `rgb()`/`rgba()` color
8710
- * string into normalized channels, alpha in `[0, 1]`. Unparseable input falls
8711
- * back to opaque black rather than throwing, since a mis-themed fill should
8712
- * still render *something*.
9031
+ * Parse a hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) or `rgb()`/`rgba()`
9032
+ * color string into normalized channels, alpha in `[0, 1]`. Unparseable input
9033
+ * falls back to opaque black rather than throwing, since a mis-themed fill
9034
+ * should still render *something*. Shared by every renderer that needs CSS
9035
+ * colors as numbers — hatch fills here, billboard pins in the MapLibre
9036
+ * adapter — so the accepted syntax stays identical across them.
8713
9037
  */
8714
9038
  function parseColor(color) {
8715
9039
  const s = color.trim();
8716
9040
  if (s.startsWith("#")) {
8717
- const hex = s.slice(1);
8718
- if (hex.length === 3) return {
8719
- r: parseInt(hex[0] + hex[0], 16),
8720
- g: parseInt(hex[1] + hex[1], 16),
8721
- b: parseInt(hex[2] + hex[2], 16),
8722
- a: 1
8723
- };
8724
- if (hex.length === 6) return {
8725
- r: parseInt(hex.slice(0, 2), 16),
8726
- g: parseInt(hex.slice(2, 4), 16),
8727
- b: parseInt(hex.slice(4, 6), 16),
8728
- a: 1
8729
- };
8730
- if (hex.length === 8) return {
9041
+ const hex = s.length <= 5 ? s.slice(1).replace(/./g, (c) => c + c) : s.slice(1);
9042
+ if (hex.length === 6 || hex.length === 8) return {
8731
9043
  r: parseInt(hex.slice(0, 2), 16),
8732
9044
  g: parseInt(hex.slice(2, 4), 16),
8733
9045
  b: parseInt(hex.slice(4, 6), 16),
8734
- a: parseInt(hex.slice(6, 8), 16) / 255
9046
+ a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1
8735
9047
  };
8736
9048
  }
8737
9049
  const fn = s.match(/^rgba?\(([^)]+)\)$/i);
@@ -8936,4 +9248,4 @@ function nearestWorldCopyPosition(position, refLng) {
8936
9248
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
8937
9249
  }
8938
9250
  //#endregion
8939
- export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, ImageCapabilityRequiredError, MINE_PATTERN_TILE_SIZE, PointSymbolCapabilityRequiredError, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, alignPointSymbolIndicator, bindAbortable, cloneImageGraphic, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, convertPointSymbolPlacement, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, imageFootprint, imageOpacity, isAnchoredGraphic, isImageGraphic, isMinePattern, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, movePointSymbolLocation, nearestWorldCopyLng, nearestWorldCopyPosition, normalizeImageGraphic, pickHitTolerance, pickPointerType, placePointSymbol, pointSymbolFootprint, pointSymbolResource, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolvePointSymbolOffset, resolveTextHaloWidth, resolveTextSizePixels, scalePointSymbolLayout, tryControlMeasureIdFromFeature, validateImageGraphic };
9251
+ export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_PIN_PRESENTATION, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, ImageCapabilityRequiredError, MINE_PATTERN_TILE_SIZE, PinPresentationStore, PointSymbolCapabilityRequiredError, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, alignPointSymbolIndicator, bindAbortable, cloneImageGraphic, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, convertPointSymbolPlacement, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, imageFootprint, imageOpacity, isAnchoredGraphic, isImageGraphic, isMinePattern, isPointSymbol, isTacticalDrawAbortError, isValidPinNumber, makeFillPatternImage, mergeNativeAndCachedPickFeatures, movePointSymbolLocation, nearestWorldCopyLng, nearestWorldCopyPosition, normalizeImageGraphic, parseColor, pickHitTolerance, pickPointerType, placePointSymbol, pointSymbolFootprint, pointSymbolResource, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolvePinPresentation, resolvePointSymbolOffset, resolveTextHaloWidth, resolveTextSizePixels, scalePointSymbolLayout, tryControlMeasureIdFromFeature, validateImageGraphic };