bruce-cesium 7.5.5 → 7.5.7

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.
Files changed (28) hide show
  1. package/dist/bruce-cesium.es5.js +848 -357
  2. package/dist/bruce-cesium.es5.js.map +1 -1
  3. package/dist/bruce-cesium.umd.js +846 -354
  4. package/dist/bruce-cesium.umd.js.map +1 -1
  5. package/dist/lib/bruce-cesium.js +1 -1
  6. package/dist/lib/internal/image-utils.js +24 -1
  7. package/dist/lib/internal/image-utils.js.map +1 -1
  8. package/dist/lib/rendering/entity-render-engine-model3d.js +3 -2
  9. package/dist/lib/rendering/entity-render-engine-model3d.js.map +1 -1
  10. package/dist/lib/rendering/entity-render-engine-point.js +21 -34
  11. package/dist/lib/rendering/entity-render-engine-point.js.map +1 -1
  12. package/dist/lib/rendering/entity-render-engine-polygon.js +16 -14
  13. package/dist/lib/rendering/entity-render-engine-polygon.js.map +1 -1
  14. package/dist/lib/rendering/render-managers/common/entity-label-group.js +178 -64
  15. package/dist/lib/rendering/render-managers/common/entity-label-group.js.map +1 -1
  16. package/dist/lib/rendering/render-managers/common/entity-label.js +437 -71
  17. package/dist/lib/rendering/render-managers/common/entity-label.js.map +1 -1
  18. package/dist/lib/rendering/render-managers/other/google-search-render-manager.js +32 -37
  19. package/dist/lib/rendering/render-managers/other/google-search-render-manager.js.map +1 -1
  20. package/dist/lib/rendering/visuals-register.js +7 -0
  21. package/dist/lib/rendering/visuals-register.js.map +1 -1
  22. package/dist/types/bruce-cesium.d.ts +1 -1
  23. package/dist/types/internal/image-utils.d.ts +1 -0
  24. package/dist/types/rendering/entity-render-engine-point.d.ts +10 -1
  25. package/dist/types/rendering/render-managers/common/entity-label-group.d.ts +9 -1
  26. package/dist/types/rendering/render-managers/common/entity-label.d.ts +13 -2
  27. package/dist/types/rendering/visuals-register.d.ts +1 -0
  28. package/package.json +2 -2
@@ -47,11 +47,196 @@ var EntityLabel;
47
47
  const ICON_RING_PADDING = 4;
48
48
  // Small points still get a ring as large as the default near ring.
49
49
  const ICON_RING_MIN = 10;
50
+ // A photo grows by this much while its entity is hovered, since it is content worth a closer look.
51
+ const PHOTO_HOVER_SCALE = 2.5;
52
+ // How long a label must stay on show before its photo is fetched, so labels that only flash past while
53
+ // clusters settle or the camera sweeps by cost nothing.
54
+ const PHOTO_SHOW_DELAY = 250;
55
+ // Photos rest this much larger than an icon on its chip, since they are content rather than a marker.
56
+ const PHOTO_SIZE_SCALE = 1.5;
57
+ // Corner radius of a photo too oblong for a circle, relative to its longest side.
58
+ const PHOTO_CORNER = 0.15;
59
+ // How long a photo takes to grow into its label, and to grow on hover.
60
+ const PHOTO_TRANSITION = 250;
61
+ // Downsized photos kept per viewer before the least recently used is released.
62
+ const PHOTO_CACHE_LIMIT = 500;
63
+ // Key where the viewer keeps its downsized photos.
64
+ const PHOTO_CACHE_KEY = "_nextspaceLabelPhotos";
50
65
  // One per tinted icon, since an SVG mask is addressed by id across the whole document.
51
66
  let tintCounter = 0;
52
67
  // Key where the label instance is stored within the cesium entity.
53
68
  // Eg: rego[ATTACH_KEY] = new EntityLabel(...);
54
69
  EntityLabel.ATTACH_KEY = "nextspace-navigator-entity-label";
