@orbat-mapper/tactical-draw 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -485,6 +485,7 @@ var BaseMapAdapter = class {
485
485
  if (!this.layers.has(layerId)) return;
486
486
  const nextGroups = groups.map((group) => ({
487
487
  id: group.id,
488
+ ...group.pin ? { pin: group.pin } : {},
488
489
  features: [...group.features],
489
490
  iconResources: [...group.iconResources],
490
491
  ...group.imageOverlays ? { imageOverlays: [...group.imageOverlays] } : {},
@@ -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
+ });
1367
+ }
1368
+ /**
1369
+ * Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
1370
+ * above the top-edge midpoint, along the box's local "up" direction, so the
1371
+ * grip stays glued to the box (rotates with it) rather than floating at a
1372
+ * fixed screen offset. The label transform box passes its own tighter
1373
+ * clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
1374
+ */
1375
+ function rotateGripPixel(box, offset = 28) {
1376
+ const { center, halfHeight, angle } = box;
1377
+ const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
1378
+ return [center[0] + rx, center[1] + ry];
1379
+ }
1380
+ /**
1381
+ * Point-in-oriented-rectangle test, used to start a body (translate) drag
1382
+ * when the pointer goes down inside the box but not on a corner/rotate grip.
1383
+ * De-rotates `pixel` into the box's local frame about `box.center` and
1384
+ * compares against the half-extents.
1385
+ */
1386
+ function pointInBox(box, pixel) {
1387
+ const { center, halfWidth, halfHeight, angle } = box;
1388
+ const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
1389
+ return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
1390
+ }
1391
+ /** Plain 2D distance between two pixel coordinates. */
1392
+ function pixelDistance$1(a, b) {
1393
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
1394
+ }
1395
+ /**
1396
+ * Pick what part of the box, if any, `pixel` hits — corners first, then the
1397
+ * rotate grip, then plain containment (body drag). Corners and the rotate
1398
+ * grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
1399
+ * click just outside the box never starts a body drag. Returns `null` when
1400
+ * nothing matches.
1401
+ */
1402
+ function pickBoxHandle(box, pixel, tolerance) {
1403
+ const corners = boxCorners(box);
1404
+ for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
1405
+ kind: "scale",
1406
+ index: i
1407
+ };
1408
+ if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
1409
+ if (pointInBox(box, pixel)) return { kind: "body" };
1410
+ return null;
1411
+ }
1412
+ //#endregion
1413
+ //#region src/point-symbol-offset.ts
1414
+ const EPSILON = 1e-7;
1415
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
1416
+ /** Trim from the symbol attachment end, including across a junction when necessary. */
1417
+ function padAnchor(path, padding) {
1418
+ if (!Number.isFinite(padding) || padding <= 0) return path;
1419
+ const trimmed = [...path];
1420
+ while (trimmed.length > 1) {
1421
+ const start = trimmed.shift();
1422
+ const end = trimmed[0];
1423
+ const length = distance(start, end);
1424
+ if (length > padding) {
1425
+ const fraction = padding / length;
1426
+ trimmed.unshift([start[0] + (end[0] - start[0]) * fraction, start[1] + (end[1] - start[1]) * fraction]);
1427
+ return trimmed;
1428
+ }
1429
+ padding -= length;
1430
+ }
1431
+ return [];
1432
+ }
1433
+ /** Pure layout: projection input/output is in CSS pixels, including document contexts. */
1434
+ function resolvePointSymbolOffset(symbol, render, view) {
1435
+ const actual = view.getPixelFromCoordinate(symbol.position);
1436
+ if (!actual) return null;
1437
+ const scale = pointSymbolPixelScale(symbol, render, view.getResolution());
1438
+ const angle = symbol.rotation - view.getBearing();
1439
+ const metadata = render.offset;
1440
+ const registration = metadata?.ordinaryRegistration ?? render.octagonAnchor;
1441
+ const shift = rotate((registration.x - render.anchor.x) * scale, (registration.y - render.anchor.y) * scale, angle);
1442
+ const ordinaryDisplay = [actual[0] + shift[0], actual[1] + shift[1]];
1443
+ const layout = {
1444
+ render,
1445
+ resource: pointSymbolResource(render),
1446
+ bodyOpacity: (symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
1447
+ rotation: angle,
1448
+ actual,
1449
+ display: ordinaryDisplay,
1450
+ imageAnchor: actual,
1451
+ ordinaryDisplay,
1452
+ path: [],
1453
+ kind: "ordinary",
1454
+ status: "ok",
1455
+ scale,
1456
+ connectorStyle: {
1457
+ strokeColor: symbol.offset?.style?.strokeColor ?? metadata?.strokeColor ?? "#000000",
1458
+ opacity: (symbol.offset?.style?.opacity ?? symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
1459
+ strokeWidth: symbol.offset?.style?.strokeWidth ?? (metadata?.strokeWidth ?? 4) * scale,
1460
+ strokeDash: [...symbol.offset?.style?.strokeDash ?? []]
1461
+ }
1462
+ };
1463
+ if (!symbol.offset) return layout;
1464
+ const { placement, connector, anchorPadding = 0 } = symbol.offset;
1465
+ if (!metadata || connector !== "direct" && !metadata.indicator) return {
1466
+ ...layout,
1467
+ status: "unsupported-presentation"
1468
+ };
1469
+ const display = placement.mode === "ground" ? view.getPixelFromCoordinate(placement.position) : [ordinaryDisplay[0] + placement.offset[0], ordinaryDisplay[1] + placement.offset[1]];
1470
+ if (!display) return {
1471
+ ...layout,
1472
+ status: "unsupported-presentation"
1473
+ };
1474
+ if (distance(display, ordinaryDisplay) < EPSILON) return layout;
1475
+ const body = metadata.body;
1476
+ const offset = rotate((metadata.attachment.x - body.anchor.x) * scale, (metadata.attachment.y - body.anchor.y) * scale, angle);
1477
+ const attachment = [display[0] + offset[0], display[1] + offset[1]];
1478
+ const resolved = {
1479
+ ...layout,
1480
+ render: body,
1481
+ resource: pointSymbolResource(body),
1482
+ display,
1483
+ imageAnchor: display,
1484
+ attachment
1485
+ };
1486
+ if (connector === "direct") return {
1487
+ ...resolved,
1488
+ kind: "direct",
1489
+ path: padAnchor(distance(attachment, actual) < EPSILON ? [] : [attachment, actual], anchorPadding)
1490
+ };
1491
+ const indicator = metadata.indicator;
1492
+ const rawDirection = rotate(indicator.direction.x, indicator.direction.y, angle);
1493
+ const norm = Math.hypot(...rawDirection);
1494
+ const direction = [rawDirection[0] / norm, rawDirection[1] / norm];
1495
+ const dx = actual[0] - attachment[0], dy = actual[1] - attachment[1];
1496
+ const forward = dx * direction[0] + dy * direction[1];
1497
+ if (Math.abs(dx * direction[1] - dy * direction[0]) < EPSILON && forward >= (indicator.minLength ?? 0) * scale) return {
1498
+ ...resolved,
1499
+ direction,
1500
+ kind: "indicator-only",
1501
+ path: padAnchor([attachment, actual], anchorPadding)
1502
+ };
1503
+ const length = symbol.offset.indicatorLength !== void 0 ? symbol.offset.indicatorLength * render.octagonSize : indicator.length;
1504
+ const junction = [attachment[0] + direction[0] * length * scale, attachment[1] + direction[1] * length * scale];
1505
+ return {
1506
+ ...resolved,
1507
+ direction,
1508
+ junction,
1509
+ kind: "with-extension",
1510
+ status: "ok",
1511
+ path: padAnchor(distance(junction, actual) < EPSILON ? [attachment, junction] : [
1512
+ attachment,
1513
+ junction,
1514
+ actual
1515
+ ], anchorPadding)
1516
+ };
837
1517
  }
838
1518
  /**
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.
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.
842
1522
  */
843
- function resizePointSymbolSize(size, value) {
844
- if (size.unit === "pixels") return {
845
- value,
846
- unit: "pixels"
1523
+ function indicatorLengthAt(point, attachment, direction, scale, octagonSize) {
1524
+ const forward = (point[0] - attachment[0]) * direction[0] + (point[1] - attachment[1]) * direction[1];
1525
+ return Math.max(0, forward / (scale * octagonSize));
1526
+ }
1527
+ /** Set body placement without changing the authoritative coordinate. */
1528
+ function placePointSymbol(symbol, render, view, display, mode = symbol.offset?.placement.mode ?? "screen-relative") {
1529
+ if (!render.offset) throw new TypeError("This renderer does not support offset placement");
1530
+ const ordinary = resolvePointSymbolOffset({
1531
+ ...symbol,
1532
+ offset: void 0
1533
+ }, render, view);
1534
+ if (!ordinary) throw new RangeError("Point symbol cannot be projected in this view");
1535
+ const next = clonePointSymbol(symbol);
1536
+ const coordinate = mode === "ground" ? view.getCoordinateFromPixel?.(display) : void 0;
1537
+ if (mode === "ground" && !coordinate) throw new RangeError("Ground placement requires inverse projection");
1538
+ next.offset = {
1539
+ ...next.offset,
1540
+ connector: next.offset?.connector ?? (render.offset.indicator ? "auto" : "direct"),
1541
+ placement: mode === "ground" ? {
1542
+ mode,
1543
+ position: [...coordinate]
1544
+ } : {
1545
+ mode,
1546
+ offset: [display[0] - ordinary.display[0], display[1] - ordinary.display[1]]
1547
+ }
847
1548
  };
848
- const scale = value / size.value;
849
- if (!Number.isFinite(scale) || scale <= 0) return {
850
- ...size,
851
- value
1549
+ return next;
1550
+ }
1551
+ function convertPointSymbolPlacement(symbol, render, view, mode) {
1552
+ const layout = resolvePointSymbolOffset(symbol, render, view);
1553
+ if (!layout || layout.status === "unsupported-presentation") throw new TypeError("Offset presentation is unavailable");
1554
+ return placePointSymbol(symbol, render, view, layout.display, mode);
1555
+ }
1556
+ /** Explicit alignment, also used with hysteresis by pointer editing. */
1557
+ function alignPointSymbolIndicator(symbol, render, view) {
1558
+ const layout = resolvePointSymbolOffset(symbol, render, view);
1559
+ if (!layout?.attachment || !layout.direction || !render.offset?.indicator) return symbol;
1560
+ const d = layout.direction;
1561
+ const delta = [layout.actual[0] - layout.attachment[0], layout.actual[1] - layout.attachment[1]];
1562
+ const forward = Math.max((render.offset.indicator.minLength ?? 0) * layout.scale, delta[0] * d[0] + delta[1] * d[1]);
1563
+ return placePointSymbol(symbol, render, view, [layout.display[0] + delta[0] - forward * d[0], layout.display[1] + delta[1] - forward * d[1]]);
1564
+ }
1565
+ function movePointSymbolLocation(symbol, render, view, position) {
1566
+ const before = resolvePointSymbolOffset(symbol, render, view);
1567
+ let next = {
1568
+ ...clonePointSymbol(symbol),
1569
+ position: [...position]
852
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) {
853
1579
  return {
854
- ...size,
855
- value,
856
- ...size.minPixels !== void 0 ? { minPixels: size.minPixels * scale } : {},
857
- ...size.maxPixels !== void 0 ? { maxPixels: size.maxPixels * scale } : {}
1580
+ id: render.resourceId,
1581
+ svg: render.svg,
1582
+ width: render.width,
1583
+ height: render.height,
1584
+ anchor: [render.anchor.x, render.anchor.y],
1585
+ coreRect: {
1586
+ x: render.octagonAnchor.x - render.octagonSize / 2,
1587
+ y: render.octagonAnchor.y - render.octagonSize / 2,
1588
+ width: render.octagonSize,
1589
+ height: render.octagonSize
1590
+ }
1591
+ };
1592
+ }
1593
+ /**
1594
+ * Convert a resolved CSS-pixel layout to output units (e.g. 25.4 / 96 for mm).
1595
+ * Scales positions, body scale, line width and dashes together, including the
1596
+ * already-resolved offset and padding. Canonical resources, direction, rotation
1597
+ * and opacity stay unchanged. Does not mutate the input or authored symbol.
1598
+ */
1599
+ function scalePointSymbolLayout(layout, factor) {
1600
+ if (!Number.isFinite(factor) || factor <= 0) throw new RangeError("Layout scale factor must be positive and finite");
1601
+ const point = ([x, y]) => [x * factor, y * factor];
1602
+ return {
1603
+ ...layout,
1604
+ actual: point(layout.actual),
1605
+ display: point(layout.display),
1606
+ imageAnchor: point(layout.imageAnchor),
1607
+ ordinaryDisplay: point(layout.ordinaryDisplay),
1608
+ attachment: layout.attachment && point(layout.attachment),
1609
+ junction: layout.junction && point(layout.junction),
1610
+ path: layout.path.map(point),
1611
+ scale: layout.scale * factor,
1612
+ connectorStyle: {
1613
+ ...layout.connectorStyle,
1614
+ strokeWidth: layout.connectorStyle.strokeWidth * factor,
1615
+ strokeDash: layout.connectorStyle.strokeDash.map((length) => length * factor)
1616
+ }
858
1617
  };
859
1618
  }
860
1619
  //#endregion
@@ -954,97 +1713,19 @@ var ActiveInteractionTracker = class {
954
1713
  this.currentSession = null;
955
1714
  return true;
956
1715
  }
957
- return false;
958
- }
959
- get session() {
960
- return this.currentSession;
961
- }
962
- /** True while an interaction is mid-construction (between `begin` and `set`). */
963
- get isStarting() {
964
- return this.starting;
965
- }
966
- get isActive() {
967
- return this.starting || this.handle !== null && !this.handle.settled;
968
- }
969
- };
970
- //#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
- });
1716
+ return false;
1717
+ }
1718
+ get session() {
1719
+ return this.currentSession;
1720
+ }
1721
+ /** True while an interaction is mid-construction (between `begin` and `set`). */
1722
+ get isStarting() {
1723
+ return this.starting;
1724
+ }
1725
+ get isActive() {
1726
+ return this.starting || this.handle !== null && !this.handle.settled;
997
1727
  }
998
1728
  };
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
1729
  //#endregion
