bruce-cesium 7.4.3 → 7.4.5

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.
@@ -37,8 +37,16 @@ var EntityLabel;
37
37
  // so a row of labels reads as one size rather than a set of differently shaped pills.
38
38
  const ICON_BACKGROUND_UNIFORM_TOLERANCE = 0.3;
39
39
  const TEXT_MAX_WIDTH = 200;
40
+ // Distinct icons a cluster shows, the representative's included.
41
+ const ICON_STACK_LIMIT = 3;
42
+ // Size of every icon in a cluster's arrangement, relative to the representative's own.
43
+ const ICON_STACK_SCALE = 0.6;
40
44
  // How far an angled leader runs sideways before the label starts.
41
45
  const ANGLED_LEADER_RUN = 28;
46
+ // Clearance between a point or billboard glyph and the ring drawn around it.
47
+ const ICON_RING_PADDING = 4;
48
+ // Small points still get a ring as large as the default near ring.
49
+ const ICON_RING_MIN = 10;
42
50
  // One per tinted icon, since an SVG mask is addressed by id across the whole document.
43
51
  let tintCounter = 0;
44
52
  // Key where the label instance is stored within the cesium entity.
@@ -220,6 +228,28 @@ var EntityLabel;
220
228
  * Prefer declared semantics; only infers from recognisable glyphs and filename tokens, never image pixels.
221
229
  */
222
230
  EntityLabel.IconPriority = point_clustering_core_1.GetClusterIconPriority;
231
+ function iconLook(options) {
232
+ return {
233
+ icon: options.icon || undefined,
234
+ iconUrl: options.iconUrl || undefined,
235
+ iconClass: typeof options.iconClass === "string" && options.iconClass ? options.iconClass : undefined,
236
+ iconColor: typeof options.iconColor === "string" && options.iconColor ? options.iconColor : undefined,
237
+ iconBackground: options.iconBackground || undefined
238
+ };
239
+ }
240
+ /*
241
+ * Everything that makes two labels look alike, so labels from different groups only merge when they would read the same.
242
+ */
243
+ function clusterLook(options) {
244
+ var _a;
245
+ const presentation = options.presentation || "text";
246
+ return JSON.stringify([
247
+ presentation,
248
+ (_a = options.leader) !== null && _a !== void 0 ? _a : null,
249
+ options.leaderStyle || "default", options.leaderColor || "",
250
+ presentation === "text" ? null : iconLook(options)
251
+ ]);
252
+ }
223
253
  /*
224
254
  * Resolves clustering and stable text placement before depth fading; highlighted labels receive priority.
225
255
  */
@@ -241,7 +271,8 @@ var EntityLabel;
241
271
  if (!((_b = (_a = frame.options) === null || _a === void 0 ? void 0 : _a.cluster) === null || _b === void 0 ? void 0 : _b.enabled) || frame.priority > 0) {
242
272
  continue;
243
273
  }
244
- const key = JSON.stringify([frame.group || "", frame.options.presentation || "text"]);
274
+ // Groups meet inside a presentation; which of them may merge is decided pairwise below.
275
+ const key = frame.options.presentation || "text";
245
276
  if (!groups.has(key)) {
246
277
  groups.set(key, []);
247
278
  }
@@ -253,32 +284,54 @@ var EntityLabel;
253
284
  frame.options.cluster.pixelRange == null ? { ...frame, x: frame.anchorX, y: frame.anchorY, width: 1, height: 1 } :
254
285
  frame.clusterBounds ? { ...frame, ...frame.clusterBounds } : frame);
255
286
  const index = new ScreenIndex();
256
- for (const frame of candidates) {
287
+ const rank = new Map();
288
+ const looks = new Map();
289
+ candidates.forEach((frame, i) => {
257
290
  index.Add(frame);
258
- }
291
+ rank.set(frame.id, i);
292
+ looks.set(frame.id, clusterLook(frame.options));
293
+ });
259
294
  const clusters = (0, point_clustering_core_1.ClusterItems)({
260
295
  items: candidates,
261
296
  id: frame => frame.id,
262
297
  minSize: group[0].options.cluster.minSize,
263
298
  neighbours: frame => {
264
299
  var _a;
265
- return (index.Query(frame, (_a = frame.options.cluster.pixelRange) !== null && _a !== void 0 ? _a : (frame.options.presentation === "icon" ? 24 : 8)));
300
+ const nearby = index.Query(frame, (_a = frame.options.cluster.pixelRange) !== null && _a !== void 0 ? _a : (frame.options.presentation === "icon" ? 24 : 8)).sort((a, b) => rank.get(a.id) - rank.get(b.id));
301
+ return compatibleMembers(frame, nearby, other => other.group || "", other => looks.get(other.id));
266
302
  }
267
303
  });
