@orbat-mapper/tactical-draw 0.8.2 → 0.9.1

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] } : {},
@@ -828,33 +829,791 @@ var PointerCallbackHub = class {
828
829
  }
829
830
  };
830
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
831
1236
  //#region src/point-symbol.ts
832
1237
  function isPointSymbol(graphic) {
833
1238
  return typeof graphic === "object" && graphic !== null && "kind" in graphic && graphic.kind === "point-symbol";
834
1239
  }
835
- function clonePointSymbol(symbol) {
836
- 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
+ });
837
1367
  }
838
1368
  /**
839
- * Replace a point-symbol size value as a user-driven resize. Ground-size display
840
- * bounds scale with the value so they continue to constrain zoom changes rather
841
- * than preventing the resize itself.
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`).
842
1374
  */
843
- function resizePointSymbolSize(size, value) {
844
- if (size.unit === "pixels") return {
845
- value,
846
- unit: "pixels"
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
847
1407
  };
848
- const scale = value / size.value;
849
- if (!Number.isFinite(scale) || scale <= 0) return {
850
- ...size,
851
- value
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
+ };
1517
+ }
1518
+ /**
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.
1522
+ */
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
+ }
1548
+ };
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]
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) {
1579
+ return {
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
+ }
852
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];
853
1602
  return {
854
- ...size,
855
- value,
856
- ...size.minPixels !== void 0 ? { minPixels: size.minPixels * scale } : {},
857
- ...size.maxPixels !== void 0 ? { maxPixels: size.maxPixels * scale } : {}
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
+ }
858
1617
  };
859
1618
  }
860
1619
  //#endregion
@@ -968,84 +1727,6 @@ var ActiveInteractionTracker = class {
968
1727
  }
969
1728
  };
970
1729
  //#endregion
971
- //#region src/tactical-draw/abort.ts
972
- const ABORT_REASONS = new Set([
973
- "escape",
974
- "signal",
975
- "preempted",
976
- "destroyed",
977
- "session",
978
- "removed"
979
- ]);
980
- /**
981
- * Façade abort error. Extends `DOMException` with `name: "AbortError"` so it
982
- * still matches platform `AbortSignal` semantics and `instanceof DOMException`.
983
- */
984
- var TacticalDrawAbortError = class TacticalDrawAbortError extends DOMException {
985
- reason;
986
- constructor(reason, options) {
987
- if (!ABORT_REASONS.has(reason)) throw new TypeError(`Invalid TacticalDrawAbortReason: ${String(reason)}`);
988
- super(options?.message ?? `TacticalDraw aborted (${reason})`, "AbortError");
989
- Object.setPrototypeOf(this, TacticalDrawAbortError.prototype);
990
- this.reason = reason;
991
- if (options && "cause" in options) Object.defineProperty(this, "cause", {
992
- value: options.cause,
993
- writable: true,
994
- configurable: true,
995
- enumerable: false
996
- });
997
- }
998
- };
999
- function isTacticalDrawAbortError(error) {
1000
- return error instanceof TacticalDrawAbortError;
1001
- }
1002
- /**
1003
- * Swallow façade `TacticalDrawAbortError` *and* plain DOM `AbortError`s;
1004
- * rethrow every other value unchanged. Designed for use as
1005
- * `promise.catch(ignoreAbort)`.
1006
- */
1007
- function ignoreAbort(error) {
1008
- if (isTacticalDrawAbortError(error)) return;
1009
- if (error instanceof DOMException && error.name === "AbortError") return;
1010
- throw error;
1011
- }
1012
- /**
1013
- * Compose the façade's internal abort controller with an optional host
1014
- * `AbortSignal`. Host aborts surface as `reason: "signal"` with the host's
1015
- * `signal.reason` on `error.cause`. An already-aborted host signal triggers
1016
- * a next-microtask abort (never synchronous), so callers can register await
1017
- * handlers before the rejection fires.
1018
- */
1019
- function combineWithHostSignal(hostSignal) {
1020
- const controller = new AbortController();
1021
- let disposed = false;
1022
- const abortFromHost = () => {
1023
- if (controller.signal.aborted) return;
1024
- const cause = hostSignal?.reason;
1025
- controller.abort(new TacticalDrawAbortError("signal", { cause }));
1026
- };
1027
- const dispose = () => {
1028
- if (disposed) return;
1029
- disposed = true;
1030
- if (hostSignal) hostSignal.removeEventListener("abort", abortFromHost);
1031
- };
1032
- if (hostSignal) if (hostSignal.aborted) queueMicrotask(abortFromHost);
1033
- else hostSignal.addEventListener("abort", abortFromHost, { once: true });
1034
- return {
1035
- signal: controller.signal,
1036
- abort(reason, cause) {
1037
- if (controller.signal.aborted) {
1038
- dispose();
1039
- return;
1040
- }
1041
- const options = arguments.length > 1 ? { cause } : void 0;
1042
- controller.abort(new TacticalDrawAbortError(reason, options));
1043
- dispose();
1044
- },
1045
- dispose
1046
- };
1047
- }
1048
- //#endregion
1049
1730
  //#region src/tactical-draw/measure-options.ts
1050
1731
  const DEFAULT_LABEL_SIZE_PIXELS = DEFAULT_LABEL_HEIGHT_CSS_PIXELS;
1051
1732
  /** An adapter resolution usable for pixel<->meter conversion. */
