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.
@@ -5387,8 +5387,16 @@
5387
5387
  // so a row of labels reads as one size rather than a set of differently shaped pills.
5388
5388
  const ICON_BACKGROUND_UNIFORM_TOLERANCE = 0.3;
5389
5389
  const TEXT_MAX_WIDTH = 200;
5390
+ // Distinct icons a cluster shows, the representative's included.
5391
+ const ICON_STACK_LIMIT = 3;
5392
+ // Size of every icon in a cluster's arrangement, relative to the representative's own.
5393
+ const ICON_STACK_SCALE = 0.6;
5390
5394
  // How far an angled leader runs sideways before the label starts.
5391
5395
  const ANGLED_LEADER_RUN = 28;
5396
+ // Clearance between a point or billboard glyph and the ring drawn around it.
5397
+ const ICON_RING_PADDING = 4;
5398
+ // Small points still get a ring as large as the default near ring.
5399
+ const ICON_RING_MIN = 10;
5392
5400
  // One per tinted icon, since an SVG mask is addressed by id across the whole document.
5393
5401
  let tintCounter = 0;
5394
5402
  // Key where the label instance is stored within the cesium entity.
@@ -5570,6 +5578,28 @@
5570
5578
  * Prefer declared semantics; only infers from recognisable glyphs and filename tokens, never image pixels.
5571
5579
  */
5572
5580
  EntityLabel.IconPriority = GetClusterIconPriority;
5581
+ function iconLook(options) {
5582
+ return {
5583
+ icon: options.icon || undefined,
5584
+ iconUrl: options.iconUrl || undefined,
5585
+ iconClass: typeof options.iconClass === "string" && options.iconClass ? options.iconClass : undefined,
5586
+ iconColor: typeof options.iconColor === "string" && options.iconColor ? options.iconColor : undefined,
5587
+ iconBackground: options.iconBackground || undefined
5588
+ };
5589
+ }
5590
+ /*
5591
+ * Everything that makes two labels look alike, so labels from different groups only merge when they would read the same.
5592
+ */
5593
+ function clusterLook(options) {
5594
+ var _a;
5595
+ const presentation = options.presentation || "text";
5596
+ return JSON.stringify([
5597
+ presentation,
5598
+ (_a = options.leader) !== null && _a !== void 0 ? _a : null,
5599
+ options.leaderStyle || "default", options.leaderColor || "",
5600
+ presentation === "text" ? null : iconLook(options)
5601
+ ]);
5602
+ }
5573
5603
  /*
5574
5604
  * Resolves clustering and stable text placement before depth fading; highlighted labels receive priority.
5575
5605
  */
@@ -5591,7 +5621,8 @@
5591
5621
  if (!((_b = (_a = frame.options) === null || _a === void 0 ? void 0 : _a.cluster) === null || _b === void 0 ? void 0 : _b.enabled) || frame.priority > 0) {
5592
5622
  continue;
5593
5623
  }
5594
- const key = JSON.stringify([frame.group || "", frame.options.presentation || "text"]);
5624
+ // Groups meet inside a presentation; which of them may merge is decided pairwise below.
5625
+ const key = frame.options.presentation || "text";
5595
5626
  if (!groups.has(key)) {
5596
5627
  groups.set(key, []);
5597
5628
  }
@@ -5603,32 +5634,54 @@
5603
5634
  frame.options.cluster.pixelRange == null ? { ...frame, x: frame.anchorX, y: frame.anchorY, width: 1, height: 1 } :
5604
5635
  frame.clusterBounds ? { ...frame, ...frame.clusterBounds } : frame);
5605
5636
  const index = new ScreenIndex();
5606
- for (const frame of candidates) {
5637
+ const rank = new Map();
5638
+ const looks = new Map();
5639
+ candidates.forEach((frame, i) => {
5607
5640
  index.Add(frame);
5608
- }
5641
+ rank.set(frame.id, i);
5642
+ looks.set(frame.id, clusterLook(frame.options));
5643
+ });
5609
5644
  const clusters = ClusterItems({
5610
5645
  items: candidates,
5611
5646
  id: frame => frame.id,
5612
5647
  minSize: group[0].options.cluster.minSize,
5613
5648
  neighbours: frame => {
5614
5649
  var _a;
5615
- return (index.Query(frame, (_a = frame.options.cluster.pixelRange) !== null && _a !== void 0 ? _a : (frame.options.presentation === "icon" ? 24 : 8)));
5650
+ 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));
5651
+ return compatibleMembers(frame, nearby, other => other.group || "", other => looks.get(other.id));
5616
5652
  }
5617
5653
  });
