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