@@ -1606,258 +2287,79 @@ function generatedGeometryCandidates(adapter, paths, request, options) {
1606
2287
  if (!edges) continue;
1607
2288
  for (let index = 1; index < path.positions.length; index += 1) {
1608
2289
  const a = path.positions[index - 1];
1609
- const b = path.positions[index];
1610
- const aPixel = adapter.getPixelFromCoordinate(a);
1611
- const bPixel = adapter.getPixelFromCoordinate(b);
1612
- if (!aPixel || !bPixel) continue;
1613
- const t = clampedSegmentParameter(request.pixel, aPixel, bPixel) ?? 0;
1614
- candidates.push({
1615
- id: `${path.id}:edge:${index - 1}`,
1616
- coordinate: interpolatePosition(a, b, t),
1617
- kind: "graphic-edge",
1618
- graphicId: path.graphicId
1619
- });
1620
- }
1621
- }
1622
- return candidates;
1623
- }
1624
- function createSnapResolver(adapter, getGraphics, getGeometryFeatures, getOptions) {
1625
- let indexedGraphics;
1626
- let indexedCandidates = [];
1627
- let indexedFeatures;
1628
- let indexedGeometryPaths = [];
1629
- const builtInCandidates = () => {
1630
- const graphics = getGraphics();
1631
- if (graphics !== indexedGraphics) {
1632
- indexedGraphics = graphics;
1633
- indexedCandidates = graphicCandidates(graphics);
1634
- }
1635
- return indexedCandidates;
1636
- };
1637
- const geometryPaths = () => {
1638
- const features = getGeometryFeatures();
1639
- if (features !== indexedFeatures) {
1640
- indexedFeatures = features;
1641
- indexedGeometryPaths = indexGeometryFeatures(features);
1642
- }
1643
- return indexedGeometryPaths;
1644
- };
1645
- return (request) => {
1646
- const options = getOptions();
1647
- const clear = () => options?.onSnap?.(null);
1648
- if (!options?.enabled || options.bypass?.(request.originalEvent)) {
1649
- clear();
1650
- return null;
1651
- }
1652
- const tolerance = options.tolerancePixels ?? DEFAULT_TOLERANCE_PIXELS;
1653
- if (!Number.isFinite(tolerance) || tolerance < 0) {
1654
- clear();
1655
- return null;
1656
- }
1657
- const candidateLists = [];
1658
- if (options.sources?.graphics !== false) candidateLists.push(builtInCandidates());
1659
- const graphicGeometry = options.sources?.graphicGeometry;
1660
- if (graphicGeometry) candidateLists.push(generatedGeometryCandidates(adapter, geometryPaths(), request, graphicGeometry));
1661
- const external = options.sources?.external?.(request);
1662
- if (external) candidateLists.push(external);
1663
- let best = null;
1664
- for (const list of candidateLists) for (const candidate of list) {
1665
- if (isSelfCandidate(candidate, request)) continue;
1666
- const pixel = adapter.getPixelFromCoordinate(candidate.coordinate);
1667
- if (!pixel) continue;
1668
- const distancePixels = Math.hypot(pixel[0] - request.pixel[0], pixel[1] - request.pixel[1]);
1669
- if (distancePixels > tolerance) continue;
1670
- const priority = candidate.priority ?? 0;
1671
- const bestPriority = best?.candidate.priority ?? 0;
1672
- if (best && (distancePixels > best.distancePixels || distancePixels === best.distancePixels && priority <= bestPriority)) continue;
1673
- best = {
1674
- coordinate: [...candidate.coordinate],
1675
- pixel: [...pixel],
1676
- candidate,
1677
- distancePixels
1678
- };
1679
- }
1680
- options.onSnap?.(best);
1681
- return best;
1682
- };
1683
- }
1684
- //#endregion
1685
- //#region src/tactical-draw/session-events.ts
1686
- /**
1687
- * Map a settle-time error to its `SettleReason`: a `TacticalDrawAbortError`
1688
- * replays its own `reason` verbatim; anything else (a `produce` throw from
1689
- * `ctx.commit`) is the commit-time snapshot build failing, reported as
1690
- * `"error"`.
1691
- */
1692
- function settleReasonFromError(error) {
1693
- return isTacticalDrawAbortError(error) ? error.reason : "error";
1694
- }
1695
- /**
1696
- * Fan a `runInteraction` settlement out to a session's commit + settle
1697
- * emitters — the shared body of the `onSettled` wrappers in the edit and draw
1698
- * controllers (transform emits its close-path settle from `commitClose`
1699
- * instead, so it does not use this). On the commit path, emit the snapshot
1700
- * then the `"close"` settle in a try/finally so a throwing `onCommit` listener
1701
- * cannot starve the settle emission; on the reject path, emit the settle with
1702
- * the reason derived from the error. Callers run their own facade
1703
- * slot-clearing (`onSettled()`) before this so a listener observes a cleared
1704
- * `td.activeSession`.
1705
- */
1706
- function settleFromInteraction(settlement, commitEmitter, settleEmitter) {
1707
- if (settlement.status === "committed") try {
1708
- commitEmitter.emit(settlement.value);
1709
- } finally {
1710
- settleEmitter.emit("close");
1711
- }
1712
- else settleEmitter.emit(settleReasonFromError(settlement.error));
1713
- }
1714
- /**
1715
- * Subscribe/emit machinery shared by `TransformSession.onSettled`,
1716
- * `EditSession.onSettled`, and `DrawSession.onSettled` (#191 / #194 / #197):
1717
- * a settle-exactly-once latch, synchronous emission to every listener with
1718
- * exception collection (single rethrow, `AggregateError` otherwise when more
1719
- * than one listener throws), and a late-registration path that delivers the
1720
- * latched reason on a microtask exactly once when a handler subscribes after
1721
- * the session has already settled.
1722
- *
1723
- * `listenerErrorsMessage` becomes the `AggregateError` message — pass a
1724
- * session-specific string (e.g. `"EditSession.onSettled listener errors"`).
1725
- */
1726
- function createSettleEmitter(listenerErrorsMessage) {
1727
- const listeners = /* @__PURE__ */ new Set();
1728
- /** Set exactly once, by `emit` — also the "has settled" latch and the reason replayed to late registrations. */
1729
- let settledReason = null;
1730
- return {
1731
- emit(reason) {
1732
- if (settledReason !== null) return;
1733
- settledReason = reason;
1734
- const current = [...listeners];
1735
- listeners.clear();
1736
- const errors = [];
1737
- for (const listener of current) try {
1738
- listener(reason);
1739
- } catch (err) {
1740
- errors.push(err);
1741
- }
1742
- if (errors.length === 1) throw errors[0];
1743
- if (errors.length > 1) throw new AggregateError(errors, listenerErrorsMessage);
1744
- },
1745
- register(handler) {
1746
- if (settledReason !== null) {
1747
- const reason = settledReason;
1748
- let cancelled = false;
1749
- queueMicrotask(() => {
1750
- if (!cancelled) handler(reason);
1751
- });
1752
- return () => {
1753
- cancelled = true;
1754
- };
1755
- }
1756
- listeners.add(handler);
1757
- return () => {
1758
- listeners.delete(handler);
1759
- };
2290
+ const b = path.positions[index];
2291
+ const aPixel = adapter.getPixelFromCoordinate(a);
2292
+ const bPixel = adapter.getPixelFromCoordinate(b);
2293
+ if (!aPixel || !bPixel) continue;
2294
+ const t = clampedSegmentParameter(request.pixel, aPixel, bPixel) ?? 0;
2295
+ candidates.push({
2296
+ id: `${path.id}:edge:${index - 1}`,
2297
+ coordinate: interpolatePosition(a, b, t),
2298
+ kind: "graphic-edge",
2299
+ graphicId: path.graphicId
2300
+ });
1760
2301
  }
1761
- };
2302
+ }
2303
+ return candidates;
1762
2304
  }