268
304
  for (const cluster of clusters) {
269
305
  let chosen = cluster.representative;
270
- if ((chosen.options.presentation || "text") !== "text" && chosen.options.cluster.representative !== "nearest") {
271
- let priority = EntityLabel.IconPriority(chosen.options);
272
- for (const member of cluster.members) {
273
- const candidate = EntityLabel.IconPriority(member.options);
274
- if (candidate > priority) {
275
- chosen = member;
276
- priority = candidate;
277
- }
278
- }
306
+ const iconic = (chosen.options.presentation || "text") !== "text";
307
+ // Members in the order they would win representation; the seed is the nearest.
308
+ let ordered = [chosen, ...cluster.members.filter(member => member !== chosen)];
309
+ if (iconic && chosen.options.cluster.representative !== "nearest") {
310
+ ordered = ordered.map((member, i) => ({ member, i, priority: EntityLabel.IconPriority(member.options) }))
311
+ .sort((a, b) => b.priority - a.priority || a.i - b.i).map(entry => entry.member);
312
+ chosen = ordered[0];
279
313
  }
280
314
  const representative = result.get(chosen.id);
281
315
  representative.members = cluster.members.map(frame => frame.id);
316
+ if (iconic && chosen.options.iconScale !== 0) {
317
+ const seen = new Set();
318
+ const icons = [];
319
+ for (const member of ordered) {
320
+ const look = iconLook(member.options);
321
+ const key = JSON.stringify(look);
322
+ if (seen.has(key)) {
323
+ continue;
324
+ }
325
+ seen.add(key);
326
+ if (member !== ordered[0])
327
+ icons.push(look);
328
+ if (seen.size >= ICON_STACK_LIMIT) {
329
+ break;
330
+ }
331
+ }
332
+ if (icons.length)
333
+ representative.icons = icons;
334
+ }
282
335
  representative.entityIds = Array.from(new Set(cluster.members.map(frame => frame.entityId).filter(Boolean)));
283
336
  if (chosen.options.presentation !== "icon") {
284
337
  const values = new Set();
@@ -332,6 +385,34 @@ var EntityLabel;
332
385
  return Array.from(result.values());
333
386
  }
334
387
  EntityLabel.Layout = Layout;
388
+ /*
389
+ * The seed plus every neighbour, in order, that shares a group or a look with each member accepted so far.
390
+ *
391
+ * Checking against the whole accepted set stops a group with mixed icons bridging two groups that look different.
392
+ */
393
+ function compatibleMembers(seed, nearby, group, look) {
394
+ const accepted = [seed];
395
+ const byGroup = new Map([[group(seed), 1]]);
396
+ const byLook = new Map([[look(seed), 1]]);
397
+ const byBoth = new Map([[JSON.stringify([group(seed), look(seed)]), 1]]);
398
+ for (const item of nearby) {
399
+ if (item === seed) {
400
+ continue;
401
+ }
402
+ const itemGroup = group(item), itemLook = look(item), both = JSON.stringify([itemGroup, itemLook]);
403
+ // Accepted members in another group with another look are the ones this item may not sit beside.
404
+ const conflicting = accepted.length - (byGroup.get(itemGroup) || 0) - (byLook.get(itemLook) || 0) + (byBoth.get(both) || 0);
405
+ if (conflicting > 0) {
406
+ continue;
407
+ }
408
+ accepted.push(item);
409
+ byGroup.set(itemGroup, (byGroup.get(itemGroup) || 0) + 1);
410
+ byLook.set(itemLook, (byLook.get(itemLook) || 0) + 1);
411
+ byBoth.set(both, (byBoth.get(both) || 0) + 1);
412
+ }
413
+ return accepted;
414
+ }
415
+ EntityLabel.compatibleMembers = compatibleMembers;
335
416
  const layers = new WeakMap();
336
417
  const containers = new WeakMap();
337
418
  /*
@@ -729,7 +810,7 @@ var EntityLabel;
729
810
  * @param starterPos3d optional position to create label at. Ignored for billboard labels.
730
811
  */
731
812
  async createLabel(starterPos3d) {
732
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
813
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
733
814
  this.removeLabel();
734
815
  let pos3d = starterPos3d ? starterPos3d.clone() : null;
735
816
  const counter = ++this.counter;
@@ -1051,125 +1132,28 @@ var EntityLabel;
1051
1132
  let iconElement;
1052
1133
  let iconContainer;
1053
1134
  let fitIconBackground;
1135
+ let iconStack;
1136
+ let stackedFits = [];
1137
+ let displayedStack = "[]";
1054
1138
  content.className = "entity-label-content";
1055
1139
  content.style.cssText = "display:flex;align-items:center;gap:6px;";
1056
1140
  if (presentation === "icon-text-above") {
1057
1141
  content.style.flexDirection = "column-reverse";
1058
1142
  }
1059
1143
  if (presentation !== "text") {
1060
- const icon = document.createElement("span");
1061
- iconElement = icon;
1062
- icon.className = "entity-label-icon";
1063
- icon.style.cssText = `font-size:${(this.options.iconSize || 24) * ((_m = this.options.iconScale) !== null && _m !== void 0 ? _m : 1)}px;line-height:1;`;
1064
- icon.style.textShadow = "0 1px 2px rgba(0, 0, 0, 0.25)";
1065
- const iconColor = typeof this.options.iconColor === "string" ? this.options.iconColor : undefined;
1066
- const iconBackground = this.options.iconBackground && this.options.iconScale !== 0;
1067
- if (iconColor) {
1068
- icon.style.color = iconColor;
1069
- }
1070
- if (this.options.iconUrl) {
1071
- const image = document.createElement("img");
1072
- image.draggable = false;
1073
- image.alt = this.options.icon || "";
1074
- const scale = (_o = this.options.iconScale) !== null && _o !== void 0 ? _o : 1;
1075
- const size = this.options.iconSize;
1076
- const svgNs = "http://www.w3.org/2000/svg";
1077
- const tint = iconColor ? document.createElementNS(svgNs, "svg") : null;
1078
- const painted = tint ? document.createElementNS(svgNs, "image") : null;
1079
- const inked = tint ? document.createElementNS(svgNs, "rect") : null;
1080
- if (tint) {
1081
- const maskId = `entity-label-tint-${++tintCounter}`;
1082
- const mask = document.createElementNS(svgNs, "mask");
1083
- mask.setAttribute("id", maskId);
1084
- mask.setAttribute("maskUnits", "userSpaceOnUse");
1085
- painted.setAttribute("href", this.options.iconUrl);
1086
- painted.setAttribute("preserveAspectRatio", "xMidYMid meet");
1087
- mask.appendChild(painted);
1088
- inked.setAttribute("fill", iconColor);
1089
- inked.setAttribute("mask", `url(#${maskId})`);
1090
- tint.append(mask, inked);
1091
- tint.style.display = "block";
1092
- icon.style.display = "inline-flex";
1093
- icon.appendChild(tint);
1094
- }
1095
- const resize = () => {
1096
- if (this.disposed || this.counter !== counter) {
1097
- return;
1098
- }
1099
- const intrinsic = this.options.iconScale != null && size == null;
1100
- const width = (intrinsic ? image.naturalWidth || 24 : size || 24) * scale;
1101
- const height = (intrinsic ? image.naturalHeight || 24 : size || 24) * scale;
1102
- if (tint) {
1103
- tint.setAttribute("viewBox", `0 0 ${width} ${height}`);
1104
- for (const node of [tint, painted, inked]) {
1105
- node.setAttribute("width", String(width));
1106
- node.setAttribute("height", String(height));
1107
- }
1108
- }
1109
- else {
1110
- image.style.width = `${width}px`;
1111
- image.style.height = `${height}px`;
1112
- }
1113
- this.viewer.scene.requestRender();
1114
- };
1115
- image.onload = resize;
1116
- resize();
1117
- image.src = this.options.iconUrl;
1118
- if (!tint) {
1119
- icon.appendChild(image);
1120
- }
1121
- }
1122
- else if (typeof this.options.iconClass === "string" && this.options.iconClass) {
1123
- icon.className = `entity-label-icon ${this.options.iconClass}`;
1124
- }
1125
- else {
1126
- icon.textContent = this.options.icon || "●";
1127
- }
1128
- if (iconBackground) {
1129
- const color = Cesium.Color.fromCssColorString(iconColor || "white") || Cesium.Color.WHITE;
1130
- const luminance = 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue;
1131
- const background = (luminance > 0.5 ? color.darken(ICON_BACKGROUND_BLEND, new Cesium.Color()) :
1132
- color.brighten(ICON_BACKGROUND_BLEND, new Cesium.Color()));
1133
- const size = (this.options.iconSize || 24) * ((_p = this.options.iconScale) !== null && _p !== void 0 ? _p : 1);
1134
- icon.style.backgroundColor = background.withAlpha(ICON_BACKGROUND_ALPHA).toCssColorString();
1135
- icon.style.borderRadius = "50%";
1136
- icon.style.padding = `${size / 3}px`;
1137
- icon.style.display = "inline-flex";
1138
- icon.style.alignItems = "center";
1139
- icon.style.justifyContent = "center";
1140
- icon.style.minWidth = `${size}px`;
1141
- icon.style.minHeight = `${size}px`;
1142
- icon.style.boxSizing = "content-box";
1143
- let fitted = false;
1144
- fitIconBackground = () => {
1145
- if (fitted) {
1146
- return;
1147
- }
1148
- const padding = size / 3;
1149
- const width = icon.offsetWidth - padding * 2;
1150
- const height = icon.offsetHeight - padding * 2;
1151
- if (width <= 0 || height <= 0) {
1152
- return;
1153
- }
1154
- fitted = true;
1155
- const longest = Math.max(width, height);
1156
- const difference = (longest - Math.min(width, height)) / longest;
1157
- if (difference > ICON_BACKGROUND_UNIFORM_TOLERANCE) {
1158
- return;
1159
- }
1160
- icon.style.minWidth = `${longest}px`;
1161
- icon.style.minHeight = `${longest}px`;
1162
- this.viewer.scene.requestRender();
1163
- };
1164
- }
1165
- if (this.options.iconScale === 0) {
1166
- icon.style.display = "none";
1167
- }
1144
+ const built = this.buildIcon(this.options, counter);
1145
+ const icon = iconElement = built.icon;
1146
+ fitIconBackground = built.fit;
1168
1147
  makeClickable(icon);
1169
1148
  // Keep badges outside the glyph so font classes and image masks cannot affect them.
1170
1149
  iconContainer = document.createElement("span");
1171
- iconContainer.style.cssText = "position:relative;display:inline-flex;flex:none;";
1172
- iconContainer.appendChild(icon);
1150
+ iconContainer.style.cssText = "position:relative;display:inline-flex;flex:none;align-items:center;";
1151
+ icon.style.position = "relative";
1152
+ iconStack = document.createElement("span");
1153
+ iconStack.className = "entity-label-icon-stack";
1154
+ iconStack.style.cssText = "display:none;gap:2px;align-items:center;justify-items:center;flex:none;";
1155
+ makeClickable(iconStack);
1156
+ iconContainer.append(iconStack, icon);
1173
1157
  content.appendChild(iconContainer);
1174
1158
  }
1175
1159
  if (presentation !== "icon") {
@@ -1181,7 +1165,7 @@ var EntityLabel;
1181
1165
  if (presentation === "text")
1182
1166
  makeClickable(textElement);
1183
1167
  content.appendChild(textElement);
1184
- if ((_q = this.options.cluster) === null || _q === void 0 ? void 0 : _q.enabled) {
1168
+ if ((_m = this.options.cluster) === null || _m === void 0 ? void 0 : _m.enabled) {
1185
1169
  sourceText = document.createElement("span");
1186
1170
  sourceText.className = "entity-label-source-text";
1187
1171
  sourceText.setAttribute("aria-hidden", "true");
@@ -1222,7 +1206,7 @@ var EntityLabel;
1222
1206
  ele.appendChild(countElement);
1223
1207
  }
1224
1208
  ele.className = "entity-label";
1225
- ele.dataset.entityId = ((_r = this.rego) === null || _r === void 0 ? void 0 : _r.entityId) || "";
1209
+ ele.dataset.entityId = ((_o = this.rego) === null || _o === void 0 ? void 0 : _o.entityId) || "";
1226
1210
  ele.dataset.presentation = presentation;
1227
1211
  if (this.groupId) {
1228
1212
  ele.dataset.groupId = this.groupId;
@@ -1338,6 +1322,41 @@ var EntityLabel;
1338
1322
  run, side: (rightwards ? "right" : "left")
1339
1323
  };
1340
1324
  };
1325
+ /*
1326
+ * The on-screen centre and half-size of a point or billboard glyph, relative to its projected position.
1327
+ * Read every frame, since styling and selection can rescale a billboard in place.
1328
+ */
1329
+ const measureIcon = () => {
1330
+ var _a;
1331
+ if (!normalLeader || !(visual instanceof Cesium.Entity)) {
1332
+ return null;
1333
+ }
1334
+ if (visual.point) {
1335
+ const size = (0, js_utils_2.EnsureNumber)(getValue(this.viewer, visual.point.pixelSize), 1) +
1336
+ (0, js_utils_2.EnsureNumber)(getValue(this.viewer, visual.point.outlineWidth), 0) * 2;
1337
+ return size > 0 ? { x: 0, y: 0, radius: size / 2 } : null;
1338
+ }
1339
+ if (!visual.billboard) {
1340
+ return null;
1341
+ }
1342
+ const billboard = visual.billboard;
1343
+ const scale = (0, js_utils_2.EnsureNumber)(getValue(this.viewer, billboard.scale), 1);
1344
+ // Icon billboards only record their image height; generated circles set both sides.
1345
+ const height = (0, js_utils_2.EnsureNumber)((_a = getValue(this.viewer, billboard.height)) !== null && _a !== void 0 ? _a : billboard._billboardSize, 0) * scale;
1346
+ const width = (0, js_utils_2.EnsureNumber)(getValue(this.viewer, billboard.width), height / scale) * scale;
1347
+ if (!(height > 0) || !(width > 0)) {
1348
+ return null;
1349
+ }
1350
+ const vertical = getValue(this.viewer, billboard.verticalOrigin);
1351
+ const horizontal = getValue(this.viewer, billboard.horizontalOrigin);
1352
+ const offset = getValue(this.viewer, billboard.pixelOffset);
1353
+ return {
1354
+ x: ((offset === null || offset === void 0 ? void 0 : offset.x) || 0) + (horizontal === Cesium.HorizontalOrigin.LEFT ? width / 2 : horizontal === Cesium.HorizontalOrigin.RIGHT ? -width / 2 : 0),
1355
+ y: ((offset === null || offset === void 0 ? void 0 : offset.y) || 0) + (vertical === Cesium.VerticalOrigin.BOTTOM || vertical === Cesium.VerticalOrigin.BASELINE ? -height / 2 :
1356
+ vertical === Cesium.VerticalOrigin.TOP ? height / 2 : 0),
1357
+ radius: Math.max(width, height) / 2
1358
+ };
1359
+ };
1341
1360
  let cursorMotion = this.cursorMotion;
1342
1361
  const project = () => {
1343
1362
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
@@ -1365,8 +1384,10 @@ var EntityLabel;
1365
1384
  const relative = Cesium.Cartesian3.subtract(this._pos3d, cameraPosition, new Cesium.Cartesian3());
1366
1385
  const depth = Cesium.Cartesian3.dot(relative, camera.directionWC || camera.direction);
1367
1386
  const distance = Cesium.Cartesian3.magnitude(relative);
1387
+ // If we're labelling from a temp label (mouse hover) don't drive opacity by cam distance.
1388
+ const hoverOnly = this.Transient && !this.Persistent;
1368
1389
  if (!Number.isFinite(distance) || depth <= 0 || distance < ((_c = this.options.near) !== null && _c !== void 0 ? _c : 0) ||
1369
- distance > ((_d = this.options.far) !== null && _d !== void 0 ? _d : Infinity) || (!this.prePositioned && distance >= MAX_DISTANCE)) {
1390
+ distance > ((_d = this.options.far) !== null && _d !== void 0 ? _d : Infinity) || (!this.prePositioned && !hoverOnly && distance >= MAX_DISTANCE)) {
1370
1391
  return null;
1371
1392
  }
1372
1393
  const transforms = Cesium.SceneTransforms;
@@ -1407,9 +1428,13 @@ var EntityLabel;
1407
1428
  this.cursorMotion = cursorMotion;
1408
1429
  const cursorAmount = cursorMotion.amount;
1409
1430
  leader = normalLeader || cursorAmount > 0;
1410
- let x = ((_l = transientPivot === null || transientPivot === void 0 ? void 0 : transientPivot.x) !== null && _l !== void 0 ? _l : anchor.x) + pixelOffset.x;
1411
- let y = ((_m = transientPivot === null || transientPivot === void 0 ? void 0 : transientPivot.y) !== null && _m !== void 0 ? _m : anchor.y) + pixelOffset.y;
1412
- if (cursorAmount > 0) {
1431
+ // An icon keeps its callout centred on the glyph rather than following the cursor across it.
1432
+ const icon = measureIcon();
1433
+ const iconRing = icon ? Math.max(ICON_RING_MIN, icon.radius + ICON_RING_PADDING) : 0;
1434
+ const pivot = icon ? undefined : transientPivot;
1435
+ let x = ((_l = pivot === null || pivot === void 0 ? void 0 : pivot.x) !== null && _l !== void 0 ? _l : anchor.x) + (icon || pixelOffset).x;
1436
+ let y = ((_m = pivot === null || pivot === void 0 ? void 0 : pivot.y) !== null && _m !== void 0 ? _m : anchor.y) + (icon || pixelOffset).y;
1437
+ if (cursorAmount > 0 && !icon) {
1413
1438
  // A leader-less label starts centred on its anchor; preserve that box as it lifts away.
1414
1439
  if (!normalLeader) {
1415
1440
  x -= ele.offsetWidth / layer.coordinates.scaleX / 2;
@@ -1448,9 +1473,11 @@ var EntityLabel;
1448
1473
  leaderLength += (96 - leaderLength) * cursorAmount;
1449
1474
  }
1450
1475
  ;
1476
+ // Measured from the ring, so the visible stem is the same length whatever the glyph's size.
1477
+ leaderLength += iconRing;
1451
1478
  y = anchorY - leaderLength;
1452
1479
  }
1453
- else if (hover && !reused) {
1480
+ else if (hover && !reused && !icon) {
1454
1481
  x = hover.x + pixelOffset.x;
1455
1482
  y = hover.y + pixelOffset.y - 24;
1456
1483
  }
@@ -1493,7 +1520,7 @@ var EntityLabel;
1493
1520
  return null;
1494
1521
  }
1495
1522
  const state = (_p = this.visualRegister) === null || _p === void 0 ? void 0 : _p.GetState({ entityId: (_q = this.rego) === null || _q === void 0 ? void 0 : _q.entityId, menuItemId: (_r = this.rego) === null || _r === void 0 ? void 0 : _r.menuItemId });
1496
- const opacity = this.prePositioned || MAX_DISTANCE === Infinity ? 1 : 1 - distance / MAX_DISTANCE;
1523
+ const opacity = this.prePositioned || hoverOnly || MAX_DISTANCE === Infinity ? 1 : 1 - distance / MAX_DISTANCE;
1497
1524
  return {
1498
1525
  canFlip: leader && presentation !== "icon", hasLeader: leader,
1499
1526
  textWrapped: ((sourceNaturalText === null || sourceNaturalText === void 0 ? void 0 : sourceNaturalText.offsetWidth) || 0) > TEXT_MAX_WIDTH,
@@ -1502,6 +1529,7 @@ var EntityLabel;
1502
1529
  viewportWidth: this.viewer.canvas.clientWidth,
1503
1530
  id: this.id, entityKey: ((_s = this.rego) === null || _s === void 0 ? void 0 : _s.entityId) ? JSON.stringify([this.rego.entityId, this.rego.menuItemId || "", this.Transient && !this.Persistent ? "hover" : ""]) : undefined,
1504
1531
  entityId: (_t = this.rego) === null || _t === void 0 ? void 0 : _t.entityId, group: this.groupId, x, y, width, height, anchorX, anchorY, depth, leaderLength, leaderRun, leaderSide, distance,
1532
+ iconRadius: icon === null || icon === void 0 ? void 0 : icon.radius,
1505
1533
  // Priority shows the hovered member individually without dragging its cluster along.
1506
1534
  opacity, priority: hover || cursorAmount > 0 || (state === null || state === void 0 ? void 0 : state.highlighted) ? 1 : 0, options: this.options, text: originalText, clusterBounds
1507
1535
  };
@@ -1509,7 +1537,7 @@ var EntityLabel;
1509
1537
  let displacement = { x: 0, y: 0 };
1510
1538
  let nudge = { x: 0, y: 0, fromX: 0, fromY: 0, targetX: 0, targetY: 0, start: 0 };
1511
1539
  const apply = (placement) => {
1512
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0;
1540
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2;
1513
1541
  const show = (placement === null || placement === void 0 ? void 0 : placement.visible) && placement.opacity > 0;
1514
1542
  clickEntityIds = show ? placement.entityIds : [];
1515
1543
  ele.style.visibility = show ? "visible" : "hidden";
@@ -1546,6 +1574,29 @@ var EntityLabel;
1546
1574
  this.viewer.scene.requestRender();
1547
1575
  }
1548
1576
  fitIconBackground === null || fitIconBackground === void 0 ? void 0 : fitIconBackground();
1577
+ const stack = JSON.stringify(placement.icons || []);
1578
+ if (iconStack && stack !== displayedStack) {
1579
+ displayedStack = stack;
1580
+ const looks = ((_b = placement.icons) === null || _b === void 0 ? void 0 : _b.length) ? [iconLook(this.options), ...placement.icons] : [];
1581
+ // One small size for all, so no icon in the arrangement reads as more important than another.
1582
+ const size = (this.options.iconSize || 24) * ((_c = this.options.iconScale) !== null && _c !== void 0 ? _c : 1) * ICON_STACK_SCALE;
1583
+ const built = looks.map(look => this.buildIcon({ ...look, iconSize: size }, counter));
1584
+ // Two sit side by side; three make a triangle with the representative on top.
1585
+ iconStack.style.gridTemplateColumns = built.length === 3 ? "auto auto" : `repeat(${built.length}, auto)`;
1586
+ if (built.length === 3) {
1587
+ built[0].icon.style.gridColumn = "1 / span 2";
1588
+ }
1589
+ iconStack.replaceChildren(...built.map(({ icon }) => icon));
1590
+ iconStack.style.display = built.length ? "inline-grid" : "none";
1591
+ // The glyph stays measurable, since cluster membership is taken from its footprint.
1592
+ iconElement.style.visibility = built.length ? "hidden" : "";
1593
+ iconElement.style.position = built.length ? "absolute" : "relative";
1594
+ stackedFits = built.map(({ fit }) => fit).filter(Boolean);
1595
+ // The stack widens the label, which the next frame has to measure.
1596
+ this.viewer.scene.requestRender();
1597
+ }
1598
+ for (const fit of stackedFits)
1599
+ fit();
1549
1600
  if (iconContainer) {
1550
1601
  const badgeWidth = countElement.offsetWidth;
1551
1602
  const iconWidth = iconElement.offsetWidth;
@@ -1561,37 +1612,44 @@ var EntityLabel;
1561
1612
  // Summary and badge changes must fit on their first visible frame, including after resize.
1562
1613
  const ring = this.options.anchorRing;
1563
1614
  const pointer = (layer.pointer || (cursorMotion.target ? cursorMotion.position : undefined) || (this.Transient && !this.Persistent ?
1564
- (this.hoverFade.to === 0 ? this.retainedHoverPosition : (_b = this.visualRegister) === null || _b === void 0 ? void 0 : _b.GetLabelHoverPosition((_c = this.rego) === null || _c === void 0 ? void 0 : _c.entityId, (_d = this.rego) === null || _d === void 0 ? void 0 : _d.menuItemId)) : undefined));
1565
- const hovered = pointer && Math.hypot(pointer.x - frame.anchorX, pointer.y - frame.anchorY) <= ((_e = ring === null || ring === void 0 ? void 0 : ring.hoverDistance) !== null && _e !== void 0 ? _e : 24);
1566
- const normalRadius = (!leader || (ring === null || ring === void 0 ? void 0 : ring.enabled) === false ? 0 : Math.max(frame.distance <= ((_f = ring === null || ring === void 0 ? void 0 : ring.nearDistance) !== null && _f !== void 0 ? _f : 100) ? ((_g = ring === null || ring === void 0 ? void 0 : ring.nearRadius) !== null && _g !== void 0 ? _g : 10) : 0, hovered ? ((_h = ring === null || ring === void 0 ? void 0 : ring.hoverRadius) !== null && _h !== void 0 ? _h : (this.Transient && !this.Persistent ? 15.4 : 22)) : 0));
1615
+ (this.hoverFade.to === 0 ? this.retainedHoverPosition : (_d = this.visualRegister) === null || _d === void 0 ? void 0 : _d.GetLabelHoverPosition((_e = this.rego) === null || _e === void 0 ? void 0 : _e.entityId, (_f = this.rego) === null || _f === void 0 ? void 0 : _f.menuItemId)) : undefined));
1616
+ const iconRing = frame.iconRadius ? Math.max(ICON_RING_MIN, frame.iconRadius + ICON_RING_PADDING) : 0;
1617
+ const hovered = pointer && Math.hypot(pointer.x - frame.anchorX, pointer.y - frame.anchorY) <= Math.max((_g = ring === null || ring === void 0 ? void 0 : ring.hoverDistance) !== null && _g !== void 0 ? _g : 24, iconRing);
1618
+ const normalRadius = (!leader || (ring === null || ring === void 0 ? void 0 : ring.enabled) === false ? 0 : Math.max(frame.distance <= ((_h = ring === null || ring === void 0 ? void 0 : ring.nearDistance) !== null && _h !== void 0 ? _h : 100) ? ((_j = ring === null || ring === void 0 ? void 0 : ring.nearRadius) !== null && _j !== void 0 ? _j : 10) : 0, hovered ? ((_k = ring === null || ring === void 0 ? void 0 : ring.hoverRadius) !== null && _k !== void 0 ? _k : (this.Transient && !this.Persistent ? 15.4 : 22)) : 0));
1567
1619
  const targetRadius = normalRadius + (15.4 - normalRadius) * cursorMotion.amount;
1620
+ // Around a glyph the ring keeps the glyph's size and only fades its stroke in, so it never covers the icon.
1621
+ const targetOuter = iconRing || targetRadius || 2.5;
1622
+ const targetInner = iconRing ? (targetRadius > 0 ? iconRing - 2 : iconRing) : Math.max(0, targetRadius - 2);
1568
1623
  const now = Date.now();
1569
- const duration = (_j = ring === null || ring === void 0 ? void 0 : ring.transitionDuration) !== null && _j !== void 0 ? _j : 200;
1624
+ const duration = (_l = ring === null || ring === void 0 ? void 0 : ring.transitionDuration) !== null && _l !== void 0 ? _l : 200;
1570
1625
  const sharedRing = this.Transient && !this.Persistent && this.hoverFade.to === 1 ? this.hoverRingState : undefined;
1571
1626
  let ringAnimation = sharedRing ? sharedRing.animation : this.ringAnimation;
1572
1627
  if (!ringAnimation) {
1573
- ringAnimation = { target: 0, start: now, outer: 2.5, inner: 0, fromOuter: 2.5, fromInner: 0 };
1628
+ const outer = iconRing || 2.5;
1629
+ const inner = iconRing;
1630
+ ringAnimation = { target: outer, targetInner: inner, start: now, outer, inner, fromOuter: outer, fromInner: inner };
1574
1631
  }
1575
1632
  ;
1576
1633
  const animateRing = () => {
1577
1634
  const progress = duration > 0 ? Math.min(1, Math.max(0, (now - ringAnimation.start) / duration)) : 1;
1578
1635
  const eased = progress * progress * (3 - 2 * progress);
1579
- ringAnimation.outer = ringAnimation.fromOuter + ((ringAnimation.target || 2.5) - ringAnimation.fromOuter) * eased;
1580
- ringAnimation.inner = ringAnimation.fromInner + (Math.max(0, ringAnimation.target - 2) - ringAnimation.fromInner) * eased;
1636
+ ringAnimation.outer = ringAnimation.fromOuter + (ringAnimation.target - ringAnimation.fromOuter) * eased;
1637
+ ringAnimation.inner = ringAnimation.fromInner + (ringAnimation.targetInner - ringAnimation.fromInner) * eased;
1581
1638
  return progress;
1582
1639
  };
1583
1640
  animateRing();
1584
- if (ringAnimation.target !== targetRadius) {
1641
+ if (ringAnimation.target !== targetOuter || ringAnimation.targetInner !== targetInner) {
1585
1642
  ringAnimation = {
1586
1643
  ...ringAnimation,
1587
- target: targetRadius,
1644
+ target: targetOuter,
1645
+ targetInner,
1588
1646
  start: now,
1589
1647
  fromOuter: ringAnimation.outer,
1590
1648
  fromInner: ringAnimation.inner
1591
1649
  };
1592
1650
  }
1593
1651
  // Animate the hole and stem together; no CSS transition may lag behind a moving world anchor.
1594
- if (animateRing() < 1 && (ringAnimation.fromOuter !== (targetRadius || 2.5) || ringAnimation.fromInner !== Math.max(0, targetRadius - 2))) {
1652
+ if (animateRing() < 1 && (ringAnimation.fromOuter !== targetOuter || ringAnimation.fromInner !== targetInner)) {
1595
1653
  this.viewer.scene.requestRender();
1596
1654
  }
1597
1655
  this.ringAnimation = ringAnimation;
@@ -1606,19 +1664,19 @@ var EntityLabel;
1606
1664
  if (leader && angled) {
1607
1665
  // The declutter owns the side; the fit only says which one had the room to begin with.
1608
1666
  const run = Math.max(ANGLED_LEADER_RUN, radius + 4);
1609
- const side = (_l = (_k = placement.textSide) !== null && _k !== void 0 ? _k : frame.leaderSide) !== null && _l !== void 0 ? _l : "right";
1667
+ const side = (_o = (_m = placement.textSide) !== null && _m !== void 0 ? _m : frame.leaderSide) !== null && _o !== void 0 ? _o : "right";
1610
1668
  const beside = side === "left" ? frame.anchorX - run - width : frame.anchorX + run;
1611
1669
  position.x = Math.max(0, Math.min(beside, this.viewer.canvas.clientWidth - width));
1612
1670
  }
1613
1671
  else if (leader && placement.textSide === "left") {
1614
1672
  position.x = Math.max(0, Math.min(frame.anchorX - width, this.viewer.canvas.clientWidth - width));
1615
1673
  }
1616
- const gap = (_o = (_m = this.options.pointerAvoidance) === null || _m === void 0 ? void 0 : _m.gap) !== null && _o !== void 0 ? _o : 12;
1674
+ const gap = (_q = (_p = this.options.pointerAvoidance) === null || _p === void 0 ? void 0 : _p.gap) !== null && _q !== void 0 ? _q : 12;
1617
1675
  const containsPointer = (x, y, margin) => {
1618
1676
  return pointer && pointer.x >= x - margin && pointer.x <= x + width + margin && pointer.y >= y - margin && pointer.y <= y + height + margin;
1619
1677
  };
1620
1678
  const passive = !selectOnClick || !this.visualRegister;
1621
- if (passive && ((_p = this.options.pointerAvoidance) === null || _p === void 0 ? void 0 : _p.enabled) !== false && pointer &&
1679
+ if (passive && ((_r = this.options.pointerAvoidance) === null || _r === void 0 ? void 0 : _r.enabled) !== false && pointer &&
1622
1680
  (containsPointer(position.x, position.y, gap) || containsPointer(position.x + displacement.x, position.y + displacement.y, gap))) {
1623
1681
  if (containsPointer(position.x + displacement.x, position.y + displacement.y, 0)) {
1624
1682
  // Choose against the original box and retain the offset until the pointer leaves both boxes.
@@ -1636,7 +1694,7 @@ var EntityLabel;
1636
1694
  else {
1637
1695
  displacement = { x: 0, y: 0 };
1638
1696
  }
1639
- const nudgeDuration = (_r = (_q = this.options.pointerAvoidance) === null || _q === void 0 ? void 0 : _q.transitionDuration) !== null && _r !== void 0 ? _r : 200;
1697
+ const nudgeDuration = (_t = (_s = this.options.pointerAvoidance) === null || _s === void 0 ? void 0 : _s.transitionDuration) !== null && _t !== void 0 ? _t : 200;
1640
1698
  const animateNudge = () => {
1641
1699
  const progress = now < nudge.start ? 0 : nudgeDuration > 0 ? Math.min(1, (now - nudge.start) / nudgeDuration) : 1;
1642
1700
  const eased = progress * progress * (3 - 2 * progress);
@@ -1652,7 +1710,7 @@ var EntityLabel;
1652
1710
  fromY: nudge.y,
1653
1711
  targetX: displacement.x,
1654
1712
  targetY: displacement.y,
1655
- start: now + ((_t = (_s = this.options.pointerAvoidance) === null || _s === void 0 ? void 0 : _s.delay) !== null && _t !== void 0 ? _t : 180)
1713
+ start: now + ((_v = (_u = this.options.pointerAvoidance) === null || _u === void 0 ? void 0 : _u.delay) !== null && _v !== void 0 ? _v : 180)
1656
1714
  };
1657
1715
  }
1658
1716
  if (animateNudge() < 1 && (nudge.fromX !== nudge.targetX || nudge.fromY !== nudge.targetY)) {
@@ -1662,9 +1720,8 @@ var EntityLabel;
1662
1720
  position.y += nudge.y;
1663
1721
  ele.style.left = `${coordinates.x + position.x * coordinates.scaleX}px`;
1664
1722
  ele.style.top = `${coordinates.y + position.y * coordinates.scaleY}px`;
1665
- const clickTarget = content.querySelector('[role="button"]');
1666
- if (clickTarget) {
1667
- clickTarget.setAttribute("aria-label", placement.members.length ? `Select ${placement.entityIds.length} entities` : `Select ${(_v = (_u = this.options.text) !== null && _u !== void 0 ? _u : text) !== null && _v !== void 0 ? _v : (_w = this.rego) === null || _w === void 0 ? void 0 : _w.entityId}`);
1723
+ for (const clickTarget of Array.from(content.querySelectorAll('[role="button"]'))) {
1724
+ clickTarget.setAttribute("aria-label", placement.members.length ? `Select ${placement.entityIds.length} entities` : `Select ${(_x = (_w = this.options.text) !== null && _w !== void 0 ? _w : text) !== null && _x !== void 0 ? _x : (_y = this.rego) === null || _y === void 0 ? void 0 : _y.entityId}`);
1668
1725
  }
1669
1726
  if (leader) {
1670
1727
  const nodeRadius = ringAnimation.outer;
@@ -1704,13 +1761,13 @@ var EntityLabel;
1704
1761
  // Keep the stem inside the 126-degree arc, away from antialiased endpoints.
1705
1762
  const arcFraction = 0.35;
1706
1763
  const rotationLimit = Math.PI * arcFraction - Math.PI / 30;
1707
- const targetOffset = hovered ? Math.max(-rotationLimit, Math.min(rotationLimit, wrappedAngle)) : ((_x = ringAnimation.arcOffset) !== null && _x !== void 0 ? _x : 0);
1764
+ const targetOffset = hovered ? Math.max(-rotationLimit, Math.min(rotationLimit, wrappedAngle)) : ((_z = ringAnimation.arcOffset) !== null && _z !== void 0 ? _z : 0);
1708
1765
  const targetOpen = cursorMotion.target || (hovered && (ring === null || ring === void 0 ? void 0 : ring.enabled) !== false && targetRadius > 0) ? 1 : 0;
1709
- const elapsed = Math.min(32, Math.max(0, now - ((_y = ringAnimation.arcTime) !== null && _y !== void 0 ? _y : now)));
1766
+ const elapsed = Math.min(32, Math.max(0, now - ((_0 = ringAnimation.arcTime) !== null && _0 !== void 0 ? _0 : now)));
1710
1767
  const blend = duration > 0 ? 1 - Math.exp(-elapsed / (duration / 3)) : 1;
1711
1768
  const approach = (value, target) => Math.abs(value - target) < 0.001 ? target : value + (target - value) * blend;
1712
- ringAnimation.arcOffset = approach((_z = ringAnimation.arcOffset) !== null && _z !== void 0 ? _z : 0, targetOffset);
1713
- ringAnimation.arcOpen = approach((_0 = ringAnimation.arcOpen) !== null && _0 !== void 0 ? _0 : 0, targetOpen);
1769
+ ringAnimation.arcOffset = approach((_1 = ringAnimation.arcOffset) !== null && _1 !== void 0 ? _1 : 0, targetOffset);
1770
+ ringAnimation.arcOpen = approach((_2 = ringAnimation.arcOpen) !== null && _2 !== void 0 ? _2 : 0, targetOpen);
1714
1771
  ringAnimation.arcTime = now;
1715
1772
  if (ringAnimation.arcOffset !== targetOffset || ringAnimation.arcOpen !== targetOpen) {
1716
1773
  this.viewer.scene.requestRender();
@@ -1763,6 +1820,123 @@ var EntityLabel;
1763
1820
  }, 2500);
1764
1821
  });
1765
1822
  }
1823
+ /*
1824
+ * Builds one icon glyph from label options, with its background chip when asked for.
1825
+ *
1826
+ * The returned fit squares the chip off once the glyph has a measurable size.
1827
+ */
1828
+ buildIcon(options, counter) {
1829
+ var _a, _b, _c;
1830
+ let fit;
1831
+ const icon = document.createElement("span");
1832
+ icon.className = "entity-label-icon";
1833
+ icon.style.cssText = `font-size:${(options.iconSize || 24) * ((_a = options.iconScale) !== null && _a !== void 0 ? _a : 1)}px;line-height:1;`;
1834
+ icon.style.textShadow = "0 1px 2px rgba(0, 0, 0, 0.25)";
1835
+ const iconColor = typeof options.iconColor === "string" ? options.iconColor : undefined;
1836
+ const iconBackground = options.iconBackground && options.iconScale !== 0;
1837
+ if (iconColor) {
1838
+ icon.style.color = iconColor;
1839
+ }
1840
+ if (options.iconUrl) {
1841
+ const image = document.createElement("img");
1842
+ image.draggable = false;
1843
+ image.alt = options.icon || "";
1844
+ const scale = (_b = options.iconScale) !== null && _b !== void 0 ? _b : 1;
1845
+ const size = options.iconSize;
1846
+ const svgNs = "http://www.w3.org/2000/svg";
1847
+ const tint = iconColor ? document.createElementNS(svgNs, "svg") : null;
1848
+ const painted = tint ? document.createElementNS(svgNs, "image") : null;
1849
+ const inked = tint ? document.createElementNS(svgNs, "rect") : null;
1850
+ if (tint) {
1851
+ const maskId = `entity-label-tint-${++tintCounter}`;
1852
+ const mask = document.createElementNS(svgNs, "mask");
1853
+ mask.setAttribute("id", maskId);
1854
+ mask.setAttribute("maskUnits", "userSpaceOnUse");
1855
+ painted.setAttribute("href", options.iconUrl);
1856
+ painted.setAttribute("preserveAspectRatio", "xMidYMid meet");
1857
+ mask.appendChild(painted);
1858
+ inked.setAttribute("fill", iconColor);
1859
+ inked.setAttribute("mask", `url(#${maskId})`);
1860
+ tint.append(mask, inked);
1861
+ tint.style.display = "block";
1862
+ icon.style.display = "inline-flex";
1863
+ icon.appendChild(tint);
1864
+ }
1865
+ const resize = () => {
1866
+ if (this.disposed || this.counter !== counter) {
1867
+ return;
1868
+ }
1869
+ const intrinsic = options.iconScale != null && size == null;
1870
+ const width = (intrinsic ? image.naturalWidth || 24 : size || 24) * scale;
1871
+ const height = (intrinsic ? image.naturalHeight || 24 : size || 24) * scale;
1872
+ if (tint) {
1873
+ tint.setAttribute("viewBox", `0 0 ${width} ${height}`);
1874
+ for (const node of [tint, painted, inked]) {
1875
+ node.setAttribute("width", String(width));
1876
+ node.setAttribute("height", String(height));
1877
+ }
1878
+ }
1879
+ else {
1880
+ image.style.width = `${width}px`;
1881
+ image.style.height = `${height}px`;
1882
+ }
1883
+ this.viewer.scene.requestRender();
1884
+ };
1885
+ image.onload = resize;
1886
+ resize();
1887
+ image.src = options.iconUrl;
1888
+ if (!tint) {
1889
+ icon.appendChild(image);
1890
+ }
1891
+ }
1892
+ else if (typeof options.iconClass === "string" && options.iconClass) {
1893
+ icon.className = `entity-label-icon ${options.iconClass}`;
1894
+ }
1895
+ else {
1896
+ icon.textContent = options.icon || "●";
1897
+ }
1898
+ if (iconBackground) {
1899
+ const color = Cesium.Color.fromCssColorString(iconColor || "white") || Cesium.Color.WHITE;
1900
+ const luminance = 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue;
1901
+ const background = (luminance > 0.5 ? color.darken(ICON_BACKGROUND_BLEND, new Cesium.Color()) :
1902
+ color.brighten(ICON_BACKGROUND_BLEND, new Cesium.Color()));
1903
+ const size = (options.iconSize || 24) * ((_c = options.iconScale) !== null && _c !== void 0 ? _c : 1);
1904
+ icon.style.backgroundColor = background.withAlpha(ICON_BACKGROUND_ALPHA).toCssColorString();
1905
+ icon.style.borderRadius = "50%";
1906
+ icon.style.padding = `${size / 3}px`;
1907
+ icon.style.display = "inline-flex";
1908
+ icon.style.alignItems = "center";
1909
+ icon.style.justifyContent = "center";
1910
+ icon.style.minWidth = `${size}px`;
1911
+ icon.style.minHeight = `${size}px`;
1912
+ icon.style.boxSizing = "content-box";
1913
+ let fitted = false;
1914
+ fit = () => {
1915
+ if (fitted) {
1916
+ return;
1917
+ }
1918
+ const padding = size / 3;
1919
+ const width = icon.offsetWidth - padding * 2;
1920
+ const height = icon.offsetHeight - padding * 2;
1921
+ if (width <= 0 || height <= 0) {
1922
+ return;
1923
+ }
1924
+ fitted = true;
1925
+ const longest = Math.max(width, height);
1926
+ const difference = (longest - Math.min(width, height)) / longest;
1927
+ if (difference > ICON_BACKGROUND_UNIFORM_TOLERANCE) {
1928
+ return;
1929
+ }
1930
+ icon.style.minWidth = `${longest}px`;
1931
+ icon.style.minHeight = `${longest}px`;
1932
+ this.viewer.scene.requestRender();
1933
+ };
1934
+ }
1935
+ if (options.iconScale === 0) {
1936
+ icon.style.display = "none";
1937
+ }
1938
+ return { icon, fit };
1939
+ }
1766
1940
  /**
1767
1941
  * Removes label from the viewer and unsubscribes from events.
1768
1942
  */