70
+ // Image types a label draws, read from the file extension. Anything else stays text.
71
+ const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "svg", "webp"];
72
+ // Shortest side a thumbnail needs before it is preferred over the full file.
73
+ const IMAGE_THUMBNAIL_MIN = 64;
74
+ // Size a found image is drawn at when the group names none, so a photo never lands at its natural size.
75
+ const IMAGE_SIZE = 24;
76
+ function isImageFile(name) {
77
+ return typeof name === "string" && IMAGE_EXTENSIONS.includes(name.split(".").pop().toLowerCase());
78
+ }
79
+ // Whether a label was configured with an icon of its own, which always outranks a found photo.
80
+ function HasIcon(options) {
81
+ return Boolean(options.iconUrl || options.iconClass || options.icon);
82
+ }
83
+ EntityLabel.HasIcon = HasIcon;
84
+ /*
85
+ * Whether a label line is an http(s) link to an image, judged by its path so a query string cannot pass for one.
86
+ */
87
+ function IsImageUrl(value) {
88
+ const text = value === null || value === void 0 ? void 0 : value.trim();
89
+ if (!text || /\s/.test(text)) {
90
+ return false;
91
+ }
92
+ let url;
93
+ try {
94
+ url = new URL(text);
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ return (url.protocol === "http:" || url.protocol === "https:") && isImageFile(url.pathname);
100
+ }
101
+ EntityLabel.IsImageUrl = IsImageUrl;
102
+ /*
103
+ * The entity's default image as InfoView picks it: its first photo attachment by display order that is an image file.
104
+ * Reads attachments expanded onto the entity, and prefers the smallest thumbnail that still covers an icon.
105
+ */
106
+ function GetDefaultImageUrl(entity, api) {
107
+ var _a, _b, _c;
108
+ const attachments = (_b = (_a = entity === null || entity === void 0 ? void 0 : entity.Bruce) === null || _a === void 0 ? void 0 : _a.Attachment) !== null && _b !== void 0 ? _b : [];
109
+ const photo = attachments
110
+ .filter(x => (x === null || x === void 0 ? void 0 : x["EntityAttachmentType.ID"]) == bruce_models_1.EntityAttachmentType.EType.Photo && x.ClientFile)
111
+ .filter(x => isImageFile(x.ClientFile.FileExt || x.ClientFile.OriginalFileName))
112
+ .sort((a, b) => { var _a, _b; return ((_a = a.DisplayOrder) !== null && _a !== void 0 ? _a : 0) - ((_b = b.DisplayOrder) !== null && _b !== void 0 ? _b : 0); })[0];
113
+ if (!photo) {
114
+ return null;
115
+ }
116
+ const thumbnail = ((_c = photo.ClientFile.Thumbnails) !== null && _c !== void 0 ? _c : [])
117
+ .filter(x => Math.min(x.Width, x.Height) >= IMAGE_THUMBNAIL_MIN && (x.URL || x["ClientFile.ID"]))
118
+ .sort((a, b) => a.Width * a.Height - b.Width * b.Height)[0];
119
+ if (thumbnail) {
120
+ return thumbnail.URL || bruce_models_1.ClientFile.GetUrl({ fileId: thumbnail["ClientFile.ID"], api, viaCdn: true });
121
+ }
122
+ return photo.ClientFile.URL || photo.URL || bruce_models_1.ClientFile.GetUrl({ fileId: photo["ClientFile.ID"], api, viaCdn: true });
123
+ }
124
+ EntityLabel.GetDefaultImageUrl = GetDefaultImageUrl;
125
+ /*
126
+ * Gives a label an image it found rather than one it was configured with.
127
+ * A text label gains it beside the text, or alone when the image was all the text there was.
128
+ */
129
+ function WithPhoto(options, url) {
130
+ var _a, _b, _c, _d;
131
+ const presentation = (_a = options.presentation) !== null && _a !== void 0 ? _a : "text";
132
+ options.iconUrl = url;
133
+ options.iconPhoto = true;
134
+ // A tint would flatten a photo into a silhouette of its own outline.
135
+ options.iconColor = undefined;
136
+ (_b = options.iconSize) !== null && _b !== void 0 ? _b : (options.iconSize = IMAGE_SIZE);
137
+ if (!((_c = options.text) === null || _c === void 0 ? void 0 : _c.trim())) {
138
+ options.presentation = "icon";
139
+ }
140
+ else if (presentation === "text") {
141
+ options.presentation = "icon-text-side";
142
+ }
143
+ if (presentation === "text") {
144
+ // Keeps the leader a text label draws by default.
145
+ (_d = options.leader) !== null && _d !== void 0 ? _d : (options.leader = true);
146
+ }
147
+ return options;
148
+ }
149
+ EntityLabel.WithPhoto = WithPhoto;
150
+ /*
151
+ * Near-square photos are cropped to a circle the size of an icon chip; the rest keep their shape with rounded corners.
152
+ * An unknown size reads as square, since the circle crops whatever arrives.
153
+ */
154
+ function PhotoShape(width, height) {
155
+ if (!(width > 0 && height > 0)) {
156
+ return "circle";
157
+ }
158
+ const longest = Math.max(width, height);
159
+ return (longest - Math.min(width, height)) / longest <= ICON_BACKGROUND_UNIFORM_TOLERANCE ? "circle" : "rect";
160
+ }
161
+ EntityLabel.PhotoShape = PhotoShape;
162
+ /*
163
+ * Loads a photo through a plain image element, so a host without CORS still works, and redraws it once onto a canvas
164
+ * no larger than it is shown. Labels draw from that canvas, so none of them holds a camera original.
165
+ */
166
+ async function shrinkPhoto(url, pixels) {
167
+ const none = { source: null, width: 0, height: 0 };
168
+ const image = new Image();
169
+ image.decoding = "async";
170
+ image.src = url;
171
+ try {
172
+ await image.decode();
173
+ }
174
+ catch {
175
+ return none;
176
+ }
177
+ const width = image.naturalWidth;
178
+ const height = image.naturalHeight;
179
+ if (!width || !height) {
180
+ return none;
181
+ }
182
+ // A circle only ever shows the centre square, so that is all worth keeping.
183
+ const circle = PhotoShape(width, height) === "circle";
184
+ const sourceWidth = circle ? Math.min(width, height) : width;
185
+ const sourceHeight = circle ? Math.min(width, height) : height;
186
+ const scale = Math.min(1, pixels / Math.max(sourceWidth, sourceHeight));
187
+ const canvas = document.createElement("canvas");
188
+ canvas.width = Math.max(1, Math.round(sourceWidth * scale));
189
+ canvas.height = Math.max(1, Math.round(sourceHeight * scale));
190
+ const context = canvas.getContext("2d");
191
+ if (!context) {
192
+ return none;
193
+ }
194
+ context.imageSmoothingQuality = "high";
195
+ context.drawImage(image, (width - sourceWidth) / 2, (height - sourceHeight) / 2, sourceWidth, sourceHeight, 0, 0, canvas.width, canvas.height);
196
+ return { source: canvas, width, height };
197
+ }
198
+ /*
199
+ * Downsized label photos, kept on the viewer so every label in a scene shares one load and one decode.
200
+ */
201
+ class Photos {
202
+ constructor() {
203
+ this.entries = new Map();
204
+ this.ready = new Map();
205
+ }
206
+ static Get(viewer) {
207
+ var _a;
208
+ return (_a = viewer[PHOTO_CACHE_KEY]) !== null && _a !== void 0 ? _a : (viewer[PHOTO_CACHE_KEY] = new Photos());
209
+ }
210
+ // The photo when it is already drawn, so a rebuilt label shows it at once instead of growing it again.
211
+ Peek(url, pixels) {
212
+ return this.ready.get(`${pixels} ${url}`);
213
+ }
214
+ Load(url, pixels) {
215
+ const key = `${pixels} ${url}`;
216
+ let entry = this.entries.get(key);
217
+ if (entry) {
218
+ // Reinsertion keeps the map in least recently used order.
219
+ this.entries.delete(key);
220
+ this.entries.set(key, entry);
221
+ return entry;
222
+ }
223
+ entry = shrinkPhoto(url, pixels).then(photo => {
224
+ if (this.entries.get(key) === entry) {
225
+ this.ready.set(key, photo);
226
+ }
227
+ return photo;
228
+ });
229
+ this.entries.set(key, entry);
230
+ for (const oldest of this.entries.keys()) {
231
+ if (this.entries.size <= PHOTO_CACHE_LIMIT) {
232
+ break;
233
+ }
234
+ this.entries.delete(oldest);
235
+ this.ready.delete(oldest);
236
+ }
237
+ return entry;
238
+ }
239
+ }
55
240
  /*
56
241
  * Indexes screen rectangles so sparse scenes do not compare every label with every other label.
57
242
  */
@@ -768,6 +953,10 @@ var EntityLabel;
768
953
  this.cursorMotion = { amount: 0, from: 0, target: 0, start: 0, position: undefined };
769
954
  // Counter to help differentiate between createLabel calls.
770
955
  this.counter = 0;
956
+ // Whether this label has asked its group for a default image, which it does once on screen.
957
+ this.imageAsked = false;
958
+ // The photo a label outside any group found while reading its attributes.
959
+ this.photo = null;
771
960
  // The label element itself.
772
961
  this.label = null;
773
962
  // Callback to kill render listener.
@@ -802,6 +991,7 @@ var EntityLabel;
802
991
  this.visible = params.visible;
803
992
  if (((_c = params.options) === null || _c === void 0 ? void 0 : _c.text) != null)
804
993
  this.additionalTexts = [];
994
+ this.photo = null;
805
995
  this.prePositioned = !isNaN((_d = params.pos3d) === null || _d === void 0 ? void 0 : _d.x);
806
996
  this.createLabel(params.pos3d);
807
997
  if (!params.api && !bruce_models_1.ENVIRONMENT.IS_SELF_MANAGED && this.options.text == null) {
@@ -864,6 +1054,7 @@ var EntityLabel;
864
1054
  this.options = options || {};
865
1055
  this.groupId = groupId;
866
1056
  this.additionalTexts = this.options.text != null ? [] : null;
1057
+ this.photo = null;
867
1058
  this.createLabel();
868
1059
  }
869
1060
  /**
@@ -871,8 +1062,8 @@ var EntityLabel;
871
1062
  * @param starterPos3d optional position to create label at. Ignored for billboard labels.
872
1063
  */
873
1064
  async createLabel(starterPos3d) {
874
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
875
- this.removeLabel();
1065
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
1066
+ // The old label stays until its replacement is built, so a rebuild never blinks while a location loads.
876
1067
  let pos3d = starterPos3d ? starterPos3d.clone() : null;
877
1068
  const counter = ++this.counter;
878
1069
  if (this.disposed) {
@@ -1030,6 +1221,7 @@ var EntityLabel;
1030
1221
  }
1031
1222
  }
1032
1223
  if (isNaN(pos3d === null || pos3d === void 0 ? void 0 : pos3d.x)) {
1224
+ this.removeLabel();
1033
1225
  return;
1034
1226
  }
1035
1227
  pos3d = drawing_utils_1.DrawingUtils.EnsurePosHeight({
@@ -1039,6 +1231,7 @@ var EntityLabel;
1039
1231
  viewer: this.viewer
1040
1232
  });
1041
1233
  if (isNaN(pos3d === null || pos3d === void 0 ? void 0 : pos3d.x)) {
1234
+ this.removeLabel();
1042
1235
  return;
1043
1236
  }
1044
1237
  this._pos3d = pos3d;
@@ -1104,35 +1297,39 @@ var EntityLabel;
1104
1297
  catch (e) {
1105
1298
  console.error(e);
1106
1299
  }
1107
- // Type could be deleted, however we'll just catch that to show a label instead of nothing.
1108
- // This is not unusual for Tilesets where the Type that is baked might be outdated.
1109
- if (entityType || this.options.attributes) {
1110
- const attributes = (_a = this.options.attributes) !== null && _a !== void 0 ? _a : (_c = (_b = entityType.VisualSettings) === null || _b === void 0 ? void 0 : _b.label) === null || _c === void 0 ? void 0 : _c.attributes;
1111
- if (attributes) {
1112
- try {
1113
- const { entity } = await bruce_models_1.Entity.Get({
1114
- api: this.api,
1115
- entityId: this.rego.entityId,
1116
- scenario: ((_d = this.rego.outline) === null || _d === void 0 ? void 0 : _d.length) ? (_e = this.rego.outline.find(x => !!x.Scenario)) === null || _e === void 0 ? void 0 : _e.Scenario : null,
1117
- schemaId: this.rego.schema,
1118
- migrated: true
1119
- });
1120
- for (let i = 0; i < attributes.length; i++) {
1121
- const value = bruce_models_1.Entity.GetValue({
1122
- entity: entity,
1123
- path: attributes[i]
1124
- });
1125
- if (value != null) {
1126
- additionalTexts.push(value);
1127
- }
1128
- }
1129
- }
1130
- // Entity doesn't exist.
1131
- // Likely a graphic with an ID that points to no record.
1132
- catch (e) {
1133
- console.error(e);
1300
+ const attributes = (_a = this.options.attributes) !== null && _a !== void 0 ? _a : (_c = (_b = entityType === null || entityType === void 0 ? void 0 : entityType.VisualSettings) === null || _b === void 0 ? void 0 : _b.label) === null || _c === void 0 ? void 0 : _c.attributes;
1301
+ // Read for its attributes, and for a photo when the label has no icon of its own.
1302
+ if (attributes || !HasIcon(this.options)) {
1303
+ let entity = null;
1304
+ try {
1305
+ entity = (await bruce_models_1.Entity.Get({
1306
+ api: this.api,
1307
+ entityId: this.rego.entityId,
1308
+ scenario: ((_d = this.rego.outline) === null || _d === void 0 ? void 0 : _d.length) ? (_e = this.rego.outline.find(x => !!x.Scenario)) === null || _e === void 0 ? void 0 : _e.Scenario : null,
1309
+ schemaId: this.rego.schema,
1310
+ migrated: true,
1311
+ expandAttachments: !HasIcon(this.options)
1312
+ })).entity;
1313
+ }
1314
+ // Entity doesn't exist.
1315
+ // Likely a graphic with an ID that points to no record.
1316
+ catch (e) {
1317
+ console.error(e);
1318
+ }
1319
+ for (let i = 0; entity && attributes && i < attributes.length; i++) {
1320
+ const value = bruce_models_1.Entity.GetValue({
1321
+ entity: entity,
1322
+ path: attributes[i]
1323
+ });
1324
+ if (value != null) {
1325
+ additionalTexts.push(value);
1134
1326
  }
1135
1327
  }
1328
+ // A linked image outranks the default one, as it does for group labels.
1329
+ if (entity && !HasIcon(this.options)) {
1330
+ const at = additionalTexts.findIndex(value => IsImageUrl(String(value)));
1331
+ this.photo = at >= 0 ? String(additionalTexts.splice(at, 1)[0]).trim() : GetDefaultImageUrl(entity, this.api);
1332
+ }
1136
1333
  }
1137
1334
  }
1138
1335
  this.additionalTexts = additionalTexts;
@@ -1151,9 +1348,13 @@ var EntityLabel;
1151
1348
  if (this.disposed || this.counter != counter) {
1152
1349
  return;
1153
1350
  }
1154
- const presentation = this.options.presentation || "text";
1351
+ this.removeLabel();
1352
+ // A found photo draws as if configured, without changing the options clustering compares and measures.
1353
+ const photo = HasIcon(this.options) ? null : (_j = this.options.photo) !== null && _j !== void 0 ? _j : this.photo;
1354
+ const look = photo ? WithPhoto({ ...this.options, text: (_k = this.options.text) !== null && _k !== void 0 ? _k : [text, ...additionalTexts].join("\n") }, photo) : this.options;
1355
+ const presentation = look.presentation || "text";
1155
1356
  let clickEntityIds = [];
1156
- const selectOnClick = (_j = this.options.selectOnClick) !== null && _j !== void 0 ? _j : Boolean(this.groupId);
1357
+ const selectOnClick = (_l = this.options.selectOnClick) !== null && _l !== void 0 ? _l : Boolean(this.groupId);
1157
1358
  const select = (event) => {
1158
1359
  event.preventDefault();
1159
1360
  event.stopPropagation();
@@ -1182,11 +1383,11 @@ var EntityLabel;
1182
1383
  target.addEventListener(type, event => event.stopPropagation());
1183
1384
  }
1184
1385
  };
1185
- const normalLeader = (_k = this.options.leader) !== null && _k !== void 0 ? _k : presentation === "text";
1386
+ const normalLeader = (_m = look.leader) !== null && _m !== void 0 ? _m : presentation === "text";
1186
1387
  const angled = this.options.leaderStyle === "angled";
1187
1388
  let leader = normalLeader;
1188
1389
  const content = document.createElement("span");
1189
- const originalText = (_l = this.options.text) !== null && _l !== void 0 ? _l : [text, ...additionalTexts].join("\n");
1390
+ const originalText = (_o = this.options.text) !== null && _o !== void 0 ? _o : [text, ...additionalTexts].join("\n");
1190
1391
  let textElement;
1191
1392
  let sourceText;
1192
1393
  let sourceNaturalText;
@@ -1201,10 +1402,33 @@ var EntityLabel;
1201
1402
  if (presentation === "icon-text-above") {
1202
1403
  content.style.flexDirection = "column-reverse";
1203
1404
  }
1405
+ // A found photo's shape decides its layout once it loads: a rectangle sits under the name it adds to, a circle beside it.
1406
+ let photoShape = null;
1407
+ let growPhoto = null;
1408
+ // Loads the photo; called once the label is actually showing, so labels out of view or folded into a cluster never fetch one.
1409
+ let startPhoto = null;
1410
+ let photoTimer = null;
1411
+ const placePhoto = (shape, grow) => {
1412
+ photoShape = shape;
1413
+ growPhoto = grow;
1414
+ content.style.flexDirection = shape === "rect" ? "column-reverse" : "";
1415
+ };
1204
1416
  if (presentation !== "text") {
1205
- const built = this.buildIcon(this.options, counter);
1417
+ const built = this.buildIcon(look, counter, placePhoto);
1418
+ startPhoto = built.start;
1206
1419
  const icon = iconElement = built.icon;
1207
1420
  fitIconBackground = built.fit;
1421
+ if (built.arriving) {
1422
+ // The gap opens with the photo, so a text label does not shift before its photo grows in.
1423
+ content.style.gap = "0px";
1424
+ built.arriving.then(() => {
1425
+ if (iconElement.style.display === "none") {
1426
+ return;
1427
+ }
1428
+ content.style.transition = `gap ${PHOTO_TRANSITION}ms ease-out`;
1429
+ content.style.gap = "6px";
1430
+ });
1431
+ }
1208
1432
  makeClickable(icon);
1209
1433
  // Keep badges outside the glyph so font classes and image masks cannot affect them.
1210
1434
  iconContainer = document.createElement("span");
@@ -1226,7 +1450,7 @@ var EntityLabel;
1226
1450
  if (presentation === "text")
1227
1451
  makeClickable(textElement);
1228
1452
  content.appendChild(textElement);
1229
- if ((_m = this.options.cluster) === null || _m === void 0 ? void 0 : _m.enabled) {
1453
+ if ((_p = this.options.cluster) === null || _p === void 0 ? void 0 : _p.enabled) {
1230
1454
  sourceText = document.createElement("span");
1231
1455
  sourceText.className = "entity-label-source-text";
1232
1456
  sourceText.setAttribute("aria-hidden", "true");
@@ -1246,7 +1470,9 @@ var EntityLabel;
1246
1470
  countElement.style.cssText = "flex:none;align-self:center;white-space:nowrap;background:#fff;color:#000;text-shadow:none;border-radius:8px;padding:0 3px;display:none;";
1247
1471
  // Text above the icon would have the badge land in it, so the free corner is the lower one.
1248
1472
  const badgeBelow = presentation === "icon-text-above";
1249
- if (iconContainer) {
1473
+ // A photo is put away while its label stands for a cluster, so its count sits with the text instead.
1474
+ const badgeOnIcon = Boolean(iconContainer) && !look.iconPhoto;
1475
+ if (badgeOnIcon) {
1250
1476
  countElement.style.cssText += `position:absolute;left:100%;top:${badgeBelow ? "100%" : "0"};transform:translate(-50%,-50%);`;
1251
1477
  iconContainer.appendChild(countElement);
1252
1478
  }
@@ -1263,11 +1489,11 @@ var EntityLabel;
1263
1489
  else {
1264
1490
  ele = document.createElement("div");
1265
1491
  ele.appendChild(content);
1266
- if (!iconContainer) {
1492
+ if (!badgeOnIcon) {
1267
1493
  ele.appendChild(countElement);
1268
1494
  }
1269
1495
  ele.className = "entity-label";
1270
- ele.dataset.entityId = ((_o = this.rego) === null || _o === void 0 ? void 0 : _o.entityId) || "";
1496
+ ele.dataset.entityId = ((_q = this.rego) === null || _q === void 0 ? void 0 : _q.entityId) || "";
1271
1497
  ele.dataset.presentation = presentation;
1272
1498
  if (this.groupId) {
1273
1499
  ele.dataset.groupId = this.groupId;
@@ -1420,7 +1646,7 @@ var EntityLabel;
1420
1646
  };
1421
1647
  let cursorMotion = this.cursorMotion;
1422
1648
  const project = () => {
1423
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
1649
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v;
1424
1650
  if (this.disposed || !this.viewer || this.viewer.isDestroyed() || ((_a = this.visible) === null || _a === void 0 ? void 0 : _a.call(this)) === false ||
1425
1651
  (presentation === "icon" && this.options.iconScale === 0)) {
1426
1652
  return null;
@@ -1458,13 +1684,18 @@ var EntityLabel;
1458
1684
  if (!Number.isFinite(anchor === null || anchor === void 0 ? void 0 : anchor.x) || !Number.isFinite(anchor === null || anchor === void 0 ? void 0 : anchor.y)) {
1459
1685
  return null;
1460
1686
  }
1461
- if (leader && (anchor.x < 0 || anchor.y < 0 || anchor.x >= this.viewer.canvas.clientWidth || anchor.y >= this.viewer.canvas.clientHeight))
1687
+ const onScreen = anchor.x >= 0 && anchor.y >= 0 && anchor.x < this.viewer.canvas.clientWidth && anchor.y < this.viewer.canvas.clientHeight;
1688
+ if (leader && !onScreen)
1462
1689
  return null;
1690
+ if (onScreen && !this.imageAsked && this.groupId && !HasIcon(this.options) && !this.options.photo) {
1691
+ this.imageAsked = true;
1692
+ (_e = this.visualRegister) === null || _e === void 0 ? void 0 : _e.RequestLabelImage((_f = this.rego) === null || _f === void 0 ? void 0 : _f.entityId);
1693
+ }
1463
1694
  const transientPivot = (this.Transient && !this.Persistent ?
1464
- (this.hoverFade.to === 0 ? this.retainedHoverPosition : (_e = this.visualRegister) === null || _e === void 0 ? void 0 : _e.GetLabelHoverPosition((_f = this.rego) === null || _f === void 0 ? void 0 : _f.entityId, (_g = this.rego) === null || _g === void 0 ? void 0 : _g.menuItemId)) : undefined);
1695
+ (this.hoverFade.to === 0 ? this.retainedHoverPosition : (_g = this.visualRegister) === null || _g === void 0 ? void 0 : _g.GetLabelHoverPosition((_h = this.rego) === null || _h === void 0 ? void 0 : _h.entityId, (_j = this.rego) === null || _j === void 0 ? void 0 : _j.menuItemId)) : undefined);
1465
1696
  const hover = (presentation !== "icon" ?
1466
1697
  (this.Transient && !this.Persistent && this.hoverFade.to === 0 ? this.retainedHoverPosition :
1467
- (_h = this.visualRegister) === null || _h === void 0 ? void 0 : _h.GetLabelHoverPosition((_j = this.rego) === null || _j === void 0 ? void 0 : _j.entityId, (_k = this.rego) === null || _k === void 0 ? void 0 : _k.menuItemId)) : undefined);
1698
+ (_k = this.visualRegister) === null || _k === void 0 ? void 0 : _k.GetLabelHoverPosition((_l = this.rego) === null || _l === void 0 ? void 0 : _l.entityId, (_m = this.rego) === null || _m === void 0 ? void 0 : _m.menuItemId)) : undefined);
1468
1699
  const reused = !this.Transient || this.Persistent;
1469
1700
  const now = Date.now();
1470
1701
  const animateCursor = () => {
@@ -1494,8 +1725,8 @@ var EntityLabel;
1494
1725
  const icon = measureIcon();
1495
1726
  const iconRing = icon ? Math.max(ICON_RING_MIN, icon.radius + ICON_RING_PADDING) : 0;
1496
1727
  const pivot = icon ? undefined : transientPivot;
1497
- let x = ((_l = pivot === null || pivot === void 0 ? void 0 : pivot.x) !== null && _l !== void 0 ? _l : anchor.x) + (icon || pixelOffset).x;
1498
- let y = ((_m = pivot === null || pivot === void 0 ? void 0 : pivot.y) !== null && _m !== void 0 ? _m : anchor.y) + (icon || pixelOffset).y;
1728
+ let x = ((_o = pivot === null || pivot === void 0 ? void 0 : pivot.x) !== null && _o !== void 0 ? _o : anchor.x) + (icon || pixelOffset).x;
1729
+ let y = ((_p = pivot === null || pivot === void 0 ? void 0 : pivot.y) !== null && _p !== void 0 ? _p : anchor.y) + (icon || pixelOffset).y;
1499
1730
  if (cursorAmount > 0 && !icon) {
1500
1731
  // A leader-less label starts centred on its anchor; preserve that box as it lifts away.
1501
1732
  if (!normalLeader) {
@@ -1548,11 +1779,12 @@ var EntityLabel;
1548
1779
  const width = ele.offsetWidth / coordinates.scaleX;
1549
1780
  const height = ele.offsetHeight / coordinates.scaleY;
1550
1781
  let clusterBounds;
1551
- if ((_o = this.options.cluster) === null || _o === void 0 ? void 0 : _o.enabled) {
1782
+ if ((_q = this.options.cluster) === null || _q === void 0 ? void 0 : _q.enabled) {
1552
1783
  const textWidth = (sourceText === null || sourceText === void 0 ? void 0 : sourceText.offsetWidth) || 0;
1553
1784
  const textHeight = (sourceText === null || sourceText === void 0 ? void 0 : sourceText.offsetHeight) || 0;
1554
- const iconWidth = (iconElement === null || iconElement === void 0 ? void 0 : iconElement.offsetWidth) || 0;
1555
- const iconHeight = (iconElement === null || iconElement === void 0 ? void 0 : iconElement.offsetHeight) || 0;
1785
+ // A photo is never part of the footprint, so its arrival cannot change which cluster a label joins.
1786
+ const iconWidth = look.iconPhoto ? 0 : (iconElement === null || iconElement === void 0 ? void 0 : iconElement.offsetWidth) || 0;
1787
+ const iconHeight = look.iconPhoto ? 0 : (iconElement === null || iconElement === void 0 ? void 0 : iconElement.offsetHeight) || 0;
1556
1788
  const gap = sourceText && iconWidth ? 6 : 0;
1557
1789
  const above = presentation === "icon-text-above";
1558
1790
  // Match the fixed content gap and label padding, excluding the count badge.
@@ -1581,7 +1813,7 @@ var EntityLabel;
1581
1813
  if (!leader && (bounds.x + bounds.width < 0 || bounds.y + bounds.height < 0 || bounds.x > this.viewer.canvas.clientWidth || bounds.y > this.viewer.canvas.clientHeight)) {
1582
1814
  return null;
1583
1815
  }
1584
- 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 });
1816
+ const state = (_r = this.visualRegister) === null || _r === void 0 ? void 0 : _r.GetState({ entityId: (_s = this.rego) === null || _s === void 0 ? void 0 : _s.entityId, menuItemId: (_t = this.rego) === null || _t === void 0 ? void 0 : _t.menuItemId });
1585
1817
  const opacity = this.prePositioned || hoverOnly || MAX_DISTANCE === Infinity ? 1 : 1 - distance / MAX_DISTANCE;
1586
1818
  return {
1587
1819
  canFlip: leader && presentation !== "icon", hasLeader: leader,
@@ -1589,8 +1821,8 @@ var EntityLabel;
1589
1821
  transient: this.Transient || Boolean(hover) || cursorAmount > 0,
1590
1822
  extendedGeometry: visual instanceof Cesium.Entity && Boolean(visual.polygon || visual.polyline || visual.corridor),
1591
1823
  viewportWidth: this.viewer.canvas.clientWidth,
1592
- 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,
1593
- 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,
1824
+ id: this.id, entityKey: ((_u = this.rego) === null || _u === void 0 ? void 0 : _u.entityId) ? JSON.stringify([this.rego.entityId, this.rego.menuItemId || "", this.Transient && !this.Persistent ? "hover" : ""]) : undefined,
1825
+ entityId: (_v = this.rego) === null || _v === void 0 ? void 0 : _v.entityId, group: this.groupId, x, y, width, height, anchorX, anchorY, depth, leaderLength, leaderRun, leaderSide, distance,
1594
1826
  iconRadius: icon === null || icon === void 0 ? void 0 : icon.radius,
1595
1827
  // Priority shows the hovered member individually without dragging its cluster along.
1596
1828
  opacity, priority: hover || cursorAmount > 0 || (state === null || state === void 0 ? void 0 : state.highlighted) ? 1 : 0, options: this.options, text: originalText, clusterBounds
@@ -1599,7 +1831,7 @@ var EntityLabel;
1599
1831
  let displacement = { x: 0, y: 0 };
1600
1832
  let nudge = { x: 0, y: 0, fromX: 0, fromY: 0, targetX: 0, targetY: 0, start: 0 };
1601
1833
  const apply = (placement) => {
1602
- 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;
1834
+ 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, _3, _4;
1603
1835
  const show = (placement === null || placement === void 0 ? void 0 : placement.visible) && placement.opacity > 0;
1604
1836
  clickEntityIds = show ? placement.entityIds : [];
1605
1837
  ele.style.visibility = show ? "visible" : "hidden";
@@ -1613,9 +1845,30 @@ var EntityLabel;
1613
1845
  if (!this.Transient) {
1614
1846
  this.ringAnimation = undefined;
1615
1847
  }
1848
+ clearTimeout(photoTimer);
1849
+ photoTimer = null;
1616
1850
  return;
1617
1851
  }
1618
- const displayText = (_a = placement.text) !== null && _a !== void 0 ? _a : originalText;
1852
+ // A label standing for a cluster shows its summary only; its photo is put away and never fetched.
1853
+ const solo = !((_a = placement.members) === null || _a === void 0 ? void 0 : _a.length);
1854
+ if (look.iconPhoto && iconElement && iconElement.dataset.failed !== "true") {
1855
+ const display = solo ? "inline-flex" : "none";
1856
+ if (iconElement.style.display !== display) {
1857
+ iconElement.style.display = display;
1858
+ }
1859
+ }
1860
+ if (!solo) {
1861
+ clearTimeout(photoTimer);
1862
+ photoTimer = null;
1863
+ }
1864
+ else if (startPhoto && photoTimer == null) {
1865
+ photoTimer = setTimeout(() => {
1866
+ if (!this.disposed && this.counter === counter) {
1867
+ startPhoto();
1868
+ }
1869
+ }, PHOTO_SHOW_DELAY);
1870
+ }
1871
+ const displayText = (_b = placement.text) !== null && _b !== void 0 ? _b : originalText;
1619
1872
  if (textElement && textElement.textContent !== displayText) {
1620
1873
  textElement.textContent = displayText;
1621
1874
  // Measure the summary's footprint next frame without replacing each member's source text.
@@ -1639,10 +1892,12 @@ var EntityLabel;
1639
1892
  const stack = JSON.stringify(placement.icons || []);
1640
1893
  if (iconStack && stack !== displayedStack) {
1641
1894
  displayedStack = stack;
1642
- const looks = ((_b = placement.icons) === null || _b === void 0 ? void 0 : _b.length) ? [iconLook(this.options), ...placement.icons] : [];
1895
+ const looks = ((_c = placement.icons) === null || _c === void 0 ? void 0 : _c.length) ? [iconLook(this.options), ...placement.icons] : [];
1643
1896
  // One small size for all, so no icon in the arrangement reads as more important than another.
1644
- const size = (this.options.iconSize || 24) * ((_c = this.options.iconScale) !== null && _c !== void 0 ? _c : 1) * ICON_STACK_SCALE;
1897
+ const size = (this.options.iconSize || 24) * ((_d = this.options.iconScale) !== null && _d !== void 0 ? _d : 1) * ICON_STACK_SCALE;
1645
1898
  const built = looks.map(look => this.buildIcon({ ...look, iconSize: size }, counter));
1899
+ // A stack is on show already, so its photos load straight away.
1900
+ built.forEach(({ start }) => start === null || start === void 0 ? void 0 : start());
1646
1901
  // Two sit side by side; three make a triangle with the representative on top.
1647
1902
  iconStack.style.gridTemplateColumns = built.length === 3 ? "auto auto" : `repeat(${built.length}, auto)`;
1648
1903
  if (built.length === 3) {
@@ -1659,7 +1914,7 @@ var EntityLabel;
1659
1914
  }
1660
1915
  for (const fit of stackedFits)
1661
1916
  fit();
1662
- if (iconContainer) {
1917
+ if (badgeOnIcon) {
1663
1918
  const badgeWidth = countElement.offsetWidth;
1664
1919
  const iconWidth = iconElement.offsetWidth;
1665
1920
  const narrow = iconWidth < iconElement.offsetHeight * 0.6;
@@ -1674,16 +1929,28 @@ var EntityLabel;
1674
1929
  // Summary and badge changes must fit on their first visible frame, including after resize.
1675
1930
  const ring = this.options.anchorRing;
1676
1931
  const pointer = (layer.pointer || (cursorMotion.target ? cursorMotion.position : undefined) || (this.Transient && !this.Persistent ?
1677
- (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));
1932
+ (this.hoverFade.to === 0 ? this.retainedHoverPosition : (_e = this.visualRegister) === null || _e === void 0 ? void 0 : _e.GetLabelHoverPosition((_f = this.rego) === null || _f === void 0 ? void 0 : _f.entityId, (_g = this.rego) === null || _g === void 0 ? void 0 : _g.menuItemId)) : undefined));
1678
1933
  const iconRing = frame.iconRadius ? Math.max(ICON_RING_MIN, frame.iconRadius + ICON_RING_PADDING) : 0;
1679
- 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);
1680
- 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));
1934
+ const hovered = pointer && Math.hypot(pointer.x - frame.anchorX, pointer.y - frame.anchorY) <= Math.max((_h = ring === null || ring === void 0 ? void 0 : ring.hoverDistance) !== null && _h !== void 0 ? _h : 24, iconRing);
1935
+ if (look.iconPhoto && growPhoto) {
1936
+ // A rectangle leans on the leader's side of the label.
1937
+ if (photoShape === "rect") {
1938
+ const align = !leader ? "center" : placement.textSide === "left" ? "flex-end" : "flex-start";
1939
+ if (content.style.alignItems !== align) {
1940
+ content.style.alignItems = align;
1941
+ }
1942
+ }
1943
+ // Grown in layout rather than scaled, so the border keeps its width, and the label, whose bottom
1944
+ // sits on the leader, grows up and sideways without changing the leader's length.
1945
+ growPhoto(Boolean(hovered) && !((_j = placement.members) === null || _j === void 0 ? void 0 : _j.length));
1946
+ }
1947
+ const normalRadius = (!leader || (ring === null || ring === void 0 ? void 0 : ring.enabled) === false ? 0 : Math.max(frame.distance <= ((_k = ring === null || ring === void 0 ? void 0 : ring.nearDistance) !== null && _k !== void 0 ? _k : 100) ? ((_l = ring === null || ring === void 0 ? void 0 : ring.nearRadius) !== null && _l !== void 0 ? _l : 10) : 0, hovered ? ((_m = ring === null || ring === void 0 ? void 0 : ring.hoverRadius) !== null && _m !== void 0 ? _m : (this.Transient && !this.Persistent ? 15.4 : 22)) : 0));
1681
1948
  const targetRadius = normalRadius + (15.4 - normalRadius) * cursorMotion.amount;
1682
1949
  // Around a glyph the ring keeps the glyph's size and only fades its stroke in, so it never covers the icon.
1683
1950
  const targetOuter = iconRing || targetRadius || 2.5;
1684
1951
  const targetInner = iconRing ? (targetRadius > 0 ? iconRing - 2 : iconRing) : Math.max(0, targetRadius - 2);
1685
1952
  const now = Date.now();
1686
- const duration = (_l = ring === null || ring === void 0 ? void 0 : ring.transitionDuration) !== null && _l !== void 0 ? _l : 200;
1953
+ const duration = (_o = ring === null || ring === void 0 ? void 0 : ring.transitionDuration) !== null && _o !== void 0 ? _o : 200;
1687
1954
  const sharedRing = this.Transient && !this.Persistent && this.hoverFade.to === 1 ? this.hoverRingState : undefined;
1688
1955
  let ringAnimation = sharedRing ? sharedRing.animation : this.ringAnimation;
1689
1956
  if (!ringAnimation) {
@@ -1726,19 +1993,19 @@ var EntityLabel;
1726
1993
  if (leader && angled) {
1727
1994
  // The declutter owns the side; the fit only says which one had the room to begin with.
1728
1995
  const run = Math.max(ANGLED_LEADER_RUN, radius + 4);
1729
- const side = (_o = (_m = placement.textSide) !== null && _m !== void 0 ? _m : frame.leaderSide) !== null && _o !== void 0 ? _o : "right";
1996
+ const side = (_q = (_p = placement.textSide) !== null && _p !== void 0 ? _p : frame.leaderSide) !== null && _q !== void 0 ? _q : "right";
1730
1997
  const beside = side === "left" ? frame.anchorX - run - width : frame.anchorX + run;
1731
1998
  position.x = Math.max(0, Math.min(beside, this.viewer.canvas.clientWidth - width));
1732
1999
  }
1733
2000
  else if (leader && placement.textSide === "left") {
1734
2001
  position.x = Math.max(0, Math.min(frame.anchorX - width, this.viewer.canvas.clientWidth - width));
1735
2002
  }
1736
- const gap = (_q = (_p = this.options.pointerAvoidance) === null || _p === void 0 ? void 0 : _p.gap) !== null && _q !== void 0 ? _q : 12;
2003
+ const gap = (_s = (_r = this.options.pointerAvoidance) === null || _r === void 0 ? void 0 : _r.gap) !== null && _s !== void 0 ? _s : 12;
1737
2004
  const containsPointer = (x, y, margin) => {
1738
2005
  return pointer && pointer.x >= x - margin && pointer.x <= x + width + margin && pointer.y >= y - margin && pointer.y <= y + height + margin;
1739
2006
  };
1740
2007
  const passive = !selectOnClick || !this.visualRegister;
1741
- if (passive && ((_r = this.options.pointerAvoidance) === null || _r === void 0 ? void 0 : _r.enabled) !== false && pointer &&
2008
+ if (passive && ((_t = this.options.pointerAvoidance) === null || _t === void 0 ? void 0 : _t.enabled) !== false && pointer &&
1742
2009
  (containsPointer(position.x, position.y, gap) || containsPointer(position.x + displacement.x, position.y + displacement.y, gap))) {
1743
2010
  if (containsPointer(position.x + displacement.x, position.y + displacement.y, 0)) {
1744
2011
  // Choose against the original box and retain the offset until the pointer leaves both boxes.
@@ -1756,7 +2023,7 @@ var EntityLabel;
1756
2023
  else {
1757
2024
  displacement = { x: 0, y: 0 };
1758
2025
  }
1759
- const nudgeDuration = (_t = (_s = this.options.pointerAvoidance) === null || _s === void 0 ? void 0 : _s.transitionDuration) !== null && _t !== void 0 ? _t : 200;
2026
+ const nudgeDuration = (_v = (_u = this.options.pointerAvoidance) === null || _u === void 0 ? void 0 : _u.transitionDuration) !== null && _v !== void 0 ? _v : 200;
1760
2027
  const animateNudge = () => {
1761
2028
  const progress = now < nudge.start ? 0 : nudgeDuration > 0 ? Math.min(1, (now - nudge.start) / nudgeDuration) : 1;
1762
2029
  const eased = progress * progress * (3 - 2 * progress);
@@ -1772,7 +2039,7 @@ var EntityLabel;
1772
2039
  fromY: nudge.y,
1773
2040
  targetX: displacement.x,
1774
2041
  targetY: displacement.y,
1775
- start: now + ((_v = (_u = this.options.pointerAvoidance) === null || _u === void 0 ? void 0 : _u.delay) !== null && _v !== void 0 ? _v : 180)
2042
+ start: now + ((_x = (_w = this.options.pointerAvoidance) === null || _w === void 0 ? void 0 : _w.delay) !== null && _x !== void 0 ? _x : 180)
1776
2043
  };
1777
2044
  }
1778
2045
  if (animateNudge() < 1 && (nudge.fromX !== nudge.targetX || nudge.fromY !== nudge.targetY)) {
@@ -1783,7 +2050,7 @@ var EntityLabel;
1783
2050
  ele.style.left = `${coordinates.x + position.x * coordinates.scaleX}px`;
1784
2051
  ele.style.top = `${coordinates.y + position.y * coordinates.scaleY}px`;
1785
2052
  for (const clickTarget of Array.from(content.querySelectorAll('[role="button"]'))) {
1786
- 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}`);
2053
+ clickTarget.setAttribute("aria-label", placement.members.length ? `Select ${placement.entityIds.length} entities` : `Select ${(_z = (_y = this.options.text) !== null && _y !== void 0 ? _y : text) !== null && _z !== void 0 ? _z : (_0 = this.rego) === null || _0 === void 0 ? void 0 : _0.entityId}`);
1787
2054
  }
1788
2055
  if (leader) {
1789
2056
  const nodeRadius = ringAnimation.outer;
@@ -1823,13 +2090,13 @@ var EntityLabel;
1823
2090
  // Keep the stem inside the 126-degree arc, away from antialiased endpoints.
1824
2091
  const arcFraction = 0.35;
1825
2092
  const rotationLimit = Math.PI * arcFraction - Math.PI / 30;
1826
- const targetOffset = hovered ? Math.max(-rotationLimit, Math.min(rotationLimit, wrappedAngle)) : ((_z = ringAnimation.arcOffset) !== null && _z !== void 0 ? _z : 0);
2093
+ const targetOffset = hovered ? Math.max(-rotationLimit, Math.min(rotationLimit, wrappedAngle)) : ((_1 = ringAnimation.arcOffset) !== null && _1 !== void 0 ? _1 : 0);
1827
2094
  const targetOpen = cursorMotion.target || (hovered && (ring === null || ring === void 0 ? void 0 : ring.enabled) !== false && targetRadius > 0) ? 1 : 0;
1828
- const elapsed = Math.min(32, Math.max(0, now - ((_0 = ringAnimation.arcTime) !== null && _0 !== void 0 ? _0 : now)));
2095
+ const elapsed = Math.min(32, Math.max(0, now - ((_2 = ringAnimation.arcTime) !== null && _2 !== void 0 ? _2 : now)));
1829
2096
  const blend = duration > 0 ? 1 - Math.exp(-elapsed / (duration / 3)) : 1;
1830
2097
  const approach = (value, target) => Math.abs(value - target) < 0.001 ? target : value + (target - value) * blend;
1831
- ringAnimation.arcOffset = approach((_1 = ringAnimation.arcOffset) !== null && _1 !== void 0 ? _1 : 0, targetOffset);
1832
- ringAnimation.arcOpen = approach((_2 = ringAnimation.arcOpen) !== null && _2 !== void 0 ? _2 : 0, targetOpen);
2098
+ ringAnimation.arcOffset = approach((_3 = ringAnimation.arcOffset) !== null && _3 !== void 0 ? _3 : 0, targetOffset);
2099
+ ringAnimation.arcOpen = approach((_4 = ringAnimation.arcOpen) !== null && _4 !== void 0 ? _4 : 0, targetOpen);
1833
2100
  ringAnimation.arcTime = now;
1834
2101
  if (ringAnimation.arcOffset !== targetOffset || ringAnimation.arcOpen !== targetOpen) {
1835
2102
  this.viewer.scene.requestRender();
@@ -1884,14 +2151,16 @@ var EntityLabel;
1884
2151
  }
1885
2152
  /*
1886
2153
  * Builds one icon glyph from label options, with its background chip when asked for.
1887
- *
1888
2154
  * The returned fit squares the chip off once the glyph has a measurable size.
1889
2155
  */
1890
- buildIcon(options, counter) {
2156
+ buildIcon(options, counter, placePhoto) {
1891
2157
  var _a, _b, _c;
1892
2158
  let fit;
1893
2159
  const icon = document.createElement("span");
1894
2160
  icon.className = "entity-label-icon";
2161
+ if (options.iconPhoto && options.iconUrl) {
2162
+ return this.buildPhoto(icon, options, counter, placePhoto);
2163
+ }
1895
2164
  icon.style.cssText = `font-size:${(options.iconSize || 24) * ((_a = options.iconScale) !== null && _a !== void 0 ? _a : 1)}px;line-height:1;`;
1896
2165
  icon.style.textShadow = "0 1px 2px rgba(0, 0, 0, 0.25)";
1897
2166
  const iconColor = typeof options.iconColor === "string" ? options.iconColor : undefined;
@@ -1944,6 +2213,8 @@ var EntityLabel;
1944
2213
  }
1945
2214
  this.viewer.scene.requestRender();
1946
2215
  };
2216
+ // A photo is rarely square; fit it inside the icon's box rather than stretching it.
2217
+ image.style.objectFit = "contain";
1947
2218
  image.onload = resize;
1948
2219
  resize();
1949
2220
  image.src = options.iconUrl;
@@ -1999,6 +2270,101 @@ var EntityLabel;
1999
2270
  }
2000
2271
  return { icon, fit };
2001
2272
  }
2273
+ /*
2274
+ * Draws an entity's own photo at the size of an icon on its chip: a circle, or a rounded rectangle when too oblong for one.
2275
+ */
2276
+ buildPhoto(icon, options, counter, placePhoto) {
2277
+ var _a;
2278
+ const size = (options.iconSize || 24) * ((_a = options.iconScale) !== null && _a !== void 0 ? _a : 1);
2279
+ // The glyph plus the chip's padding of a third either side, enlarged for a photo.
2280
+ const diameter = (size + 2 * (size / 3)) * PHOTO_SIZE_SCALE;
2281
+ // Enough pixels to stay sharp at the hover size.
2282
+ const pixels = Math.ceil(diameter * PHOTO_HOVER_SCALE * (window.devicePixelRatio || 1));
2283
+ const canvas = document.createElement("canvas");
2284
+ canvas.className = "entity-label-photo";
2285
+ canvas.style.cssText = "display:block;box-sizing:border-box;border:2px solid rgba(255,255,255,0.9);box-shadow:0 1px 3px rgba(0,0,0,0.35);";
2286
+ canvas.style.transition = ["width", "height", "opacity"].map(property => `${property} ${PHOTO_TRANSITION}ms ease-out`).join(",");
2287
+ icon.classList.add("entity-label-icon-photo");
2288
+ icon.style.display = options.iconScale === 0 ? "none" : "inline-flex";
2289
+ icon.appendChild(canvas);
2290
+ // Resting size, set once the photo's shape is known.
2291
+ let width = 0;
2292
+ let height = 0;
2293
+ let grown = false;
2294
+ const grow = (on) => {
2295
+ if (on === grown || !width) {
2296
+ return;
2297
+ }
2298
+ grown = on;
2299
+ const scale = on ? PHOTO_HOVER_SCALE : 1;
2300
+ canvas.style.width = `${width * scale}px`;
2301
+ canvas.style.height = `${height * scale}px`;
2302
+ this.renderFor(PHOTO_TRANSITION);
2303
+ };
2304
+ // Draws the photo in, or drops the icon when it would not load so the label stays as its text.
2305
+ const show = (photo) => {
2306
+ var _a;
2307
+ if (!photo.source) {
2308
+ icon.dataset.failed = "true";
2309
+ icon.style.display = "none";
2310
+ return false;
2311
+ }
2312
+ canvas.width = photo.source.width;
2313
+ canvas.height = photo.source.height;
2314
+ (_a = canvas.getContext("2d")) === null || _a === void 0 ? void 0 : _a.drawImage(photo.source, 0, 0);
2315
+ const longest = Math.max(photo.width, photo.height);
2316
+ const circle = PhotoShape(photo.width, photo.height) === "circle";
2317
+ width = circle ? diameter : diameter * photo.width / longest;
2318
+ height = circle ? diameter : diameter * photo.height / longest;
2319
+ canvas.style.width = `${width}px`;
2320
+ canvas.style.height = `${height}px`;
2321
+ canvas.style.borderRadius = circle ? "50%" : `${diameter * PHOTO_CORNER}px`;
2322
+ canvas.style.opacity = "1";
2323
+ placePhoto === null || placePhoto === void 0 ? void 0 : placePhoto(circle ? "circle" : "rect", grow);
2324
+ return true;
2325
+ };
2326
+ const photos = Photos.Get(this.viewer);
2327
+ const ready = photos.Peek(options.iconUrl, pixels);
2328
+ if (ready) {
2329
+ show(ready);
2330
+ return { icon };
2331
+ }
2332
+ canvas.style.width = "0px";
2333
+ canvas.style.height = "0px";
2334
+ canvas.style.opacity = "0";
2335
+ let arrived;
2336
+ const arriving = new Promise(resolve => arrived = resolve);
2337
+ let started = false;
2338
+ const start = () => {
2339
+ if (started) {
2340
+ return;
2341
+ }
2342
+ started = true;
2343
+ photos.Load(options.iconUrl, pixels).then(photo => {
2344
+ if (!this.disposed && this.counter === counter && show(photo)) {
2345
+ this.renderFor(PHOTO_TRANSITION);
2346
+ }
2347
+ arrived();
2348
+ });
2349
+ };
2350
+ return { icon, arriving, start };
2351
+ }
2352
+ /*
2353
+ * Keeps rendering while a CSS transition changes a label's size, since placement measures it every frame.
2354
+ */
2355
+ renderFor(duration) {
2356
+ const end = Date.now() + duration;
2357
+ const step = () => {
2358
+ if (this.disposed || !this.viewer || this.viewer.isDestroyed()) {
2359
+ return;
2360
+ }
2361
+ this.viewer.scene.requestRender();
2362
+ if (Date.now() < end) {
2363
+ requestAnimationFrame(step);
2364
+ }
2365
+ };
2366
+ step();
2367
+ }
2002
2368
  /**
2003
2369
  * Removes label from the viewer and unsubscribes from events.
2004
2370
  */