1763
- /**
1764
- * The deferred reject/close/abort change-emit protocol (issue #49 contract)
1765
- * shared by `EditSession.onChange`, `TransformSession.onChange`, and
1766
- * `GraphicEditSession.onChange`. Owns the listener `Set` (like
1767
- * `createCommitEmitter.register`) and, per emission:
1768
- *
1769
- * - builds one event by spreading the controller-supplied `payload` plus the
1770
- * injected `reject()` / `close()` / `abort()` recorders, and delivers that
1771
- * same instance to every listener in registration order;
1772
- * - collects listener exceptions (single → rethrow as-is, multiple →
1773
- * `AggregateError` with `listenerErrorsMessage`);
1774
- * - applies decisions after all listeners run, priority `abort` > `close` >
1775
- * `reject`, with `reject` further gated on `!isSettled()` and
1776
- * `stillApplicable()`;
1777
- * - runs `onAccepted` (when the change survived) *before* rethrowing listener
1778
- * errors — unifying every controller on edit's history-before-throw ordering
1779
- * — and returns whether the change was accepted.
1780
- *
1781
- * Registration-time `isSettled()` guards stay in the controllers' `onChange`
1782
- * wrappers, mirroring `createCommitEmitter`.
1783
- */
1784
- function createChangeEmitter(options) {
1785
- const listeners = /* @__PURE__ */ new Set();
1786
- return {
1787
- register(listener) {
1788
- listeners.add(listener);
1789
- return () => {
1790
- listeners.delete(listener);
1791
- };
1792
- },
1793
- emit(payload, emission) {
1794
- if (options.isSettled()) return false;
1795
- let rejectRequested = false;
1796
- let closeRequested = false;
1797
- let abortRequested = false;
1798
- const event = {
1799
- ...payload,
1800
- reject() {
1801
- rejectRequested = true;
1802
- },
1803
- close() {
1804
- closeRequested = true;
1805
- },
1806
- abort() {
1807
- abortRequested = true;
1808
- }
1809
- };
1810
- const errors = [];
1811
- for (const listener of [...listeners]) try {
1812
- listener(event);
1813
- } catch (err) {
1814
- errors.push(err);
1815
- }
1816
- const applicable = emission.stillApplicable?.() ?? true;
1817
- if (abortRequested) options.abort();
1818
- else if (closeRequested) options.close();
1819
- else if (rejectRequested && !options.isSettled() && applicable) emission.rollback();
1820
- const accepted = !options.isSettled() && !rejectRequested && applicable;
1821
- if (accepted) emission.onAccepted?.();
1822
- if (errors.length === 1) throw errors[0];
1823
- if (errors.length > 1) throw new AggregateError(errors, options.listenerErrorsMessage);
1824
- return accepted;
2305
+ function createSnapResolver(adapter, getGraphics, getGeometryFeatures, getOptions) {
2306
+ let indexedGraphics;
2307
+ let indexedCandidates = [];
2308
+ let indexedFeatures;
2309
+ let indexedGeometryPaths = [];
2310
+ const builtInCandidates = () => {
2311
+ const graphics = getGraphics();
2312
+ if (graphics !== indexedGraphics) {
2313
+ indexedGraphics = graphics;
2314
+ indexedCandidates = graphicCandidates(graphics);
1825
2315
  }
2316
+ return indexedCandidates;
1826
2317
  };
1827
- }
1828
- /**
1829
- * Channel-neutral subscribe/emit machinery: a listener set with synchronous,
1830
- * exception-collecting emission (single rethrow, `AggregateError` otherwise).
1831
- * Named for its original `onCommit` channel (`TransformSession` /
1832
- * `EditSession` / `DrawSession`), it also carries the `onTransientChange`
1833
- * streams and the fixed/variable draw controllers' `onChange` — any
1834
- * multi-emit channel with no latch. Unlike `createSettleEmitter`, `emit` may
1835
- * be called more than
1836
- * once — a transform's `setGraphics` can commit several removal batches before
1837
- * the close batch — and there is no latch or late-registration path:
1838
- * registration-time `ctx.settled` guards stay in the controllers, mirroring
1839
- * `onChange`.
1840
- */
1841
- function createCommitEmitter(listenerErrorsMessage) {
1842
- const listeners = /* @__PURE__ */ new Set();
1843
- return {
1844
- emit(value) {
1845
- const current = [...listeners];
1846
- const errors = [];
1847
- for (const listener of current) try {
1848
- listener(value);
1849
- } catch (err) {
1850
- errors.push(err);
1851
- }
1852
- if (errors.length === 1) throw errors[0];
1853
- if (errors.length > 1) throw new AggregateError(errors, listenerErrorsMessage);
1854
- },
1855
- register(listener) {
1856
- listeners.add(listener);
1857
- return () => {
1858
- 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
1859
2359
  };
1860
2360
  }
2361
+ options.onSnap?.(best);
2362
+ return best;
1861
2363
  };
1862
2364
  }
1863
2365
  //#endregion
@@ -2594,146 +3096,12 @@ function runEditInteraction(spec) {
2594
3096
  unsubMove();
2595
3097
  unsubUp();
2596
3098
  spec.extraTeardown?.();
2597
- driver.dispose();
2598
- }, spec.onAbort);
2599
- spec.attach(controllerContext, driver, shell);
2600
- }
2601
- });
2602
- }
2603
- //#endregion
2604
- //#region src/tactical-draw/transform-box.ts
2605
- /**
2606
- * The uniform scale factor and rotation delta a box implies relative to a
2607
- * pre-drag box, given the gesture kind. A `body` translate reports neither
2608
- * (1, 0). Consumed both by point symbols (size/rotation) and by Text graphics
2609
- * (folding the delta into rotation/size options); the delta is pivot-
2610
- * independent so either can apply it without knowing the box center.
2611
- */
2612
- function boxScaleRotation(kind, box, preDragBox) {
2613
- return {
2614
- scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
2615
- rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
2616
- };
2617
- }
2618
- function rotate(x, y, angle) {
2619
- const cos = Math.cos(angle);
2620
- const sin = Math.sin(angle);
2621
- return [x * cos - y * sin, x * sin + y * cos];
2622
- }
2623
- /**
2624
- * Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
2625
- * Rotates the points by `-angle` about their centroid, takes the axis-aligned
2626
- * bounding box in that de-rotated frame, then re-expresses the result as an
2627
- * oriented box at `angle` — so the box always has exactly the requested tilt,
2628
- * sized to just contain the (de-rotated) points. Half-dimensions are floored
2629
- * at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
2630
- * pickable box. Returns `null` for an empty list — there is nothing to fit.
2631
- *
2632
- * `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
2633
- * the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
2634
- * `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
2635
- * the rendered geometry. Everything downstream that reads a fitted box
2636
- * (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
2637
- * `halfWidth`/`halfHeight` alone, so the padding flows through to corner
2638
- * placement, the rotate grip, and picking automatically — no other function
2639
- * needs to know about it.
2640
- */
2641
- function fitTransformBox(pixels, angle, padding = 0) {
2642
- if (pixels.length === 0) return null;
2643
- let cx = 0;
2644
- let cy = 0;
2645
- for (const p of pixels) {
2646
- cx += p[0];
2647
- cy += p[1];
2648
- }
2649
- cx /= pixels.length;
2650
- cy /= pixels.length;
2651
- let minX = Infinity;
2652
- let minY = Infinity;
2653
- let maxX = -Infinity;
2654
- let maxY = -Infinity;
2655
- for (const p of pixels) {
2656
- const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
2657
- if (rx < minX) minX = rx;
2658
- if (rx > maxX) maxX = rx;
2659
- if (ry < minY) minY = ry;
2660
- if (ry > maxY) maxY = ry;
2661
- }
2662
- const localCenterX = (minX + maxX) / 2;
2663
- const localCenterY = (minY + maxY) / 2;
2664
- const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
2665
- const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
2666
- const [ox, oy] = rotate(localCenterX, localCenterY, angle);
2667
- return {
2668
- center: [cx + ox, cy + oy],
2669
- halfWidth,
2670
- halfHeight,
2671
- angle
2672
- };
2673
- }
2674
- /**
2675
- * The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
2676
- * bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
2677
- * `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
2678
- * and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
2679
- * anchor is always the opposite corner, `(index + 2) % 4`.
2680
- */
2681
- function boxCorners(box) {
2682
- const { center, halfWidth, halfHeight, angle } = box;
2683
- return [
2684
- [-halfWidth, -halfHeight],
2685
- [halfWidth, -halfHeight],
2686
- [halfWidth, halfHeight],
2687
- [-halfWidth, halfHeight]
2688
- ].map(([x, y]) => {
2689
- const [rx, ry] = rotate(x, y, angle);
2690
- return [center[0] + rx, center[1] + ry];
3099
+ driver.dispose();
3100
+ }, spec.onAbort);
3101
+ spec.attach(controllerContext, driver, shell);
3102
+ }
2691
3103
  });
2692
3104
  }
2693
- /**
2694
- * Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
2695
- * above the top-edge midpoint, along the box's local "up" direction, so the
2696
- * grip stays glued to the box (rotates with it) rather than floating at a
2697
- * fixed screen offset. The label transform box passes its own tighter
2698
- * clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
2699
- */
2700
- function rotateGripPixel(box, offset = 28) {
2701
- const { center, halfHeight, angle } = box;
2702
- const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
2703
- return [center[0] + rx, center[1] + ry];
2704
- }
2705
- /**
2706
- * Point-in-oriented-rectangle test, used to start a body (translate) drag
2707
- * when the pointer goes down inside the box but not on a corner/rotate grip.
2708
- * De-rotates `pixel` into the box's local frame about `box.center` and
2709
- * compares against the half-extents.
2710
- */
2711
- function pointInBox(box, pixel) {
2712
- const { center, halfWidth, halfHeight, angle } = box;
2713
- const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
2714
- return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
2715
- }
2716
- /** Plain 2D distance between two pixel coordinates. */
2717
- function pixelDistance$1(a, b) {
2718
- return Math.hypot(a[0] - b[0], a[1] - b[1]);
2719
- }
2720
- /**
2721
- * Pick what part of the box, if any, `pixel` hits — corners first, then the
2722
- * rotate grip, then plain containment (body drag). Corners and the rotate
2723
- * grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
2724
- * click just outside the box never starts a body drag. Returns `null` when
2725
- * nothing matches.
2726
- */
2727
- function pickBoxHandle(box, pixel, tolerance) {
2728
- const corners = boxCorners(box);
2729
- for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
2730
- kind: "scale",
2731
- index: i
2732
- };
2733
- if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
2734
- if (pointInBox(box, pixel)) return { kind: "body" };
2735
- return null;
2736
- }
2737
3105
  //#endregion