1049
1730
  //#region src/tactical-draw/measure-options.ts
1050
1731
  const DEFAULT_LABEL_SIZE_PIXELS = DEFAULT_LABEL_HEIGHT_CSS_PIXELS;
@@ -1626,238 +2307,59 @@ function createSnapResolver(adapter, getGraphics, getGeometryFeatures, getOption
1626
2307
  let indexedCandidates = [];
1627
2308
  let indexedFeatures;
1628
2309
  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
- };
1760
- }
1761
- };
1762
- }
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;
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
@@ -2558,181 +3060,47 @@ function runEditInteraction(spec) {
2558
3060
  value
2559
3061
  });
2560
3062
  return value;
2561
- });
2562
- },
2563
- fail(error) {
2564
- if (!interaction.settled) runBeforeTeardown({
2565
- status: "rejected",
2566
- error
2567
- });
2568
- interaction.fail(error);
2569
- },
2570
- writeLayer: interaction.writeLayer,
2571
- onTeardown: interaction.onTeardown
2572
- };
2573
- const onDown = (pixel, info) => {
2574
- suppressClickAway = false;
2575
- spec.onDown(pixel, info);
2576
- };
2577
- const unsubDown = driver.onPointerDown(onDown);
2578
- const unsubMove = driver.onPointerMove(spec.onMove);
2579
- const unsubUp = driver.onPointerUp(spec.onUp);
2580
- const unsubPreview = adapter.onPick(previewLayer, onPreviewPick);
2581
- const unsubHandles = adapter.onPick(handlesLayer, onHandlesPick);
2582
- const unsubGraphics = adapter.onPick(graphicsLayer, onGraphicsPick);
2583
- adapter.on("click", onClick);
2584
- interaction.onTeardown(() => {
2585
- runBeforeTeardown({
2586
- status: "rejected",
2587
- error: void 0
2588
- });
2589
- adapter.off("click", onClick);
2590
- unsubPreview();
2591
- unsubHandles();
2592
- unsubGraphics();
2593
- unsubDown();
2594
- unsubMove();
2595
- unsubUp();
2596
- 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];
2691
- });
2692
- }
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;
3063
+ });
3064
+ },
3065
+ fail(error) {
3066
+ if (!interaction.settled) runBeforeTeardown({
3067
+ status: "rejected",
3068
+ error
3069
+ });
3070
+ interaction.fail(error);
3071
+ },
3072
+ writeLayer: interaction.writeLayer,
3073
+ onTeardown: interaction.onTeardown
3074
+ };
3075
+ const onDown = (pixel, info) => {
3076
+ suppressClickAway = false;
3077
+ spec.onDown(pixel, info);
3078
+ };
3079
+ const unsubDown = driver.onPointerDown(onDown);
3080
+ const unsubMove = driver.onPointerMove(spec.onMove);
3081
+ const unsubUp = driver.onPointerUp(spec.onUp);
3082
+ const unsubPreview = adapter.onPick(previewLayer, onPreviewPick);
3083
+ const unsubHandles = adapter.onPick(handlesLayer, onHandlesPick);
3084
+ const unsubGraphics = adapter.onPick(graphicsLayer, onGraphicsPick);
3085
+ adapter.on("click", onClick);
3086
+ interaction.onTeardown(() => {
3087
+ runBeforeTeardown({
3088
+ status: "rejected",
3089
+ error: void 0
3090
+ });
3091
+ adapter.off("click", onClick);
3092
+ unsubPreview();
3093
+ unsubHandles();
3094
+ unsubGraphics();
3095
+ unsubDown();
3096
+ unsubMove();
3097
+ unsubUp();
3098
+ spec.extraTeardown?.();
3099
+ driver.dispose();
3100
+ }, spec.onAbort);
3101
+ spec.attach(controllerContext, driver, shell);
3102
+ }
3103
+ });
2736
3104
  }
