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