2738
3106
  //#region src/tactical-draw/handles.ts
2739
3107
  /**
@@ -5086,328 +5454,108 @@ function runEdit(opts) {
5086
5454
  historySettle: () => history.settle(),
5087
5455
  facadeOnSettled: onSettled,
5088
5456
  emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
5089
- extraTeardown: () => {
5090
- if (activeDrag !== null || activeOptionDrag !== null || activeBoxDrag !== null || labels.dragging) {
5091
- emitTransient("end", workingPoints, activeDrag?.controlPointIndex ?? null);
5092
- driver.setDragPanEnabled(true);
5093
- activeDrag = null;
5094
- activeOptionDrag = null;
5095
- activeBoxDrag = null;
5096
- labels.endDrag();
5097
- dragInFlight = false;
5098
- }
5099
- },
5100
- attach(interaction, createdDriver, createdShell) {
5101
- ctx = interaction;
5102
- driver = createdDriver;
5103
- shell = createdShell;
5104
- renderWorkingGraphic();
5105
- renderHandlesLayer();
5106
- recomputeDirty();
5107
- if (onSession) onSession(session);
5108
- }
5109
- });
5110
- return {
5111
- promise: core.promise,
5112
- get settled() {
5113
- return core.settled;
5114
- },
5115
- usesGraphicStack: true,
5116
- measureId: measure.id,
5117
- abort(reason, cause) {
5118
- core.abort(reason, cause);
5119
- },
5120
- notifyExternalMeasure(next) {
5121
- if (core.settled) return;
5122
- if (stylesEqual(currentStyle, next.style)) return;
5123
- currentStyle = next.style;
5124
- renderWorkingGraphic();
5125
- }
5126
- };
5127
- }
5128
- /**
5129
- * Validate and normalise an `EditOptions.modes` / `EditSession.setModes` input:
5130
- * throws `TypeError` synchronously for an unknown mode, collapses `delete` to
5131
- * an exclusive set, dedupes, and defaults to `["reshape", "transform"]`.
5132
- * Exported so the façade can hoist this validation *before* `guardAndPreempt`
5133
- * — an invalid mode must not tear down whatever edit/draw is already active
5134
- * (see the `td.edit` regression test for issue tracked alongside ADR-0017).
5135
- */
5136
- function normaliseModes(modes) {
5137
- if (!modes || modes.length === 0) return ["reshape", "transform"];
5138
- for (const m of modes) if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
5139
- if (modes.includes("delete")) return ["delete"];
5140
- const seen = /* @__PURE__ */ new Set();
5141
- const out = [];
5142
- for (const m of modes) if (!seen.has(m)) {
5143
- seen.add(m);
5144
- out.push(m);
5145
- }
5146
- return out;
5147
- }
5148
- function stylesEqual(a, b) {
5149
- if (a === b) return true;
5150
- if (!a || !b) return false;
5151
- const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
5152
- for (const key of keys) if (a[key] !== b[key]) return false;
5153
- return true;
5154
- }
5155
- function pointsEqualWithinTolerance(a, b, epsilon) {
5156
- if (a.length !== b.length) return false;
5157
- for (let i = 0; i < a.length; i++) {
5158
- const pa = a[i];
5159
- const pb = b[i];
5160
- if (pa.length !== pb.length) return false;
5161
- for (let k = 0; k < pa.length; k++) if (Math.abs((pa[k] ?? 0) - (pb[k] ?? 0)) > epsilon) return false;
5162
- }
5163
- return true;
5164
- }
5165
- //#endregion
5166
- //#region src/tactical-draw/errors.ts
5167
- /**
5168
- * Error thrown when any `TacticalDraw` method is called after `destroy()`.
5169
- * See PRD #40 / issue #45.
5170
- */
5171
- var TacticalDrawDestroyedError = class TacticalDrawDestroyedError extends Error {
5172
- constructor(message = "TacticalDraw instance has been destroyed") {
5173
- super(message);
5174
- this.name = "TacticalDrawDestroyedError";
5175
- Object.setPrototypeOf(this, TacticalDrawDestroyedError.prototype);
5176
- }
5177
- };
5178
- var PointSymbolCapabilityRequiredError = class extends Error {
5179
- constructor() {
5180
- super("TacticalDraw: point-symbol capability is not configured");
5181
- this.name = "PointSymbolCapabilityRequiredError";
5182
- }
5183
- };
5184
- var ImageCapabilityRequiredError = class ImageCapabilityRequiredError extends Error {
5185
- constructor() {
5186
- super("TacticalDraw: image capability is not configured");
5187
- this.name = "ImageCapabilityRequiredError";
5188
- Object.setPrototypeOf(this, ImageCapabilityRequiredError.prototype);
5189
- }
5190
- };
5191
- //#endregion
5192
- //#region src/point-symbol-size.ts
5193
- function effectivePointSymbolPixelSize(symbol, resolution) {
5194
- if (symbol.size.unit === "pixels") return symbol.size.value;
5195
- if (resolution === void 0 || resolution <= 0) return symbol.size.value;
5196
- const px = symbol.size.value / resolution;
5197
- const { minPixels, maxPixels } = symbol.size;
5198
- return Math.max(Number.isFinite(minPixels) && minPixels > 0 ? minPixels : 0, Math.min(Number.isFinite(maxPixels) && maxPixels > 0 ? maxPixels : Infinity, px));
5199
- }
5200
- function pointSymbolPixelScale(symbol, render, resolution) {
5201
- if (!Number.isFinite(render.octagonSize) || render.octagonSize <= 0) throw new TypeError("PointSymbolRender.octagonSize must be a positive finite number");
5202
- return effectivePointSymbolPixelSize(symbol, resolution) / render.octagonSize;
5203
- }
5204
- //#endregion
5205
- //#region src/point-symbol-offset.ts
5206
- const EPSILON = 1e-7;
5207
- const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
5208
- /** Trim from the symbol attachment end, including across a junction when necessary. */
5209
- function padAnchor(path, padding) {
5210
- if (!Number.isFinite(padding) || padding <= 0) return path;
5211
- const trimmed = [...path];
5212
- while (trimmed.length > 1) {
5213
- const start = trimmed.shift();
5214
- const end = trimmed[0];
5215
- const length = distance(start, end);
5216
- if (length > padding) {
5217
- const fraction = padding / length;
5218
- trimmed.unshift([start[0] + (end[0] - start[0]) * fraction, start[1] + (end[1] - start[1]) * fraction]);
5219
- return trimmed;
5220
- }
5221
- padding -= length;
5222
- }
5223
- return [];
5224
- }
5225
- /** Pure layout: projection input/output is in CSS pixels, including document contexts. */
5226
- function resolvePointSymbolOffset(symbol, render, view) {
5227
- const actual = view.getPixelFromCoordinate(symbol.position);
5228
- if (!actual) return null;
5229
- const scale = pointSymbolPixelScale(symbol, render, view.getResolution());
5230
- const angle = symbol.rotation - view.getBearing();
5231
- const metadata = render.offset;
5232
- const registration = metadata?.ordinaryRegistration ?? render.octagonAnchor;
5233
- const shift = rotate((registration.x - render.anchor.x) * scale, (registration.y - render.anchor.y) * scale, angle);
5234
- const ordinaryDisplay = [actual[0] + shift[0], actual[1] + shift[1]];
5235
- const layout = {
5236
- render,
5237
- resource: pointSymbolResource(render),
5238
- bodyOpacity: (symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
5239
- rotation: angle,
5240
- actual,
5241
- display: ordinaryDisplay,
5242
- imageAnchor: actual,
5243
- ordinaryDisplay,
5244
- path: [],
5245
- kind: "ordinary",
5246
- status: "ok",
5247
- scale,
5248
- connectorStyle: {
5249
- strokeColor: symbol.offset?.style?.strokeColor ?? metadata?.strokeColor ?? "#000000",
5250
- opacity: (symbol.offset?.style?.opacity ?? symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
5251
- strokeWidth: symbol.offset?.style?.strokeWidth ?? (metadata?.strokeWidth ?? 4) * scale,
5252
- strokeDash: [...symbol.offset?.style?.strokeDash ?? []]
5253
- }
5254
- };
5255
- if (!symbol.offset) return layout;
5256
- const { placement, connector, anchorPadding = 0 } = symbol.offset;
5257
- if (!metadata || connector !== "direct" && !metadata.indicator) return {
5258
- ...layout,
5259
- status: "unsupported-presentation"
5260
- };
5261
- const display = placement.mode === "ground" ? view.getPixelFromCoordinate(placement.position) : [ordinaryDisplay[0] + placement.offset[0], ordinaryDisplay[1] + placement.offset[1]];
5262
- if (!display) return {
5263
- ...layout,
5264
- status: "unsupported-presentation"
5265
- };
5266
- if (distance(display, ordinaryDisplay) < EPSILON) return layout;
5267
- const body = metadata.body;
5268
- const offset = rotate((metadata.attachment.x - body.anchor.x) * scale, (metadata.attachment.y - body.anchor.y) * scale, angle);
5269
- const attachment = [display[0] + offset[0], display[1] + offset[1]];
5270
- const resolved = {
5271
- ...layout,
5272
- render: body,
5273
- resource: pointSymbolResource(body),
5274
- display,
5275
- imageAnchor: display,
5276
- attachment
5277
- };
5278
- if (connector === "direct") return {
5279
- ...resolved,
5280
- kind: "direct",
5281
- path: padAnchor(distance(attachment, actual) < EPSILON ? [] : [attachment, actual], anchorPadding)
5282
- };
5283
- const indicator = metadata.indicator;
5284
- const rawDirection = rotate(indicator.direction.x, indicator.direction.y, angle);
5285
- const norm = Math.hypot(...rawDirection);
5286
- const direction = [rawDirection[0] / norm, rawDirection[1] / norm];
5287
- const dx = actual[0] - attachment[0], dy = actual[1] - attachment[1];
5288
- const forward = dx * direction[0] + dy * direction[1];
5289
- if (Math.abs(dx * direction[1] - dy * direction[0]) < EPSILON && forward >= (indicator.minLength ?? 0) * scale) return {
5290
- ...resolved,
5291
- direction,
5292
- kind: "indicator-only",
5293
- path: padAnchor([attachment, actual], anchorPadding)
5294
- };
5295
- const length = symbol.offset.indicatorLength !== void 0 ? symbol.offset.indicatorLength * render.octagonSize : indicator.length;
5296
- const junction = [attachment[0] + direction[0] * length * scale, attachment[1] + direction[1] * length * scale];
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
+ });
5297
5478
  return {
5298
- ...resolved,
5299
- direction,
5300
- junction,
5301
- kind: "with-extension",
5302
- status: "ok",
5303
- path: padAnchor(distance(junction, actual) < EPSILON ? [attachment, junction] : [
5304
- attachment,
5305
- junction,
5306
- actual
5307
- ], 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
+ }
5308
5494
  };
5309
5495
  }