5618
5654
  for (const cluster of clusters) {
5619
5655
  let chosen = cluster.representative;
5620
- if ((chosen.options.presentation || "text") !== "text" && chosen.options.cluster.representative !== "nearest") {
5621
- let priority = EntityLabel.IconPriority(chosen.options);
5622
- for (const member of cluster.members) {
5623
- const candidate = EntityLabel.IconPriority(member.options);
5624
- if (candidate > priority) {
5625
- chosen = member;
5626
- priority = candidate;
5627
- }
5628
- }
5656
+ const iconic = (chosen.options.presentation || "text") !== "text";
5657
+ // Members in the order they would win representation; the seed is the nearest.
5658
+ let ordered = [chosen, ...cluster.members.filter(member => member !== chosen)];
5659
+ if (iconic && chosen.options.cluster.representative !== "nearest") {
5660
+ ordered = ordered.map((member, i) => ({ member, i, priority: EntityLabel.IconPriority(member.options) }))
5661
+ .sort((a, b) => b.priority - a.priority || a.i - b.i).map(entry => entry.member);
5662
+ chosen = ordered[0];
5629
5663
  }
5630
5664
  const representative = result.get(chosen.id);
5631
5665
  representative.members = cluster.members.map(frame => frame.id);
5666
+ if (iconic && chosen.options.iconScale !== 0) {
5667
+ const seen = new Set();
5668
+ const icons = [];
5669
+ for (const member of ordered) {
5670
+ const look = iconLook(member.options);
5671
+ const key = JSON.stringify(look);
5672
+ if (seen.has(key)) {
5673
+ continue;
5674
+ }
5675
+ seen.add(key);
5676
+ if (member !== ordered[0])
5677
+ icons.push(look);
5678
+ if (seen.size >= ICON_STACK_LIMIT) {
5679
+ break;
5680
+ }
5681
+ }
5682
+ if (icons.length)
5683
+ representative.icons = icons;
5684
+ }
5632
5685
  representative.entityIds = Array.from(new Set(cluster.members.map(frame => frame.entityId).filter(Boolean)));
5633
5686
  if (chosen.options.presentation !== "icon") {
5634
5687
  const values = new Set();
@@ -5681,6 +5734,34 @@
5681
5734
  return Array.from(result.values());
5682
5735
  }
5683
5736
  EntityLabel.Layout = Layout;
5737
+ /*
5738
+ * The seed plus every neighbour, in order, that shares a group or a look with each member accepted so far.
5739
+ *
5740
+ * Checking against the whole accepted set stops a group with mixed icons bridging two groups that look different.
5741
+ */
5742
+ function compatibleMembers(seed, nearby, group, look) {
5743
+ const accepted = [seed];
5744
+ const byGroup = new Map([[group(seed), 1]]);
5745
+ const byLook = new Map([[look(seed), 1]]);
5746
+ const byBoth = new Map([[JSON.stringify([group(seed), look(seed)]), 1]]);
5747
+ for (const item of nearby) {
5748
+ if (item === seed) {
5749
+ continue;
5750
+ }
5751
+ const itemGroup = group(item), itemLook = look(item), both = JSON.stringify([itemGroup, itemLook]);
5752
+ // Accepted members in another group with another look are the ones this item may not sit beside.
5753
+ const conflicting = accepted.length - (byGroup.get(itemGroup) || 0) - (byLook.get(itemLook) || 0) + (byBoth.get(both) || 0);
5754
+ if (conflicting > 0) {
5755
+ continue;
5756
+ }
5757
+ accepted.push(item);
5758
+ byGroup.set(itemGroup, (byGroup.get(itemGroup) || 0) + 1);
5759
+ byLook.set(itemLook, (byLook.get(itemLook) || 0) + 1);
5760
+ byBoth.set(both, (byBoth.get(both) || 0) + 1);
5761
+ }
5762
+ return accepted;
5763
+ }
5764
+ EntityLabel.compatibleMembers = compatibleMembers;
5684
5765
  const layers = new WeakMap();
5685
5766
  const containers = new WeakMap();
5686
5767
  /*
@@ -6078,7 +6159,7 @@
6078
6159
  * @param starterPos3d optional position to create label at. Ignored for billboard labels.
6079
6160
  */
6080
6161
  async createLabel(starterPos3d) {
6081
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
6162
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
6082
6163
  this.removeLabel();
6083
6164
  let pos3d = starterPos3d ? starterPos3d.clone() : null;
6084
6165
  const counter = ++this.counter;
@@ -6400,125 +6481,28 @@
6400
6481
  let iconElement;
6401
6482
  let iconContainer;
6402
6483
  let fitIconBackground;
6484
+ let iconStack;
6485
+ let stackedFits = [];
6486
+ let displayedStack = "[]";
6403
6487
  content.className = "entity-label-content";
6404
6488
  content.style.cssText = "display:flex;align-items:center;gap:6px;";
6405
6489
  if (presentation === "icon-text-above") {
6406
6490
  content.style.flexDirection = "column-reverse";
6407
6491
  }
6408
6492
  if (presentation !== "text") {
6409
- const icon = document.createElement("span");
6410
- iconElement = icon;
6411
- icon.className = "entity-label-icon";
6412
- icon.style.cssText = `font-size:${(this.options.iconSize || 24) * ((_m = this.options.iconScale) !== null && _m !== void 0 ? _m : 1)}px;line-height:1;`;
6413
- icon.style.textShadow = "0 1px 2px rgba(0, 0, 0, 0.25)";
6414
- const iconColor = typeof this.options.iconColor === "string" ? this.options.iconColor : undefined;
6415
- const iconBackground = this.options.iconBackground && this.options.iconScale !== 0;
6416
- if (iconColor) {
6417
- icon.style.color = iconColor;
6418
- }
6419
- if (this.options.iconUrl) {
6420
- const image = document.createElement("img");
6421
- image.draggable = false;
6422
- image.alt = this.options.icon || "";
6423
- const scale = (_o = this.options.iconScale) !== null && _o !== void 0 ? _o : 1;
6424
- const size = this.options.iconSize;
6425
- const svgNs = "http://www.w3.org/2000/svg";
6426
- const tint = iconColor ? document.createElementNS(svgNs, "svg") : null;
6427
- const painted = tint ? document.createElementNS(svgNs, "image") : null;
6428
- const inked = tint ? document.createElementNS(svgNs, "rect") : null;
6429
- if (tint) {
6430
- const maskId = `entity-label-tint-${++tintCounter}`;
6431
- const mask = document.createElementNS(svgNs, "mask");
6432
- mask.setAttribute("id", maskId);
6433
- mask.setAttribute("maskUnits", "userSpaceOnUse");
6434
- painted.setAttribute("href", this.options.iconUrl);
6435
- painted.setAttribute("preserveAspectRatio", "xMidYMid meet");
6436
- mask.appendChild(painted);
6437
- inked.setAttribute("fill", iconColor);
6438
- inked.setAttribute("mask", `url(#${maskId})`);
6439
- tint.append(mask, inked);
6440
- tint.style.display = "block";
6441
- icon.style.display = "inline-flex";
6442
- icon.appendChild(tint);
6443
- }
6444
- const resize = () => {
6445
- if (this.disposed || this.counter !== counter) {
6446
- return;
6447
- }
6448
- const intrinsic = this.options.iconScale != null && size == null;
6449
- const width = (intrinsic ? image.naturalWidth || 24 : size || 24) * scale;
6450
- const height = (intrinsic ? image.naturalHeight || 24 : size || 24) * scale;
6451
- if (tint) {
6452
- tint.setAttribute("viewBox", `0 0 ${width} ${height}`);
6453
- for (const node of [tint, painted, inked]) {
6454
- node.setAttribute("width", String(width));
6455
- node.setAttribute("height", String(height));
6456
- }
6457
- }
6458
- else {
6459
- image.style.width = `${width}px`;
6460
- image.style.height = `${height}px`;
6461
- }
6462
- this.viewer.scene.requestRender();
6463
- };
6464
- image.onload = resize;
6465
- resize();
6466
- image.src = this.options.iconUrl;
6467
- if (!tint) {
6468
- icon.appendChild(image);
6469
- }
6470
- }
6471
- else if (typeof this.options.iconClass === "string" && this.options.iconClass) {
6472
- icon.className = `entity-label-icon ${this.options.iconClass}`;
6473
- }
6474
- else {
6475
- icon.textContent = this.options.icon || "●";
6476
- }
6477
- if (iconBackground) {
6478
- const color = Cesium.Color.fromCssColorString(iconColor || "white") || Cesium.Color.WHITE;
6479
- const luminance = 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue;
6480
- const background = (luminance > 0.5 ? color.darken(ICON_BACKGROUND_BLEND, new Cesium.Color()) :
6481
- color.brighten(ICON_BACKGROUND_BLEND, new Cesium.Color()));
6482
- const size = (this.options.iconSize || 24) * ((_p = this.options.iconScale) !== null && _p !== void 0 ? _p : 1);
6483
- icon.style.backgroundColor = background.withAlpha(ICON_BACKGROUND_ALPHA).toCssColorString();
6484
- icon.style.borderRadius = "50%";
6485
- icon.style.padding = `${size / 3}px`;
6486
- icon.style.display = "inline-flex";
6487
- icon.style.alignItems = "center";
6488
- icon.style.justifyContent = "center";
6489
- icon.style.minWidth = `${size}px`;
6490
- icon.style.minHeight = `${size}px`;
6491
- icon.style.boxSizing = "content-box";
6492
- let fitted = false;
6493
- fitIconBackground = () => {
6494
- if (fitted) {
6495
- return;
6496
- }
6497
- const padding = size / 3;
6498
- const width = icon.offsetWidth - padding * 2;
6499
- const height = icon.offsetHeight - padding * 2;
6500
- if (width <= 0 || height <= 0) {
6501
- return;
6502
- }
6503
- fitted = true;
6504
- const longest = Math.max(width, height);
6505
- const difference = (longest - Math.min(width, height)) / longest;
6506
- if (difference > ICON_BACKGROUND_UNIFORM_TOLERANCE) {
6507
- return;
6508
- }
6509
- icon.style.minWidth = `${longest}px`;
6510
- icon.style.minHeight = `${longest}px`;
6511
- this.viewer.scene.requestRender();
6512
- };
6513
- }
6514
- if (this.options.iconScale === 0) {
6515
- icon.style.display = "none";
6516
- }
6493
+ const built = this.buildIcon(this.options, counter);
6494
+ const icon = iconElement = built.icon;
6495
+ fitIconBackground = built.fit;
6517
6496
  makeClickable(icon);
6518
6497
  // Keep badges outside the glyph so font classes and image masks cannot affect them.
6519
6498
  iconContainer = document.createElement("span");
6520
- iconContainer.style.cssText = "position:relative;display:inline-flex;flex:none;";
6521
- iconContainer.appendChild(icon);
6499
+ iconContainer.style.cssText = "position:relative;display:inline-flex;flex:none;align-items:center;";
6500
+ icon.style.position = "relative";
6501
+ iconStack = document.createElement("span");
6502
+ iconStack.className = "entity-label-icon-stack";
6503
+ iconStack.style.cssText = "display:none;gap:2px;align-items:center;justify-items:center;flex:none;";
6504
+ makeClickable(iconStack);
6505
+ iconContainer.append(iconStack, icon);
6522
6506
  content.appendChild(iconContainer);
6523
6507
  }
6524
6508
  if (presentation !== "icon") {
@@ -6530,7 +6514,7 @@
6530
6514
  if (presentation === "text")
6531
6515
  makeClickable(textElement);
6532
6516
  content.appendChild(textElement);
6533
- if ((_q = this.options.cluster) === null || _q === void 0 ? void 0 : _q.enabled) {
6517
+ if ((_m = this.options.cluster) === null || _m === void 0 ? void 0 : _m.enabled) {
6534
6518
  sourceText = document.createElement("span");
6535
6519
  sourceText.className = "entity-label-source-text";
6536
6520
  sourceText.setAttribute("aria-hidden", "true");
@@ -6571,7 +6555,7 @@
6571
6555
  ele.appendChild(countElement);
6572
6556
  }
6573
6557
  ele.className = "entity-label";
6574
- ele.dataset.entityId = ((_r = this.rego) === null || _r === void 0 ? void 0 : _r.entityId) || "";
6558
+ ele.dataset.entityId = ((_o = this.rego) === null || _o === void 0 ? void 0 : _o.entityId) || "";
6575
6559
  ele.dataset.presentation = presentation;
6576
6560
  if (this.groupId) {
6577
6561
  ele.dataset.groupId = this.groupId;
@@ -6687,6 +6671,41 @@
6687
6671
  run, side: (rightwards ? "right" : "left")
6688
6672
  };
6689
6673
  };
6674
+ /*
6675
+ * The on-screen centre and half-size of a point or billboard glyph, relative to its projected position.
6676
+ * Read every frame, since styling and selection can rescale a billboard in place.
6677
+ */
6678
+ const measureIcon = () => {
6679
+ var _a;
6680
+ if (!normalLeader || !(visual instanceof Cesium.Entity)) {
6681
+ return null;
6682
+ }
6683
+ if (visual.point) {
6684
+ const size = EnsureNumber(getValue(this.viewer, visual.point.pixelSize), 1) +
6685
+ EnsureNumber(getValue(this.viewer, visual.point.outlineWidth), 0) * 2;
6686
+ return size > 0 ? { x: 0, y: 0, radius: size / 2 } : null;
6687
+ }
6688
+ if (!visual.billboard) {
6689
+ return null;
6690
+ }
6691
+ const billboard = visual.billboard;
6692
+ const scale = EnsureNumber(getValue(this.viewer, billboard.scale), 1);
6693
+ // Icon billboards only record their image height; generated circles set both sides.
6694
+ const height = EnsureNumber((_a = getValue(this.viewer, billboard.height)) !== null && _a !== void 0 ? _a : billboard._billboardSize, 0) * scale;
6695
+ const width = EnsureNumber(getValue(this.viewer, billboard.width), height / scale) * scale;
6696
+ if (!(height > 0) || !(width > 0)) {
6697
+ return null;
6698
+ }
6699
+ const vertical = getValue(this.viewer, billboard.verticalOrigin);
6700
+ const horizontal = getValue(this.viewer, billboard.horizontalOrigin);
6701
+ const offset = getValue(this.viewer, billboard.pixelOffset);
6702
+ return {
6703
+ x: ((offset === null || offset === void 0 ? void 0 : offset.x) || 0) + (horizontal === Cesium.HorizontalOrigin.LEFT ? width / 2 : horizontal === Cesium.HorizontalOrigin.RIGHT ? -width / 2 : 0),
6704
+ y: ((offset === null || offset === void 0 ? void 0 : offset.y) || 0) + (vertical === Cesium.VerticalOrigin.BOTTOM || vertical === Cesium.VerticalOrigin.BASELINE ? -height / 2 :
6705
+ vertical === Cesium.VerticalOrigin.TOP ? height / 2 : 0),
6706
+ radius: Math.max(width, height) / 2
6707
+ };
6708
+ };
6690
6709
  let cursorMotion = this.cursorMotion;
6691
6710
  const project = () => {
6692
6711
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
@@ -6714,8 +6733,10 @@
6714
6733
  const relative = Cesium.Cartesian3.subtract(this._pos3d, cameraPosition, new Cesium.Cartesian3());
6715
6734
  const depth = Cesium.Cartesian3.dot(relative, camera.directionWC || camera.direction);
6716
6735
  const distance = Cesium.Cartesian3.magnitude(relative);
6736
+ // If we're labelling from a temp label (mouse hover) don't drive opacity by cam distance.
6737
+ const hoverOnly = this.Transient && !this.Persistent;
6717
6738
  if (!Number.isFinite(distance) || depth <= 0 || distance < ((_c = this.options.near) !== null && _c !== void 0 ? _c : 0) ||
6718
- distance > ((_d = this.options.far) !== null && _d !== void 0 ? _d : Infinity) || (!this.prePositioned && distance >= MAX_DISTANCE)) {
6739
+ distance > ((_d = this.options.far) !== null && _d !== void 0 ? _d : Infinity) || (!this.prePositioned && !hoverOnly && distance >= MAX_DISTANCE)) {
6719
6740
  return null;
6720
6741
  }
6721
6742
  const transforms = Cesium.SceneTransforms;
@@ -6753,9 +6774,13 @@
6753
6774
  this.cursorMotion = cursorMotion;
6754
6775
  const cursorAmount = cursorMotion.amount;
6755
6776
  leader = normalLeader || cursorAmount > 0;
6756
- let x = ((_l = transientPivot === null || transientPivot === void 0 ? void 0 : transientPivot.x) !== null && _l !== void 0 ? _l : anchor.x) + pixelOffset.x;
6757
- let y = ((_m = transientPivot === null || transientPivot === void 0 ? void 0 : transientPivot.y) !== null && _m !== void 0 ? _m : anchor.y) + pixelOffset.y;
6758
- if (cursorAmount > 0) {
6777
+ // An icon keeps its callout centred on the glyph rather than following the cursor across it.
6778
+ const icon = measureIcon();
6779
+ const iconRing = icon ? Math.max(ICON_RING_MIN, icon.radius + ICON_RING_PADDING) : 0;
6780
+ const pivot = icon ? undefined : transientPivot;
6781
+ let x = ((_l = pivot === null || pivot === void 0 ? void 0 : pivot.x) !== null && _l !== void 0 ? _l : anchor.x) + (icon || pixelOffset).x;
6782
+ let y = ((_m = pivot === null || pivot === void 0 ? void 0 : pivot.y) !== null && _m !== void 0 ? _m : anchor.y) + (icon || pixelOffset).y;
6783
+ if (cursorAmount > 0 && !icon) {
6759
6784
  // A leader-less label starts centred on its anchor; preserve that box as it lifts away.
6760
6785
  if (!normalLeader) {
6761
6786
  x -= ele.offsetWidth / layer.coordinates.scaleX / 2;
@@ -6793,9 +6818,11 @@
6793
6818
  if (cursorAmount > 0) {
6794
6819
  leaderLength += (96 - leaderLength) * cursorAmount;
6795
6820
  }
6821
+ // Measured from the ring, so the visible stem is the same length whatever the glyph's size.
6822
+ leaderLength += iconRing;
6796
6823
  y = anchorY - leaderLength;
6797
6824
  }
6798
- else if (hover && !reused) {
6825
+ else if (hover && !reused && !icon) {
6799
6826
  x = hover.x + pixelOffset.x;
6800
6827
  y = hover.y + pixelOffset.y - 24;
6801
6828
  }
@@ -6838,7 +6865,7 @@
6838
6865
  return null;
6839
6866
  }
6840
6867
  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 });
6841
- const opacity = this.prePositioned || MAX_DISTANCE === Infinity ? 1 : 1 - distance / MAX_DISTANCE;
6868
+ const opacity = this.prePositioned || hoverOnly || MAX_DISTANCE === Infinity ? 1 : 1 - distance / MAX_DISTANCE;
6842
6869
  return {
6843
6870
  canFlip: leader && presentation !== "icon", hasLeader: leader,
6844
6871
  textWrapped: ((sourceNaturalText === null || sourceNaturalText === void 0 ? void 0 : sourceNaturalText.offsetWidth) || 0) > TEXT_MAX_WIDTH,
@@ -6847,6 +6874,7 @@
6847
6874
  viewportWidth: this.viewer.canvas.clientWidth,
6848
6875
  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,
6849
6876
  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,
6877
+ iconRadius: icon === null || icon === void 0 ? void 0 : icon.radius,
6850
6878
  // Priority shows the hovered member individually without dragging its cluster along.
6851
6879
  opacity, priority: hover || cursorAmount > 0 || (state === null || state === void 0 ? void 0 : state.highlighted) ? 1 : 0, options: this.options, text: originalText, clusterBounds
6852
6880
  };
@@ -6854,7 +6882,7 @@
6854
6882
  let displacement = { x: 0, y: 0 };
6855
6883
  let nudge = { x: 0, y: 0, fromX: 0, fromY: 0, targetX: 0, targetY: 0, start: 0 };
6856
6884
  const apply = (placement) => {
6857
- 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;
6885
+ 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;
6858
6886
  const show = (placement === null || placement === void 0 ? void 0 : placement.visible) && placement.opacity > 0;
6859
6887
  clickEntityIds = show ? placement.entityIds : [];
6860
6888
  ele.style.visibility = show ? "visible" : "hidden";
@@ -6891,6 +6919,29 @@
6891
6919
  this.viewer.scene.requestRender();
6892
6920
  }
6893
6921
  fitIconBackground === null || fitIconBackground === void 0 ? void 0 : fitIconBackground();
6922
+ const stack = JSON.stringify(placement.icons || []);
6923
+ if (iconStack && stack !== displayedStack) {
6924
+ displayedStack = stack;
6925
+ const looks = ((_b = placement.icons) === null || _b === void 0 ? void 0 : _b.length) ? [iconLook(this.options), ...placement.icons] : [];
6926
+ // One small size for all, so no icon in the arrangement reads as more important than another.
6927
+ const size = (this.options.iconSize || 24) * ((_c = this.options.iconScale) !== null && _c !== void 0 ? _c : 1) * ICON_STACK_SCALE;
6928
+ const built = looks.map(look => this.buildIcon({ ...look, iconSize: size }, counter));
6929
+ // Two sit side by side; three make a triangle with the representative on top.
6930
+ iconStack.style.gridTemplateColumns = built.length === 3 ? "auto auto" : `repeat(${built.length}, auto)`;
6931
+ if (built.length === 3) {
6932
+ built[0].icon.style.gridColumn = "1 / span 2";
6933
+ }
6934
+ iconStack.replaceChildren(...built.map(({ icon }) => icon));
6935
+ iconStack.style.display = built.length ? "inline-grid" : "none";
6936
+ // The glyph stays measurable, since cluster membership is taken from its footprint.
6937
+ iconElement.style.visibility = built.length ? "hidden" : "";
6938
+ iconElement.style.position = built.length ? "absolute" : "relative";
6939
+ stackedFits = built.map(({ fit }) => fit).filter(Boolean);
6940
+ // The stack widens the label, which the next frame has to measure.
6941
+ this.viewer.scene.requestRender();
6942
+ }
6943
+ for (const fit of stackedFits)
6944
+ fit();
6894
6945
  if (iconContainer) {
6895
6946
  const badgeWidth = countElement.offsetWidth;
6896
6947
  const iconWidth = iconElement.offsetWidth;
@@ -6906,36 +6957,43 @@
6906
6957
  // Summary and badge changes must fit on their first visible frame, including after resize.
6907
6958
  const ring = this.options.anchorRing;
6908
6959
  const pointer = (layer.pointer || (cursorMotion.target ? cursorMotion.position : undefined) || (this.Transient && !this.Persistent ?
6909
- (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));
6910
- 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);
6911
- 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));
6960
+ (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));
6961
+ const iconRing = frame.iconRadius ? Math.max(ICON_RING_MIN, frame.iconRadius + ICON_RING_PADDING) : 0;
6962
+ 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);
6963
+ 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));
6912
6964
  const targetRadius = normalRadius + (15.4 - normalRadius) * cursorMotion.amount;