2737
3105
  //#endregion
2738
3106
  //#region src/tactical-draw/handles.ts
@@ -5079,335 +5447,115 @@ function runEdit(opts) {
5079
5447
  onMove: onDriverMove,
5080
5448
  onUp: onDriverUp,
5081
5449
  ownsGraphic: (id) => id === measure.id,
5082
- close: () => {
5083
- ctx.commit(rebuildRendered);
5084
- },
5085
- onForeignPick: onClickAwayGraphicsPick,
5086
- historySettle: () => history.settle(),
5087
- facadeOnSettled: onSettled,
5088
- 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];
5450
+ close: () => {
5451
+ ctx.commit(rebuildRendered);
5452
+ },
5453
+ onForeignPick: onClickAwayGraphicsPick,
5454
+ historySettle: () => history.settle(),
5455
+ facadeOnSettled: onSettled,
5456
+ emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
5457
+ extraTeardown: () => {
5458
+ if (activeDrag !== null || activeOptionDrag !== null || activeBoxDrag !== null || labels.dragging) {
5459
+ emitTransient("end", workingPoints, activeDrag?.controlPointIndex ?? null);
5460
+ driver.setDragPanEnabled(true);
5461
+ activeDrag = null;
5462
+ activeOptionDrag = null;
5463
+ activeBoxDrag = null;
5464
+ labels.endDrag();
5465
+ dragInFlight = false;
5466
+ }
5467
+ },
5468
+ attach(interaction, createdDriver, createdShell) {
5469
+ ctx = interaction;
5470
+ driver = createdDriver;
5471
+ shell = createdShell;
5472
+ renderWorkingGraphic();
5473
+ renderHandlesLayer();
5474
+ recomputeDirty();
5475
+ if (onSession) onSession(session);
5476
+ }
5477
+ });
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) {
@@ -6816,6 +6984,18 @@ function runPointSymbolEdit(opts) {
6816
6984
  };
6817
6985
  const pointerAngle = (pixel, anchor) => Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
6818
6986
  const onDown = (pixel, info) => {
6987
+ if (pinEditing && opts.canRenderPin?.() && !ctx.settled) {
6988
+ const footprint = adapter.getPinFootprint?.(working.id);
6989
+ if (footprint && adapter.pickPin?.(working.id, pixel, info?.hitTolerance ?? 12)) {
6990
+ pinDrag = {
6991
+ start: clonePointSymbol(working),
6992
+ down: [...pixel],
6993
+ attachment: footprint.attachment
6994
+ };
6995
+ driver.setDragPanEnabled(false);
6996
+ }
6997
+ return;
6998
+ }
6819
6999
  if (ctx.settled || !box || !currentLayout || modes.includes("delete")) return;
6820
7000
  let offsetMode = modes.includes("offset");
6821
7001
  if (!offsetMode && !modes.includes("transform")) return;
@@ -6850,6 +7030,13 @@ function runPointSymbolEdit(opts) {
6850
7030
  driver.setDragPanEnabled(false);
6851
7031
  };
6852
7032
  const transformed = (pixel, info) => {
7033
+ if (pinDrag) {
7034
+ const position = adapter.movePinFromPixel?.(working.id, [pinDrag.attachment[0] + pixel[0] - pinDrag.down[0], pinDrag.attachment[1] + pixel[1] - pinDrag.down[1]]);
7035
+ return position ? {
7036
+ ...clonePointSymbol(pinDrag.start),
7037
+ position
7038
+ } : null;
7039
+ }
6853
7040
  if (!drag) return null;
6854
7041
  let next = clonePointSymbol(drag.start);
6855
7042
  const intrinsic = drag.render;
@@ -6906,6 +7093,16 @@ function runPointSymbolEdit(opts) {
6906
7093
  render();
6907
7094
  };
6908
7095
  const onUp = (pixel, info) => {
7096
+ if (pinDrag) {
7097
+ const next = transformed(pixel, info), start = pinDrag.start, down = pinDrag.down;
7098
+ pinDrag = null;
7099
+ driver.setDragPanEnabled(true);
7100
+ shell.suppressNextClickAway();
7101
+ working = clonePointSymbol(start);
7102
+ if (next && !withinClickSlop(pixel, down)) apply(next, "gesture", "Move unit");
7103
+ else render();
7104
+ return;
7105
+ }
6909
7106
  if (!drag) return;
6910
7107
  const currentDrag = drag;
6911
7108
  const next = transformed(pixel, info);
@@ -6928,7 +7125,9 @@ function runPointSymbolEdit(opts) {
6928
7125
  previewLayer,
6929
7126
  handlesLayer,
6930
7127
  closeOnClickAway,
6931
- onViewChange: render,
7128
+ onViewChange: () => {
7129
+ if (!pinEditing || !opts.canRenderPin?.()) render();
7130
+ },
6932
7131
  onBeforeTeardown: (settlement) => {
6933
7132
  settleToGraphics(settlement.status === "committed" ? settlement.value.graphic : void 0);
6934
7133
  },
@@ -6953,6 +7152,7 @@ function runPointSymbolEdit(opts) {
6953
7152
  });
6954
7153
  return {
6955
7154
  promise: handle.promise,
7155
+ refreshPresentation: render,
6956
7156
  get settled() {
6957
7157
  return handle.settled;
6958
7158
  },
@@ -7450,6 +7650,9 @@ var TacticalDraw = class {
7450
7650
  * `render()` so outlines track geometry, style, and dropped graphics.
7451
7651
  */
7452
7652
  highlightedIds = [];
7653
+ pinBodies = /* @__PURE__ */ new WeakMap();
7654
+ pins = new PinPresentationStore();
7655
+ unsubscribePins;
7453
7656
  /**
7454
7657
  * Whether the highlight layer currently holds any outline. Lets
7455
7658
  * `renderHighlights` skip a redundant empty write on every view change /
@@ -7485,6 +7688,54 @@ var TacticalDraw = class {
7485
7688
  this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(this.baseGuideStyle()));
7486
7689
  this.adapter.setLayerStyle(this.highlightLayer, guideToStyleOptions(this.baseHighlightStyle()));
7487
7690
  this.adapter.onViewChange(this.handleViewChange);
7691
+ this.unsubscribePins = this.pins.onChange(() => {
7692
+ this.invalidateGraphicGroupCache(false);
7693
+ this.refreshActiveEditPresentation();
7694
+ this.reconcileGraphicsStack();
7695
+ this.renderHighlights();
7696
+ });
7697
+ }
7698
+ setPinPresentation(defaults) {
7699
+ this.assertAlive();
7700
+ this.pins.setDefaults(defaults);
7701
+ }
7702
+ setPinOverride(id, patch) {
7703
+ this.assertAlive();
7704
+ this.pins.setOverride(id, patch);
7705
+ }
7706
+ resetPinOverride(id) {
7707
+ this.assertAlive();
7708
+ this.pins.resetOverride(id);
7709
+ }
7710
+ getPinPresentationCapability(id) {
7711
+ return this.adapter.getPinPresentationCapability?.(id) ?? {
7712
+ status: "unsupported",
7713
+ reason: "This map adapter uses ordinary point-symbol presentation"
7714
+ };
7715
+ }
7716
+ onPinPresentationStatusChange(listener) {
7717
+ this.assertAlive();
7718
+ return this.adapter.onPinPresentationStatusChange?.(listener) ?? (() => {});
7719
+ }
7720
+ getPinPresentationState() {
7721
+ return this.pins.getState();
7722
+ }
7723
+ /**
7724
+ * Let an in-flight edit session re-read pin presentation, when it is a kind
7725
+ * of session that renders pins at all. Both the presentation-store change
7726
+ * handler and the adapter-capability change in `handleViewChange` need it.
7727
+ */
7728
+ refreshActiveEditPresentation() {
7729
+ if (this.activeEdit && "refreshPresentation" in this.activeEdit) this.activeEdit.refreshPresentation();
7730
+ }
7731
+ /**
7732
+ * Whether a point symbol should render as a billboard pin rather than an
7733
+ * ordinary point symbol: its own presentation opts in, and the adapter
7734
+ * currently supports rendering pins. Pass an already-resolved
7735
+ * `presentation` to skip re-resolving it when a caller has one in hand.
7736
+ */
7737
+ graphicUsesPinPresentation(graphic, presentation) {
7738
+ return (presentation ?? this.pins.resolve(graphic.id)).enabled && this.adapter.getPinPresentationCapability?.().status === "supported";
7488
7739
  }
7489
7740
  /** Change the default shortcut for subsequent drags, including in an active edit. */
7490
7741
  setOffsetDragModifier(modifier) {
@@ -7524,9 +7775,10 @@ var TacticalDraw = class {
7524
7775
  if (cached.dependence === "none") return cached.group;
7525
7776
  if (cached.dependence === "resolution" && Object.is(cached.resolution, this.adapter.getResolution())) return cached.group;
7526
7777
  }
7527
- const dependence = graphicViewDependence(graphic, this.adapter.scalesGroundPointSymbolsNatively === true);
7778
+ const presentation = isPointSymbol(graphic) ? this.pins.resolve(graphic.id) : void 0;
7779
+ const dependence = presentation && this.graphicUsesPinPresentation(graphic, presentation) ? "none" : graphicViewDependence(graphic, this.adapter.scalesGroundPointSymbolsNatively === true);
7528
7780
  const resolution = dependence === "none" ? void 0 : this.adapter.getResolution();
7529
- const group = this.buildGraphicGroup(graphic);
7781
+ const group = this.buildGraphicGroup(graphic, presentation);
7530
7782
  this.graphicGroupCache.set(graphic, {
7531
7783
  group,
7532
7784
  dependence,
@@ -7539,10 +7791,49 @@ var TacticalDraw = class {
7539
7791
  * drawn from the module-scoped {@link nextGraphicGroupRevision} counter (see
7540
7792
  * its doc comment for why it isn't a per-instance field).
7541
7793
  */
7542
- buildGraphicGroup(graphic) {
7794
+ buildGraphicGroup(graphic, resolved) {
7543
7795
  const revision = ++nextGraphicGroupRevision;
7544
7796
  if (isPointSymbol(graphic)) {
7545
7797
  if (!this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
7798
+ const presentation = resolved ?? this.pins.resolve(graphic.id);
7799
+ if (this.graphicUsesPinPresentation(graphic, presentation)) {
7800
+ const body = this.pinBodies.get(graphic) ?? this.pointSymbols.render({
7801
+ ...graphic,
7802
+ graphicModifiers: {
7803
+ ...graphic.graphicModifiers,
7804
+ direction: void 0
7805
+ }
7806
+ });
7807
+ this.pinBodies.set(graphic, body);
7808
+ const resource = pointSymbolResource(body);
7809
+ return {
7810
+ id: graphic.id,
7811
+ revision,
7812
+ features: [{
7813
+ type: "Feature",
7814
+ id: `${graphic.id}:pin`,
7815
+ geometry: {
7816
+ type: "Point",
7817
+ coordinates: [...graphic.position]
7818
+ },
7819
+ properties: {
7820
+ graphicId: graphic.id,
7821
+ graphicKind: "point-symbol",
7822
+ pinPresentation: true
7823
+ }
7824
+ }],
7825
+ iconResources: [],
7826
+ pin: {
7827
+ id: graphic.id,
7828
+ position: graphic.position,
7829
+ resource,
7830
+ octagonSize: body.octagonSize,
7831
+ presentation,
7832
+ opacity: (graphic.style?.opacity ?? 1) * (graphic.style?.overallOpacity ?? 1),
7833
+ ...graphic.graphicModifiers?.direction !== void 0 ? { direction: graphic.rotation + graphic.graphicModifiers.direction } : {}
7834
+ }
7835
+ };
7836
+ }
7546
7837
  const plan = renderPointSymbolForAdapter(this.adapter, this.pointSymbols, graphic);
7547
7838
  return {
7548
7839
  id: graphic.id,
@@ -7578,7 +7869,8 @@ var TacticalDraw = class {
7578
7869
  * View changes are handled per entry by {@link renderGraphicGroup} so
7579
7870
  * resolution-independent graphics remain cache hits.
7580
7871
  */
7581
- invalidateGraphicGroupCache() {
7872
+ invalidateGraphicGroupCache(clearBodies = true) {
7873
+ if (clearBodies) this.pinBodies = /* @__PURE__ */ new WeakMap();
7582
7874
  this.graphicGroupCache = /* @__PURE__ */ new WeakMap();
7583
7875
  }
7584
7876
  /** Refresh capability-dependent renders before any caller-visible state is mutated. */
@@ -7620,8 +7912,16 @@ var TacticalDraw = class {
7620
7912
  * point symbols and transform members already present in the host stack do
7621
7913
  * the same. Graphics without a host slot remain on preview.
7622
7914
  */
7915
+ pinSupport;
7623
7916
  handleViewChange = () => {
7624
7917
  if (this.destroyed) return;
7918
+ const support = this.adapter.getPinPresentationCapability?.().status;
7919
+ if (support !== this.pinSupport) {
7920
+ this.pinSupport = support;
7921
+ this.refreshActiveEditPresentation();
7922
+ this.invalidateGraphicGroupCache();
7923
+ if (this.hasRendered) this.reconcileGraphicsStack();
7924
+ }
7625
7925
  const resolution = this.adapter.getResolution();
7626
7926
  const resolutionChanged = !Object.is(resolution, this.lastViewResolution);
7627
7927
  this.lastViewResolution = resolution;
@@ -7631,7 +7931,7 @@ var TacticalDraw = class {
7631
7931
  if (!resolutionChanged && !this.lastRenderedHasViewDependent) return;
7632
7932
  const excluded = this.graphicsStackExcludedIds();
7633
7933
  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;
7934
+ if (!visible.some((g) => this.renderGraphicGroup(g).pin ? false : graphicViewDependence(g, nativePointSymbolScaling) !== "none")) return;
7635
7935
  this.reconcileGraphicsStack(visible);
7636
7936
  };
7637
7937
  /**
@@ -7715,7 +8015,7 @@ var TacticalDraw = class {
7715
8015
  }
7716
8016
  }
7717
8017
  this.lastRenderedMeasures = measures.slice();
7718
- this.lastRenderedHasViewDependent = this.lastRenderedMeasures.some((g) => graphicViewDependence(g, this.adapter.scalesGroundPointSymbolsNatively === true) === "view");
8018
+ this.lastRenderedHasViewDependent = this.lastRenderedMeasures.some((g) => !this.renderGraphicGroup(g).pin && graphicViewDependence(g, this.adapter.scalesGroundPointSymbolsNatively === true) === "view");
7719
8019
  this.graphicStack.acknowledgeHostRender(measures);
7720
8020
  if (activeEdit && "syncHostMembership" in activeEdit && measures.some((graphic) => graphic.id === activeEdit.measureId && graphic.kind === activeEdit.graphicKind)) activeEdit.syncHostMembership(true);
7721
8021
  (this.activeTransform ?? this.activeTransformSessionRef)?.syncHostGraphics(measures);
@@ -7770,10 +8070,26 @@ var TacticalDraw = class {
7770
8070
  const excluded = this.excludedInteractionIds();
7771
8071
  const bearing = this.adapter.getBearing();
7772
8072
  const features = [];
8073
+ const pinGroups = [];
7773
8074
  for (const id of this.highlightedIds) {
7774
8075
  if (excluded.has(id)) continue;
7775
8076
  const graphic = this.lastRenderedMeasures.find((g) => g.id === id);
7776
8077
  if (!graphic) continue;
8078
+ if (isPointSymbol(graphic)) {
8079
+ const pin = this.renderGraphicGroup(graphic).pin;
8080
+ if (pin) {
8081
+ pinGroups.push({
8082
+ id: `__td_highlight__:${id}`,
8083
+ features: [],
8084
+ iconResources: [],
8085
+ pin: {
8086
+ ...pin,
8087
+ highlightOnly: true
8088
+ }
8089
+ });
8090
+ continue;
8091
+ }
8092
+ }
7777
8093
  const box = this.highlightBoxForGraphic(graphic, bearing, padding);
7778
8094
  if (!box) continue;
7779
8095
  let invertFailed = false;
@@ -7788,12 +8104,12 @@ var TacticalDraw = class {
7788
8104
  const feature = boxOutlineFeature(box, invert, `__td_highlight__:${id}`, style);
7789
8105
  if (!invertFailed) features.push(feature);
7790
8106
  }
7791
- this.adapter.setLayerFeatureGroups(this.highlightLayer, features.map((feature) => ({
8107
+ this.adapter.setLayerFeatureGroups(this.highlightLayer, [...pinGroups, ...features.map((feature) => ({
7792
8108
  id: String(feature.id),
7793
8109
  features: [feature],
7794
8110
  iconResources: []
7795
- })));
7796
- this.hasHighlightOutlines = features.length > 0;
8111
+ }))]);
8112
+ this.hasHighlightOutlines = features.length > 0 || pinGroups.length > 0;
7797
8113
  }
7798
8114
  /**
7799
8115
  * Fit the passive-highlight transform box for one graphic: the point-symbol
@@ -8067,6 +8383,10 @@ var TacticalDraw = class {
8067
8383
  adapter: this.adapter,
8068
8384
  capability: this.pointSymbols,
8069
8385
  symbol: measure,
8386
+ ...this.graphicUsesPinPresentation(measure) ? {
8387
+ renderPin: (symbol) => this.buildGraphicGroup(symbol),
8388
+ canRenderPin: () => this.graphicUsesPinPresentation(measure)
8389
+ } : {},
8070
8390
  offsetDragModifier: () => options?.offsetDragModifier ?? this.offsetDragModifier,
8071
8391
  graphicsLayer: this.graphicsLayer,
8072
8392
  previewLayer: this.previewLayer,
@@ -8433,6 +8753,7 @@ var TacticalDraw = class {
8433
8753
  * the first call every public method throws `TacticalDrawDestroyedError`.
8434
8754
  */
8435
8755
  destroy() {
8756
+ this.unsubscribePins();
8436
8757
  if (this.destroyed) return;
8437
8758
  this.destroyed = true;
8438
8759
  if (this.activeInteraction.isActive) {
@@ -8707,32 +9028,22 @@ function fillPatternImageDimension(pixelRatio) {
8707
9028
  return 2 ** Math.ceil(Math.log2(target));
8708
9029
  }
8709
9030
  /**
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*.
9031
+ * Parse a hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) or `rgb()`/`rgba()`
9032
+ * color string into normalized channels, alpha in `[0, 1]`. Unparseable input
9033
+ * falls back to opaque black rather than throwing, since a mis-themed fill
9034
+ * should still render *something*. Shared by every renderer that needs CSS
9035
+ * colors as numbers — hatch fills here, billboard pins in the MapLibre
9036
+ * adapter — so the accepted syntax stays identical across them.
8714
9037
  */
8715
9038
  function parseColor(color) {
8716
9039
  const s = color.trim();
8717
9040
  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 {
9041
+ const hex = s.length <= 5 ? s.slice(1).replace(/./g, (c) => c + c) : s.slice(1);
9042
+ if (hex.length === 6 || hex.length === 8) return {
8732
9043
  r: parseInt(hex.slice(0, 2), 16),
8733
9044
  g: parseInt(hex.slice(2, 4), 16),
8734
9045
  b: parseInt(hex.slice(4, 6), 16),
8735
- a: parseInt(hex.slice(6, 8), 16) / 255
9046
+ a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1
8736
9047
  };
8737
9048
  }
8738
9049
  const fn = s.match(/^rgba?\(([^)]+)\)$/i);
@@ -8937,4 +9248,4 @@ function nearestWorldCopyPosition(position, refLng) {
8937
9248
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
8938
9249
  }
8939
9250
  //#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 };
9251
+ export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_PIN_PRESENTATION, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, ImageCapabilityRequiredError, MINE_PATTERN_TILE_SIZE, PinPresentationStore, PointSymbolCapabilityRequiredError, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, alignPointSymbolIndicator, bindAbortable, cloneImageGraphic, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, convertPointSymbolPlacement, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, imageFootprint, imageOpacity, isAnchoredGraphic, isImageGraphic, isMinePattern, isPointSymbol, isTacticalDrawAbortError, isValidPinNumber, makeFillPatternImage, mergeNativeAndCachedPickFeatures, movePointSymbolLocation, nearestWorldCopyLng, nearestWorldCopyPosition, normalizeImageGraphic, parseColor, pickHitTolerance, pickPointerType, placePointSymbol, pointSymbolFootprint, pointSymbolResource, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolvePinPresentation, resolvePointSymbolOffset, resolveTextHaloWidth, resolveTextSizePixels, scalePointSymbolLayout, tryControlMeasureIdFromFeature, validateImageGraphic };