5310
5496
  /**
5311
- * Authored indicator length that puts the junction at `point`: the forward
5312
- * projection onto the attachment ray, expressed as a multiple of the displayed
5313
- * 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).
5314
5503
  */
5315
- function indicatorLengthAt(point, attachment, direction, scale, octagonSize) {
5316
- const forward = (point[0] - attachment[0]) * direction[0] + (point[1] - attachment[1]) * direction[1];
5317
- return Math.max(0, forward / (scale * octagonSize));
5318
- }
5319
- /** Set body placement without changing the authoritative coordinate. */
5320
- function placePointSymbol(symbol, render, view, display, mode = symbol.offset?.placement.mode ?? "screen-relative") {
5321
- if (!render.offset) throw new TypeError("This renderer does not support offset placement");
5322
- const ordinary = resolvePointSymbolOffset({
5323
- ...symbol,
5324
- offset: void 0
5325
- }, render, view);
5326
- if (!ordinary) throw new RangeError("Point symbol cannot be projected in this view");
5327
- const next = clonePointSymbol(symbol);
5328
- const coordinate = mode === "ground" ? view.getCoordinateFromPixel?.(display) : void 0;
5329
- if (mode === "ground" && !coordinate) throw new RangeError("Ground placement requires inverse projection");
5330
- next.offset = {
5331
- ...next.offset,
5332
- connector: next.offset?.connector ?? (render.offset.indicator ? "auto" : "direct"),
5333
- placement: mode === "ground" ? {
5334
- mode,
5335
- position: [...coordinate]
5336
- } : {
5337
- mode,
5338
- offset: [display[0] - ordinary.display[0], display[1] - ordinary.display[1]]
5339
- }
5340
- };
5341
- return next;
5342
- }
5343
- function convertPointSymbolPlacement(symbol, render, view, mode) {
5344
- const layout = resolvePointSymbolOffset(symbol, render, view);
5345
- if (!layout || layout.status === "unsupported-presentation") throw new TypeError("Offset presentation is unavailable");
5346
- return placePointSymbol(symbol, render, view, layout.display, mode);
5347
- }
5348
- /** Explicit alignment, also used with hysteresis by pointer editing. */
5349
- function alignPointSymbolIndicator(symbol, render, view) {
5350
- const layout = resolvePointSymbolOffset(symbol, render, view);
5351
- if (!layout?.attachment || !layout.direction || !render.offset?.indicator) return symbol;
5352
- const d = layout.direction;
5353
- const delta = [layout.actual[0] - layout.attachment[0], layout.actual[1] - layout.attachment[1]];
5354
- const forward = Math.max((render.offset.indicator.minLength ?? 0) * layout.scale, delta[0] * d[0] + delta[1] * d[1]);
5355
- 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;
5356
5515
  }
5357
- function movePointSymbolLocation(symbol, render, view, position) {
5358
- const before = resolvePointSymbolOffset(symbol, render, view);
5359
- let next = {
5360
- ...clonePointSymbol(symbol),
5361
- position: [...position]
5362
- };
5363
- if (!symbol.offset || !before || before.status === "unsupported-presentation") return next;
5364
- next = placePointSymbol(next, render, view, before.display);
5365
- const after = resolvePointSymbolOffset(next, render, view);
5366
- 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);
5367
- 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;
5368
5522
  }
5369
- /** Canonical resource metadata shared by native adapters and external exporters. */
5370
- function pointSymbolResource(render) {
5371
- return {
5372
- id: render.resourceId,
5373
- svg: render.svg,
5374
- width: render.width,
5375
- height: render.height,
5376
- anchor: [render.anchor.x, render.anchor.y],
5377
- coreRect: {
5378
- x: render.octagonAnchor.x - render.octagonSize / 2,
5379
- y: render.octagonAnchor.y - render.octagonSize / 2,
5380
- width: render.octagonSize,
5381
- height: render.octagonSize
5382
- }
5383
- };
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;
5384
5532
  }
5533
+ //#endregion
5534
+ //#region src/tactical-draw/errors.ts
5385
5535
  /**
5386
- * Convert a resolved CSS-pixel layout to output units (e.g. 25.4 / 96 for mm).
5387
- * Scales positions, body scale, line width and dashes together, including the
5388
- * already-resolved offset and padding. Canonical resources, direction, rotation
5389
- * 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.
5390
5538
  */