6965
+ // Around a glyph the ring keeps the glyph's size and only fades its stroke in, so it never covers the icon.
6966
+ const targetOuter = iconRing || targetRadius || 2.5;
6967
+ const targetInner = iconRing ? (targetRadius > 0 ? iconRing - 2 : iconRing) : Math.max(0, targetRadius - 2);
6913
6968
  const now = Date.now();
6914
- const duration = (_j = ring === null || ring === void 0 ? void 0 : ring.transitionDuration) !== null && _j !== void 0 ? _j : 200;
6969
+ const duration = (_l = ring === null || ring === void 0 ? void 0 : ring.transitionDuration) !== null && _l !== void 0 ? _l : 200;
6915
6970
  const sharedRing = this.Transient && !this.Persistent && this.hoverFade.to === 1 ? this.hoverRingState : undefined;
6916
6971
  let ringAnimation = sharedRing ? sharedRing.animation : this.ringAnimation;
6917
6972
  if (!ringAnimation) {
6918
- ringAnimation = { target: 0, start: now, outer: 2.5, inner: 0, fromOuter: 2.5, fromInner: 0 };
6973
+ const outer = iconRing || 2.5;
6974
+ const inner = iconRing;
6975
+ ringAnimation = { target: outer, targetInner: inner, start: now, outer, inner, fromOuter: outer, fromInner: inner };
6919
6976
  }