5391
- function scalePointSymbolLayout(layout, factor) {
5392
- if (!Number.isFinite(factor) || factor <= 0) throw new RangeError("Layout scale factor must be positive and finite");
5393
- const point = ([x, y]) => [x * factor, y * factor];
5394
- return {
5395
- ...layout,
5396
- actual: point(layout.actual),
5397
- display: point(layout.display),
5398
- imageAnchor: point(layout.imageAnchor),
5399
- ordinaryDisplay: point(layout.ordinaryDisplay),
5400
- attachment: layout.attachment && point(layout.attachment),
5401
- junction: layout.junction && point(layout.junction),
5402
- path: layout.path.map(point),
5403
- scale: layout.scale * factor,
5404
- connectorStyle: {
5405
- ...layout.connectorStyle,
5406
- strokeWidth: layout.connectorStyle.strokeWidth * factor,
5407
- strokeDash: layout.connectorStyle.strokeDash.map((length) => length * factor)
5408
- }
5409
- };
5410
- }
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
+ };
5411
5559
  //#endregion
5412
5560
  //#region src/tactical-draw/point-symbol-rendering.ts
5413
5561
  /** Complete rotated logical footprint for selection and transform chrome. */
@@ -6627,6 +6775,8 @@ function runPointSymbolEdit(opts) {
6627
6775
  const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, settleToGraphics, publishWorkingGraphic, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
6628
6776
  let modes = normalisePointModes(opts.modes ?? (symbol.offset ? ["transform", "offset"] : ["transform"]));
6629
6777
  let usesGraphicStack = wasOnGraphicsLayer;
6778
+ let pinEditing = !!opts.renderPin && opts.modes === void 0;
6779
+ let pinDrag = null;
6630
6780
  let working = clonePointSymbol(symbol);
6631
6781
  let dirty = false;
6632
6782
  let box = null;
@@ -6651,6 +6801,23 @@ function runPointSymbolEdit(opts) {
6651
6801
  });
6652
6802
  const render = () => {
6653
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
+ }
6654
6821
  const plan = renderPointSymbolForAdapter(adapter, capability, working);
6655
6822
  currentLayout = plan.layout;
6656
6823
  const nextBox = symbolBox(adapter, working, plan.render, interactionStyle?.boxPadding);