6920
6977
  const animateRing = () => {
6921
6978
  const progress = duration > 0 ? Math.min(1, Math.max(0, (now - ringAnimation.start) / duration)) : 1;
6922
6979
  const eased = progress * progress * (3 - 2 * progress);
6923
- ringAnimation.outer = ringAnimation.fromOuter + ((ringAnimation.target || 2.5) - ringAnimation.fromOuter) * eased;
6924
- ringAnimation.inner = ringAnimation.fromInner + (Math.max(0, ringAnimation.target - 2) - ringAnimation.fromInner) * eased;
6980
+ ringAnimation.outer = ringAnimation.fromOuter + (ringAnimation.target - ringAnimation.fromOuter) * eased;
6981
+ ringAnimation.inner = ringAnimation.fromInner + (ringAnimation.targetInner - ringAnimation.fromInner) * eased;
6925
6982
  return progress;
6926
6983
  };
6927
6984
  animateRing();
6928
- if (ringAnimation.target !== targetRadius) {
6985
+ if (ringAnimation.target !== targetOuter || ringAnimation.targetInner !== targetInner) {
6929
6986
  ringAnimation = {
6930
6987
  ...ringAnimation,
6931
- target: targetRadius,
6988
+ target: targetOuter,
6989
+ targetInner,
6932
6990
  start: now,
6933
6991
  fromOuter: ringAnimation.outer,
6934
6992
  fromInner: ringAnimation.inner
6935
6993
  };
6936
6994
  }
6937
6995
  // Animate the hole and stem together; no CSS transition may lag behind a moving world anchor.
6938
- if (animateRing() < 1 && (ringAnimation.fromOuter !== (targetRadius || 2.5) || ringAnimation.fromInner !== Math.max(0, targetRadius - 2))) {
6996
+ if (animateRing() < 1 && (ringAnimation.fromOuter !== targetOuter || ringAnimation.fromInner !== targetInner)) {
6939
6997
  this.viewer.scene.requestRender();
6940
6998
  }
6941
6999
  this.ringAnimation = ringAnimation;
@@ -6950,19 +7008,19 @@
6950
7008
  if (leader && angled) {
6951
7009
  // The declutter owns the side; the fit only says which one had the room to begin with.
6952
7010
  const run = Math.max(ANGLED_LEADER_RUN, radius + 4);
6953
- const side = (_l = (_k = placement.textSide) !== null && _k !== void 0 ? _k : frame.leaderSide) !== null && _l !== void 0 ? _l : "right";
7011
+ const side = (_o = (_m = placement.textSide) !== null && _m !== void 0 ? _m : frame.leaderSide) !== null && _o !== void 0 ? _o : "right";
6954
7012
  const beside = side === "left" ? frame.anchorX - run - width : frame.anchorX + run;
6955
7013
  position.x = Math.max(0, Math.min(beside, this.viewer.canvas.clientWidth - width));
6956
7014
  }
6957
7015
  else if (leader && placement.textSide === "left") {
6958
7016
  position.x = Math.max(0, Math.min(frame.anchorX - width, this.viewer.canvas.clientWidth - width));
6959
7017
  }
6960
- const gap = (_o = (_m = this.options.pointerAvoidance) === null || _m === void 0 ? void 0 : _m.gap) !== null && _o !== void 0 ? _o : 12;
7018
+ const gap = (_q = (_p = this.options.pointerAvoidance) === null || _p === void 0 ? void 0 : _p.gap) !== null && _q !== void 0 ? _q : 12;
6961
7019
  const containsPointer = (x, y, margin) => {
6962
7020
  return pointer && pointer.x >= x - margin && pointer.x <= x + width + margin && pointer.y >= y - margin && pointer.y <= y + height + margin;
6963
7021
  };
6964
7022
  const passive = !selectOnClick || !this.visualRegister;
6965
- if (passive && ((_p = this.options.pointerAvoidance) === null || _p === void 0 ? void 0 : _p.enabled) !== false && pointer &&
7023
+ if (passive && ((_r = this.options.pointerAvoidance) === null || _r === void 0 ? void 0 : _r.enabled) !== false && pointer &&
6966
7024
  (containsPointer(position.x, position.y, gap) || containsPointer(position.x + displacement.x, position.y + displacement.y, gap))) {
6967
7025
  if (containsPointer(position.x + displacement.x, position.y + displacement.y, 0)) {
6968
7026
  // Choose against the original box and retain the offset until the pointer leaves both boxes.
@@ -6980,7 +7038,7 @@
6980
7038
  else {
6981
7039
  displacement = { x: 0, y: 0 };
6982
7040
  }
6983
- const nudgeDuration = (_r = (_q = this.options.pointerAvoidance) === null || _q === void 0 ? void 0 : _q.transitionDuration) !== null && _r !== void 0 ? _r : 200;
7041
+ const nudgeDuration = (_t = (_s = this.options.pointerAvoidance) === null || _s === void 0 ? void 0 : _s.transitionDuration) !== null && _t !== void 0 ? _t : 200;
6984
7042
  const animateNudge = () => {
6985
7043
  const progress = now < nudge.start ? 0 : nudgeDuration > 0 ? Math.min(1, (now - nudge.start) / nudgeDuration) : 1;
6986
7044
  const eased = progress * progress * (3 - 2 * progress);
@@ -6996,7 +7054,7 @@
6996
7054
  fromY: nudge.y,
6997
7055
  targetX: displacement.x,
6998
7056
  targetY: displacement.y,
6999
- start: now + ((_t = (_s = this.options.pointerAvoidance) === null || _s === void 0 ? void 0 : _s.delay) !== null && _t !== void 0 ? _t : 180)
7057
+ start: now + ((_v = (_u = this.options.pointerAvoidance) === null || _u === void 0 ? void 0 : _u.delay) !== null && _v !== void 0 ? _v : 180)
7000
7058
  };
7001
7059
  }
7002
7060
  if (animateNudge() < 1 && (nudge.fromX !== nudge.targetX || nudge.fromY !== nudge.targetY)) {
@@ -7006,9 +7064,8 @@
7006
7064
  position.y += nudge.y;
7007
7065
  ele.style.left = `${coordinates.x + position.x * coordinates.scaleX}px`;
7008
7066
  ele.style.top = `${coordinates.y + position.y * coordinates.scaleY}px`;
7009
- const clickTarget = content.querySelector('[role="button"]');
7010
- if (clickTarget) {
7011
- 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}`);
7067
+ for (const clickTarget of Array.from(content.querySelectorAll('[role="button"]'))) {
7068
+ 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}`);
7012
7069
  }
7013
7070
  if (leader) {
7014
7071
  const nodeRadius = ringAnimation.outer;
@@ -7048,13 +7105,13 @@
7048
7105
  // Keep the stem inside the 126-degree arc, away from antialiased endpoints.
7049
7106
  const arcFraction = 0.35;
7050
7107
  const rotationLimit = Math.PI * arcFraction - Math.PI / 30;
7051
- const targetOffset = hovered ? Math.max(-rotationLimit, Math.min(rotationLimit, wrappedAngle)) : ((_x = ringAnimation.arcOffset) !== null && _x !== void 0 ? _x : 0);
7108
+ const targetOffset = hovered ? Math.max(-rotationLimit, Math.min(rotationLimit, wrappedAngle)) : ((_z = ringAnimation.arcOffset) !== null && _z !== void 0 ? _z : 0);
7052
7109
  const targetOpen = cursorMotion.target || (hovered && (ring === null || ring === void 0 ? void 0 : ring.enabled) !== false && targetRadius > 0) ? 1 : 0;
7053
- const elapsed = Math.min(32, Math.max(0, now - ((_y = ringAnimation.arcTime) !== null && _y !== void 0 ? _y : now)));
7110
+ const elapsed = Math.min(32, Math.max(0, now - ((_0 = ringAnimation.arcTime) !== null && _0 !== void 0 ? _0 : now)));
7054
7111
  const blend = duration > 0 ? 1 - Math.exp(-elapsed / (duration / 3)) : 1;
7055
7112
  const approach = (value, target) => Math.abs(value - target) < 0.001 ? target : value + (target - value) * blend;
7056
- ringAnimation.arcOffset = approach((_z = ringAnimation.arcOffset) !== null && _z !== void 0 ? _z : 0, targetOffset);
7057
- ringAnimation.arcOpen = approach((_0 = ringAnimation.arcOpen) !== null && _0 !== void 0 ? _0 : 0, targetOpen);
7113
+ ringAnimation.arcOffset = approach((_1 = ringAnimation.arcOffset) !== null && _1 !== void 0 ? _1 : 0, targetOffset);
7114
+ ringAnimation.arcOpen = approach((_2 = ringAnimation.arcOpen) !== null && _2 !== void 0 ? _2 : 0, targetOpen);
7058
7115
  ringAnimation.arcTime = now;
7059
7116
  if (ringAnimation.arcOffset !== targetOffset || ringAnimation.arcOpen !== targetOpen) {
7060
7117
  this.viewer.scene.requestRender();
@@ -7107,6 +7164,123 @@
7107
7164
  }, 2500);
7108
7165
  });
7109
7166
  }
7167
+ /*
7168
+ * Builds one icon glyph from label options, with its background chip when asked for.
7169
+ *
7170
+ * The returned fit squares the chip off once the glyph has a measurable size.
7171
+ */
7172
+ buildIcon(options, counter) {
7173
+ var _a, _b, _c;
7174
+ let fit;
7175
+ const icon = document.createElement("span");
7176
+ icon.className = "entity-label-icon";
7177
+ icon.style.cssText = `font-size:${(options.iconSize || 24) * ((_a = options.iconScale) !== null && _a !== void 0 ? _a : 1)}px;line-height:1;`;
7178
+ icon.style.textShadow = "0 1px 2px rgba(0, 0, 0, 0.25)";
7179
+ const iconColor = typeof options.iconColor === "string" ? options.iconColor : undefined;
7180
+ const iconBackground = options.iconBackground && options.iconScale !== 0;
7181
+ if (iconColor) {
7182
+ icon.style.color = iconColor;
7183
+ }
7184
+ if (options.iconUrl) {
7185
+ const image = document.createElement("img");
7186
+ image.draggable = false;
7187
+ image.alt = options.icon || "";
7188
+ const scale = (_b = options.iconScale) !== null && _b !== void 0 ? _b : 1;
7189
+ const size = options.iconSize;
7190
+ const svgNs = "http://www.w3.org/2000/svg";
7191
+ const tint = iconColor ? document.createElementNS(svgNs, "svg") : null;
7192
+ const painted = tint ? document.createElementNS(svgNs, "image") : null;
7193
+ const inked = tint ? document.createElementNS(svgNs, "rect") : null;
7194
+ if (tint) {
7195
+ const maskId = `entity-label-tint-${++tintCounter}`;
7196
+ const mask = document.createElementNS(svgNs, "mask");
7197
+ mask.setAttribute("id", maskId);
7198
+ mask.setAttribute("maskUnits", "userSpaceOnUse");
7199
+ painted.setAttribute("href", options.iconUrl);
7200
+ painted.setAttribute("preserveAspectRatio", "xMidYMid meet");
7201
+ mask.appendChild(painted);
7202
+ inked.setAttribute("fill", iconColor);
7203
+ inked.setAttribute("mask", `url(#${maskId})`);
7204
+ tint.append(mask, inked);
7205
+ tint.style.display = "block";
7206
+ icon.style.display = "inline-flex";
7207
+ icon.appendChild(tint);
7208
+ }
7209
+ const resize = () => {
7210
+ if (this.disposed || this.counter !== counter) {
7211
+ return;
7212
+ }
7213
+ const intrinsic = options.iconScale != null && size == null;
7214
+ const width = (intrinsic ? image.naturalWidth || 24 : size || 24) * scale;
7215
+ const height = (intrinsic ? image.naturalHeight || 24 : size || 24) * scale;
7216
+ if (tint) {
7217
+ tint.setAttribute("viewBox", `0 0 ${width} ${height}`);
7218
+ for (const node of [tint, painted, inked]) {
7219
+ node.setAttribute("width", String(width));
7220
+ node.setAttribute("height", String(height));
7221
+ }
7222
+ }
7223
+ else {
7224
+ image.style.width = `${width}px`;
7225
+ image.style.height = `${height}px`;
7226
+ }
7227
+ this.viewer.scene.requestRender();
7228
+ };
7229
+ image.onload = resize;
7230
+ resize();
7231
+ image.src = options.iconUrl;
7232
+ if (!tint) {
7233
+ icon.appendChild(image);
7234
+ }
7235
+ }
7236
+ else if (typeof options.iconClass === "string" && options.iconClass) {
7237
+ icon.className = `entity-label-icon ${options.iconClass}`;
7238
+ }
7239
+ else {
7240
+ icon.textContent = options.icon || "●";
7241
+ }
7242
+ if (iconBackground) {
7243
+ const color = Cesium.Color.fromCssColorString(iconColor || "white") || Cesium.Color.WHITE;
7244
+ const luminance = 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue;
7245
+ const background = (luminance > 0.5 ? color.darken(ICON_BACKGROUND_BLEND, new Cesium.Color()) :
7246
+ color.brighten(ICON_BACKGROUND_BLEND, new Cesium.Color()));
7247
+ const size = (options.iconSize || 24) * ((_c = options.iconScale) !== null && _c !== void 0 ? _c : 1);
7248
+ icon.style.backgroundColor = background.withAlpha(ICON_BACKGROUND_ALPHA).toCssColorString();
7249
+ icon.style.borderRadius = "50%";
7250
+ icon.style.padding = `${size / 3}px`;
7251
+ icon.style.display = "inline-flex";
7252
+ icon.style.alignItems = "center";
7253
+ icon.style.justifyContent = "center";
7254
+ icon.style.minWidth = `${size}px`;
7255
+ icon.style.minHeight = `${size}px`;
7256
+ icon.style.boxSizing = "content-box";
7257
+ let fitted = false;
7258
+ fit = () => {
7259
+ if (fitted) {
7260
+ return;
7261
+ }
7262
+ const padding = size / 3;
7263
+ const width = icon.offsetWidth - padding * 2;
7264
+ const height = icon.offsetHeight - padding * 2;
7265
+ if (width <= 0 || height <= 0) {
7266
+ return;
7267
+ }
7268
+ fitted = true;
7269
+ const longest = Math.max(width, height);
7270
+ const difference = (longest - Math.min(width, height)) / longest;
7271
+ if (difference > ICON_BACKGROUND_UNIFORM_TOLERANCE) {
7272
+ return;
7273
+ }
7274
+ icon.style.minWidth = `${longest}px`;
7275
+ icon.style.minHeight = `${longest}px`;
7276
+ this.viewer.scene.requestRender();
7277
+ };
7278
+ }
7279
+ if (options.iconScale === 0) {
7280
+ icon.style.display = "none";
7281
+ }
7282
+ return { icon, fit };
7283
+ }
7110
7284
  /**
7111
7285
  * Removes label from the viewer and unsubscribes from events.
7112
7286
  */
@@ -50260,7 +50434,7 @@ void main() {
50260
50434
  StyleUtils.ApplyTypeStyle = ApplyTypeStyle;
50261
50435
  })(exports.StyleUtils || (exports.StyleUtils = {}));
50262
50436
 
50263
- const VERSION = "7.4.3";
50437
+ const VERSION = "7.4.5";
50264
50438
  /**
50265
50439
  * Updates the environment instance used by bruce-cesium to one specified.
50266
50440
  * This can be used to ensure that the instance a parent is referencing is shared between bruce-cesium, bruce-models, and the parent app.