@@ -6793,6 +6960,7 @@ function runPointSymbolEdit(opts) {
6793
6960
  setModes(next) {
6794
6961
  if (ctx.settled) return;
6795
6962
  modes = normalisePointModes(next);
6963
+ pinEditing = false;
6796
6964
  render();
6797
6965
  },
6798
6966
  updateGraphic(updater) {
@@ -6815,7 +6983,26 @@ function runPointSymbolEdit(opts) {
6815
6983
  }
6816
6984
  };
6817
6985
  const pointerAngle = (pixel, anchor) => Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
6986
+ const snapTranslate = (position, fallbackPixel, info) => snap?.({
6987
+ coordinate: position,
6988
+ pixel: driver.toPixel(position) ?? fallbackPixel,
6989
+ interaction: "translate",
6990
+ graphicId: symbol.id,
6991
+ originalEvent: info?.native
6992
+ })?.coordinate ?? position;
6818
6993
  const onDown = (pixel, info) => {
6994
+ if (pinEditing && opts.canRenderPin?.() && !ctx.settled) {
6995
+ const footprint = adapter.getPinFootprint?.(working.id);
6996
+ if (footprint && adapter.pickPin?.(working.id, pixel, info?.hitTolerance ?? 12)) {
6997
+ pinDrag = {
6998
+ start: clonePointSymbol(working),
6999
+ down: [...pixel],
7000
+ attachment: footprint.attachment
7001
+ };
7002
+ driver.setDragPanEnabled(false);
7003
+ }
7004
+ return;
7005
+ }
6819
7006
  if (ctx.settled || !box || !currentLayout || modes.includes("delete")) return;
6820
7007
  let offsetMode = modes.includes("offset");
6821
7008
  if (!offsetMode && !modes.includes("transform")) return;
@@ -6850,6 +7037,15 @@ function runPointSymbolEdit(opts) {
6850
7037
  driver.setDragPanEnabled(false);
6851
7038
  };
6852
7039
  const transformed = (pixel, info) => {
7040
+ if (pinDrag) {
7041
+ const attachment = [pinDrag.attachment[0] + pixel[0] - pinDrag.down[0], pinDrag.attachment[1] + pixel[1] - pinDrag.down[1]];
7042
+ const position = adapter.movePinFromPixel?.(working.id, attachment);
7043
+ if (!position) return null;
7044
+ return {
7045
+ ...clonePointSymbol(pinDrag.start),
7046
+ position: snapTranslate(position, attachment, info)
7047
+ };
7048
+ }
6853
7049
  if (!drag) return null;
6854
7050
  let next = clonePointSymbol(drag.start);
6855
7051
  const intrinsic = drag.render;
@@ -6874,15 +7070,7 @@ function runPointSymbolEdit(opts) {
6874
7070
  return next;
6875
7071
  }
6876
7072
  if (drag.kind === "body" || drag.kind === "location") {
6877
- const unsnappedPosition = driver.fromPixel([drag.anchor[0] + pixel[0] - drag.down[0], drag.anchor[1] + pixel[1] - drag.down[1]]);
6878
- const anchorPixel = driver.toPixel(unsnappedPosition) ?? pixel;
6879
- const position = (snap?.({
6880
- coordinate: unsnappedPosition,
6881
- pixel: anchorPixel,
6882
- interaction: "translate",
6883
- graphicId: symbol.id,
6884
- originalEvent: info?.native
6885
- }))?.coordinate ?? unsnappedPosition;
7073
+ const position = snapTranslate(driver.fromPixel([drag.anchor[0] + pixel[0] - drag.down[0], drag.anchor[1] + pixel[1] - drag.down[1]]), pixel, info);
6886
7074
  if (drag.kind === "location") return movePointSymbolLocation(next, intrinsic, adapter, position);
6887
7075
  next.position = position;
6888
7076
  if (next.offset?.placement.mode === "ground") {
@@ -6906,6 +7094,16 @@ function runPointSymbolEdit(opts) {
6906
7094
  render();
6907
7095
  };
6908
7096
  const onUp = (pixel, info) => {
7097
+ if (pinDrag) {
7098
+ const next = transformed(pixel, info), start = pinDrag.start, down = pinDrag.down;
7099
+ pinDrag = null;
7100
+ driver.setDragPanEnabled(true);
7101
+ shell.suppressNextClickAway();
7102
+ working = clonePointSymbol(start);
7103
+ if (next && !withinClickSlop(pixel, down)) apply(next, "gesture", "Move unit");
7104
+ else render();
7105
+ return;
7106
+ }
6909
7107
  if (!drag) return;
6910
7108
  const currentDrag = drag;
6911
7109
  const next = transformed(pixel, info);
@@ -6928,7 +7126,9 @@ function runPointSymbolEdit(opts) {
6928
7126
  previewLayer,
6929
7127
  handlesLayer,
6930
7128
  closeOnClickAway,
6931
- onViewChange: render,
7129
+ onViewChange: () => {
7130
+ if (!pinEditing || !opts.canRenderPin?.()) render();
7131
+ },
6932
7132
  onBeforeTeardown: (settlement) => {
6933
7133
  settleToGraphics(settlement.status === "committed" ? settlement.value.graphic : void 0);
6934
7134
  },
@@ -6953,6 +7153,7 @@ function runPointSymbolEdit(opts) {
6953
7153
  });
6954
7154
  return {
6955
7155
  promise: handle.promise,
7156
+ refreshPresentation: render,
6956
7157
  get settled() {
6957
7158
  return handle.settled;
6958
7159
  },
@@ -7450,6 +7651,9 @@ var TacticalDraw = class {
7450
7651
  * `render()` so outlines track geometry, style, and dropped graphics.
7451
7652
  */
7452
7653
  highlightedIds = [];
7654
+ pinBodies = /* @__PURE__ */ new WeakMap();
7655
+ pins = new PinPresentationStore();
7656
+ unsubscribePins;
7453
7657
  /**
7454
7658
  * Whether the highlight layer currently holds any outline. Lets
7455
7659
  * `renderHighlights` skip a redundant empty write on every view change /
@@ -7485,6 +7689,54 @@ var TacticalDraw = class {
7485
7689
  this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(this.baseGuideStyle()));
7486
7690
  this.adapter.setLayerStyle(this.highlightLayer, guideToStyleOptions(this.baseHighlightStyle()));
7487
7691
  this.adapter.onViewChange(this.handleViewChange);
7692
+ this.unsubscribePins = this.pins.onChange(() => {
7693
+ this.invalidateGraphicGroupCache(false);
7694
+ this.refreshActiveEditPresentation();
7695
+ this.reconcileGraphicsStack();
7696
+ this.renderHighlights();
7697
+ });
7698
+ }
7699
+ setPinPresentation(defaults) {
7700
+ this.assertAlive();
7701
+ this.pins.setDefaults(defaults);
7702
+ }
7703
+ setPinOverride(id, patch) {
7704
+ this.assertAlive();
7705
+ this.pins.setOverride(id, patch);
7706
+ }
7707
+ resetPinOverride(id) {
7708
+ this.assertAlive();
7709
+ this.pins.resetOverride(id);
7710
+ }
7711
+ getPinPresentationCapability(id) {
7712
+ return this.adapter.getPinPresentationCapability?.(id) ?? {
7713
+ status: "unsupported",
7714
+ reason: "This map adapter uses ordinary point-symbol presentation"
7715
+ };
7716
+ }
7717
+ onPinPresentationStatusChange(listener) {
7718
+ this.assertAlive();
7719
+ return this.adapter.onPinPresentationStatusChange?.(listener) ?? (() => {});
7720
+ }
7721
+ getPinPresentationState() {
7722
+ return this.pins.getState();
7723
+ }
7724
+ /**
7725
+ * Let an in-flight edit session re-read pin presentation, when it is a kind
7726
+ * of session that renders pins at all. Both the presentation-store change
7727
+ * handler and the adapter-capability change in `handleViewChange` need it.
7728
+ */
7729
+ refreshActiveEditPresentation() {
7730
+ if (this.activeEdit && "refreshPresentation" in this.activeEdit) this.activeEdit.refreshPresentation();
7731
+ }
7732
+ /**
7733
+ * Whether a point symbol should render as a billboard pin rather than an
7734
+ * ordinary point symbol: its own presentation opts in, and the adapter
7735
+ * currently supports rendering pins. Pass an already-resolved
7736
+ * `presentation` to skip re-resolving it when a caller has one in hand.
7737
+ */
7738
+ graphicUsesPinPresentation(graphic, presentation) {
7739
+ return (presentation ?? this.pins.resolve(graphic.id)).enabled && this.adapter.getPinPresentationCapability?.().status === "supported";
7488
7740
  }
7489
7741
  /** Change the default shortcut for subsequent drags, including in an active edit. */
7490
7742
  setOffsetDragModifier(modifier) {
@@ -7524,9 +7776,10 @@ var TacticalDraw = class {
7524
7776
  if (cached.dependence === "none") return cached.group;
7525
7777
  if (cached.dependence === "resolution" && Object.is(cached.resolution, this.adapter.getResolution())) return cached.group;
7526
7778
  }
7527
- const dependence = graphicViewDependence(graphic, this.adapter.scalesGroundPointSymbolsNatively === true);
7779
+ const presentation = isPointSymbol(graphic) ? this.pins.resolve(graphic.id) : void 0;
7780
+ const dependence = presentation && this.graphicUsesPinPresentation(graphic, presentation) ? "none" : graphicViewDependence(graphic, this.adapter.scalesGroundPointSymbolsNatively === true);
7528
7781
  const resolution = dependence === "none" ? void 0 : this.adapter.getResolution();
7529
- const group = this.buildGraphicGroup(graphic);
7782
+ const group = this.buildGraphicGroup(graphic, presentation);
7530
7783
  this.graphicGroupCache.set(graphic, {
7531
7784
  group,
7532
7785
  dependence,
@@ -7539,10 +7792,49 @@ var TacticalDraw = class {
7539
7792
  * drawn from the module-scoped {@link nextGraphicGroupRevision} counter (see
7540
7793
  * its doc comment for why it isn't a per-instance field).
7541
7794
  */
7542
- buildGraphicGroup(graphic) {
7795
+ buildGraphicGroup(graphic, resolved) {
7543
7796
  const revision = ++nextGraphicGroupRevision;
7544
7797
  if (isPointSymbol(graphic)) {
7545
7798
  if (!this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
7799
+ const presentation = resolved ?? this.pins.resolve(graphic.id);
7800
+ if (this.graphicUsesPinPresentation(graphic, presentation)) {
7801
+ const body = this.pinBodies.get(graphic) ?? this.pointSymbols.render({
7802
+ ...graphic,
7803
+ graphicModifiers: {
7804
+ ...graphic.graphicModifiers,
7805
+ direction: void 0
7806
+ }
7807
+ });
7808
+ this.pinBodies.set(graphic, body);
7809
+ const resource = pointSymbolResource(body);
7810
+ return {
7811
+ id: graphic.id,
7812
+ revision,
7813
+ features: [{
7814
+ type: "Feature",
7815
+ id: `${graphic.id}:pin`,
7816
+ geometry: {
7817
+ type: "Point",
7818
+ coordinates: [...graphic.position]
7819
+ },
7820
+ properties: {
7821
+ graphicId: graphic.id,
7822
+ graphicKind: "point-symbol",
7823
+ pinPresentation: true
7824
+ }
7825
+ }],
7826
+ iconResources: [],
7827
+ pin: {
7828
+ id: graphic.id,
7829
+ position: graphic.position,
7830
+ resource,
7831
+ octagonSize: body.octagonSize,
7832
+ presentation,
7833
+ opacity: (graphic.style?.opacity ?? 1) * (graphic.style?.overallOpacity ?? 1),
7834
+ ...graphic.graphicModifiers?.direction !== void 0 ? { direction: graphic.rotation + graphic.graphicModifiers.direction } : {}
7835
+ }
7836
+ };
7837
+ }
7546
7838
  const plan = renderPointSymbolForAdapter(this.adapter, this.pointSymbols, graphic);
7547
7839
  return {
7548
7840
  id: graphic.id,
@@ -7578,7 +7870,8 @@ var TacticalDraw = class {
7578
7870
  * View changes are handled per entry by {@link renderGraphicGroup} so
7579
7871
  * resolution-independent graphics remain cache hits.
7580
7872
  */
7581
- invalidateGraphicGroupCache() {
7873
+ invalidateGraphicGroupCache(clearBodies = true) {
7874
+ if (clearBodies) this.pinBodies = /* @__PURE__ */ new WeakMap();
7582
7875
  this.graphicGroupCache = /* @__PURE__ */ new WeakMap();
7583
7876
  }
7584
7877
  /** Refresh capability-dependent renders before any caller-visible state is mutated. */
@@ -7620,8 +7913,16 @@ var TacticalDraw = class {
7620
7913
  * point symbols and transform members already present in the host stack do
7621
7914
  * the same. Graphics without a host slot remain on preview.
7622
7915
  */
7916
+ pinSupport;
7623
7917
  handleViewChange = () => {
7624
7918
  if (this.destroyed) return;
7919
+ const support = this.adapter.getPinPresentationCapability?.().status;
7920
+ if (support !== this.pinSupport) {
7921
+ this.pinSupport = support;
7922
+ this.refreshActiveEditPresentation();
7923
+ this.invalidateGraphicGroupCache();
7924
+ if (this.hasRendered) this.reconcileGraphicsStack();
7925
+ }
7625
7926
  const resolution = this.adapter.getResolution();
7626
7927
  const resolutionChanged = !Object.is(resolution, this.lastViewResolution);
7627
7928
  this.lastViewResolution = resolution;
@@ -7631,7 +7932,7 @@ var TacticalDraw = class {
7631
7932
  if (!resolutionChanged && !this.lastRenderedHasViewDependent) return;
7632
7933
  const excluded = this.graphicsStackExcludedIds();
7633
7934
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
7634
- if (!visible.some((g) => graphicViewDependence(g, nativePointSymbolScaling) !== "none")) return;
7935
+ if (!visible.some((g) => this.renderGraphicGroup(g).pin ? false : graphicViewDependence(g, nativePointSymbolScaling) !== "none")) return;
7635
7936
  this.reconcileGraphicsStack(visible);
7636
7937
  };
7637
7938
  /**
@@ -7715,7 +8016,7 @@ var TacticalDraw = class {
7715
8016
  }
7716
8017
  }
7717
8018
  this.lastRenderedMeasures = measures.slice();
7718
- this.lastRenderedHasViewDependent = this.lastRenderedMeasures.some((g) => graphicViewDependence(g, this.adapter.scalesGroundPointSymbolsNatively === true) === "view");
8019
+ this.lastRenderedHasViewDependent = this.lastRenderedMeasures.some((g) => !this.renderGraphicGroup(g).pin && graphicViewDependence(g, this.adapter.scalesGroundPointSymbolsNatively === true) === "view");
7719
8020
  this.graphicStack.acknowledgeHostRender(measures);
7720
8021
  if (activeEdit && "syncHostMembership" in activeEdit && measures.some((graphic) => graphic.id === activeEdit.measureId && graphic.kind === activeEdit.graphicKind)) activeEdit.syncHostMembership(true);
7721
8022
  (this.activeTransform ?? this.activeTransformSessionRef)?.syncHostGraphics(measures);
@@ -7770,10 +8071,26 @@ var TacticalDraw = class {
7770
8071
  const excluded = this.excludedInteractionIds();
7771
8072
  const bearing = this.adapter.getBearing();
7772
8073
  const features = [];
8074
+ const pinGroups = [];
7773
8075
  for (const id of this.highlightedIds) {
7774
8076
  if (excluded.has(id)) continue;
7775
8077
  const graphic = this.lastRenderedMeasures.find((g) => g.id === id);
7776
8078
  if (!graphic) continue;
8079
+ if (isPointSymbol(graphic)) {
8080
+ const pin = this.renderGraphicGroup(graphic).pin;
8081
+ if (pin) {
8082
+ pinGroups.push({
8083
+ id: `__td_highlight__:${id}`,
8084
+ features: [],
8085
+ iconResources: [],
8086
+ pin: {
8087
+ ...pin,
8088
+ highlightOnly: true
8089
+ }
8090
+ });
8091
+ continue;
8092
+ }
8093
+ }
7777
8094
  const box = this.highlightBoxForGraphic(graphic, bearing, padding);
7778
8095
  if (!box) continue;
7779
8096
  let invertFailed = false;
@@ -7788,12 +8105,12 @@ var TacticalDraw = class {
7788
8105
  const feature = boxOutlineFeature(box, invert, `__td_highlight__:${id}`, style);
7789
8106
  if (!invertFailed) features.push(feature);
7790
8107
  }
7791
- this.adapter.setLayerFeatureGroups(this.highlightLayer, features.map((feature) => ({
8108
+ this.adapter.setLayerFeatureGroups(this.highlightLayer, [...pinGroups, ...features.map((feature) => ({
7792
8109
  id: String(feature.id),
7793
8110
  features: [feature],
7794
8111
  iconResources: []
7795
- })));
7796
- this.hasHighlightOutlines = features.length > 0;
8112
+ }))]);
8113
+ this.hasHighlightOutlines = features.length > 0 || pinGroups.length > 0;
7797
8114
  }
7798
8115
  /**
7799
8116
  * Fit the passive-highlight transform box for one graphic: the point-symbol
@@ -8067,6 +8384,10 @@ var TacticalDraw = class {
8067
8384
  adapter: this.adapter,
8068
8385
  capability: this.pointSymbols,
8069
8386
  symbol: measure,
8387
+ ...this.graphicUsesPinPresentation(measure) ? {
8388
+ renderPin: (symbol) => this.buildGraphicGroup(symbol),
8389
+ canRenderPin: () => this.graphicUsesPinPresentation(measure)
8390
+ } : {},
8070
8391
  offsetDragModifier: () => options?.offsetDragModifier ?? this.offsetDragModifier,
8071
8392
  graphicsLayer: this.graphicsLayer,
8072
8393
  previewLayer: this.previewLayer,
@@ -8433,6 +8754,7 @@ var TacticalDraw = class {
8433
8754
  * the first call every public method throws `TacticalDrawDestroyedError`.
8434
8755
  */
8435
8756
  destroy() {
8757
+ this.unsubscribePins();
8436
8758
  if (this.destroyed) return;
8437
8759
  this.destroyed = true;
8438
8760
  if (this.activeInteraction.isActive) {
@@ -8707,32 +9029,22 @@ function fillPatternImageDimension(pixelRatio) {
8707
9029
  return 2 ** Math.ceil(Math.log2(target));
8708
9030
  }
8709
9031
  /**
8710
- * Parse a hex (`#rgb`, `#rrggbb`, `#rrggbbaa`) or `rgb()`/`rgba()` color
8711
- * string into normalized channels, alpha in `[0, 1]`. Unparseable input falls
8712
- * back to opaque black rather than throwing, since a mis-themed fill should
8713
- * still render *something*.
9032
+ * Parse a hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) or `rgb()`/`rgba()`
9033
+ * color string into normalized channels, alpha in `[0, 1]`. Unparseable input
9034
+ * falls back to opaque black rather than throwing, since a mis-themed fill
9035
+ * should still render *something*. Shared by every renderer that needs CSS
9036
+ * colors as numbers — hatch fills here, billboard pins in the MapLibre
9037
+ * adapter — so the accepted syntax stays identical across them.
8714
9038
  */
8715
9039
  function parseColor(color) {
8716
9040
  const s = color.trim();
8717
9041
  if (s.startsWith("#")) {
8718
- const hex = s.slice(1);
8719
- if (hex.length === 3) return {
8720
- r: parseInt(hex[0] + hex[0], 16),
8721
- g: parseInt(hex[1] + hex[1], 16),
8722
- b: parseInt(hex[2] + hex[2], 16),
8723
- a: 1
8724
- };
8725
- if (hex.length === 6) return {
8726
- r: parseInt(hex.slice(0, 2), 16),
8727
- g: parseInt(hex.slice(2, 4), 16),
8728
- b: parseInt(hex.slice(4, 6), 16),
8729
- a: 1
8730
- };
8731
- if (hex.length === 8) return {
9042
+ const hex = s.length <= 5 ? s.slice(1).replace(/./g, (c) => c + c) : s.slice(1);
9043
+ if (hex.length === 6 || hex.length === 8) return {
8732
9044
  r: parseInt(hex.slice(0, 2), 16),
8733
9045
  g: parseInt(hex.slice(2, 4), 16),
8734
9046
  b: parseInt(hex.slice(4, 6), 16),
8735
- a: parseInt(hex.slice(6, 8), 16) / 255
9047
+ a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1
8736
9048
  };
8737
9049
  }
8738
9050
  const fn = s.match(/^rgba?\(([^)]+)\)$/i);
@@ -8937,4 +9249,4 @@ function nearestWorldCopyPosition(position, refLng) {
8937
9249
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
8938
9250
  }
8939
9251
  //#endregion
8940
- 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 };
9252
+ 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 };