@mosaicajs/components 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +2 -0
  2. package/dist/aggregate.d.ts +4 -10
  3. package/dist/aggregate.js +7 -13
  4. package/dist/artifact-card.d.ts +21 -7
  5. package/dist/artifact-card.js +136 -79
  6. package/dist/base.d.ts +44 -5
  7. package/dist/base.js +54 -5
  8. package/dist/cluster-filter.d.ts +29 -25
  9. package/dist/cluster-filter.js +123 -106
  10. package/dist/colour-editor.d.ts +15 -4
  11. package/dist/colour-editor.js +107 -34
  12. package/dist/colour-picker.d.ts +12 -5
  13. package/dist/colour-picker.js +119 -32
  14. package/dist/display.js +77 -9
  15. package/dist/events.d.ts +21 -7
  16. package/dist/explorer.d.ts +118 -37
  17. package/dist/explorer.js +487 -212
  18. package/dist/field-card.d.ts +304 -48
  19. package/dist/field-card.js +1364 -244
  20. package/dist/filter-panel.d.ts +54 -10
  21. package/dist/filter-panel.js +242 -42
  22. package/dist/filter.d.ts +22 -4
  23. package/dist/filter.js +113 -34
  24. package/dist/hierarchy.d.ts +68 -45
  25. package/dist/hierarchy.js +320 -243
  26. package/dist/item-card.d.ts +62 -14
  27. package/dist/item-card.js +310 -110
  28. package/dist/key-picker.d.ts +2 -1
  29. package/dist/key-picker.js +4 -3
  30. package/dist/layer-picker.d.ts +4 -2
  31. package/dist/layer-picker.js +16 -7
  32. package/dist/lineage.d.ts +132 -0
  33. package/dist/lineage.js +307 -0
  34. package/dist/map.d.ts +135 -24
  35. package/dist/map.js +387 -117
  36. package/dist/mosaica-components.js +1848 -832
  37. package/dist/mosaica-components.js.sri +1 -1
  38. package/dist/mosaica-components.json +2 -2
  39. package/dist/parts.d.ts +4 -4
  40. package/dist/parts.js +18 -4
  41. package/dist/paths.d.ts +34 -12
  42. package/dist/paths.js +84 -46
  43. package/dist/selection.d.ts +22 -3
  44. package/dist/selection.js +125 -59
  45. package/dist/states.d.ts +72 -0
  46. package/dist/states.js +135 -26
  47. package/dist/status.d.ts +63 -5
  48. package/dist/status.js +215 -36
  49. package/dist/tokens.d.ts +8 -4
  50. package/dist/tokens.js +144 -14
  51. package/dist/view-picker.js +5 -0
  52. package/package.json +4 -3
package/dist/map.js CHANGED
@@ -35,13 +35,13 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
35
35
  import { Deck, OrthographicView } from '@deck.gl/core';
36
36
  import { css, html, nothing } from 'lit';
37
37
  import { property, state } from 'lit/decorators.js';
38
- import { MAX_DEPTH, WORLD_SIZE, dataToWorldXY } from '@mosaicajs/client';
38
+ import { MAX_DEPTH, WORLD_SIZE, dataToWorldXY, formatMasked } from '@mosaicajs/client';
39
39
  import { assertCompositionMatchesServed, chosenColour, hasValue } from '@mosaicajs/client/internal';
40
40
  import { DEFAULT_DENSITY_CELL_PX, DEFAULT_DENSITY_SCALE, DensityCounter, MosaicaLayer, cameraFitting, densityCountAt, frameMatches, resolvePick, viewInputOf } from '@mosaicajs/deck';
41
- import { DENSITY_COLOUR_TITLES, MarkSlab, artifactOfMark, clusterLayerOf, contourShapes, drawnLevel, densityStops, drawnCells, encodingOf, encodingSignature, hoverAt, maxCount } from '@mosaicajs/deck/internal';
41
+ import { DENSITY_COLOUR_TITLES, MarkSlab, artifactOfMark, clusterLayerOf, colourLayerOf, contourShapes, drawnLevel, drawnLevels, densityStops, drawnCells, encodingOf, encodingSignature, hoverAt, levelsKey, maxCount } from '@mosaicajs/deck/internal';
42
42
  import { MosaicaElement, emit, idString, shapeDetail, timestampText } from './base.js';
43
43
  import { attachContextRoot, defineOnce } from './define.js';
44
- import { NOTHING_VISIBLE, refusedState, renderState, seesNothing, stateOf } from './states.js';
44
+ import { ActionFocus, EmptyAnswer, NOTHING_VISIBLE, UNSAID, liveLine, refusedLine, seesNothing, stateLine, stateOf } from './states.js';
45
45
  import { MapBasemap, basemapChanged } from './basemap.js';
46
46
  import { icon } from './icons.js';
47
47
  import { sameFrame } from './view-switch.js';
@@ -70,13 +70,29 @@ let mapsMade = 0;
70
70
  * A box or lasso drawn with the toolbar becomes the store's selected region, which narrows the map
71
71
  * and every count to the items inside it. Shift-drag draws a box without changing tool. With the
72
72
  * map focused, the arrow keys pan and `+` and `-` zoom. Escape cancels a shape being drawn, or
73
- * clears the region when there is no shape, or drops the picked item when there is neither.
73
+ * clears the region when there is no shape, or drops the picked item when there is neither. With
74
+ * none of the three, the map leaves Escape to the page, so a dialog holding the map still closes.
74
75
  *
75
- * When the map cannot draw the view, it says why over the canvas.
76
+ * The hover tooltip follows the pointer and stays inside the map. Escape dismisses it wherever the
77
+ * focus is, and it stays away until the pointer moves onto another point. With the focus in the
78
+ * map, or in an element that renders the map inside its own shadow root, such as
79
+ * `<mosaica-explorer>`, the press does nothing else: the map calls `preventDefault` on it, and
80
+ * clears no selection on it. With the focus anywhere else on the page, the map leaves the press as
81
+ * it was, so the page's own Escape still acts.
82
+ *
83
+ * When the map cannot draw the view, it says why over the canvas, and screen readers announce it.
84
+ * Nothing in view is announced as the answer to a change of filters, highlight or selection, and
85
+ * only shown where a pan went. Screen readers also announce the basemap's note, and how many items
86
+ * match inside the selected region, once, when the query that counts it has finished loading. Set
87
+ * `quiet` where another element on the page says the same things, such as a status strip: the map
88
+ * shows the same, and announces only what it alone knows, the basemap's note and a basemap it
89
+ * refuses.
90
+ *
91
+ * With `ground` unset the map follows the page's colour scheme, and draws again when it changes.
76
92
  *
77
93
  * The canvas is a single tab stop, and it takes any `tabindex` or `aria-label` you set on the map.
78
- * Without an `aria-label`, the canvas is labelled with its keys, "map: arrow keys pan, + and -
79
- * zoom", so that someone using a screen reader knows how to move it.
94
+ * Without an `aria-label` the canvas is named "Map". Either way its description lists its keys, so
95
+ * that someone using a screen reader knows how to move it.
80
96
  *
81
97
  * @summary An interactive map with no panels around it.
82
98
  * @tagname mosaica-map
@@ -105,23 +121,26 @@ let mapsMade = 0;
105
121
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-layerchange']>} mosaica-layerchange - The
106
122
  * `layers` attribute changed.
107
123
  * @csspart canvas - The deck.gl canvas's container.
108
- * @csspart overlay - The message over the canvas when the view is refused, expired or empty, or the
109
- * server cannot be reached, did not answer or is too busy. If the viewer may see no item in the
110
- * view at all, it says so instead of "Nothing in view".
124
+ * @csspart overlay - The layer over the canvas that holds the state line.
111
125
  * @csspart state - The state line inside the overlay, with `data-state`, and `data-visible="none"`
112
- * if the viewer may see no item in the view.
126
+ * if the viewer may see no item in the view. It shows a message when the view is refused,
127
+ * expired or empty, or the server cannot be reached, did not answer or is too busy; if the viewer
128
+ * may see no item in the view at all, it says so instead of "Nothing in view". It is in the page
129
+ * in every state, and drawn only while it has a message.
113
130
  * @csspart retry - The Retry button in the overlay, after a refusal, no answer or a busy server.
114
131
  * @csspart refusal - The words "View refused", or "Server busy", inside the overlay, with
115
132
  * `data-code` set to the refusal's code.
116
133
  * @csspart reason - The server's reason for a refusal, as it sent it, on a line under "View
117
134
  * refused".
118
135
  * @csspart controls - The toolbar.
119
- * @csspart region-tag - The tag on the selected region's top-left corner, with how many items
120
- * match inside it (or outside it, for its complement) and a button that clears it.
136
+ * @csspart region-tag - The tag above the selected region's top-left corner, with how many items
137
+ * match inside it (or outside it, for its complement) and a button that clears it. It stays
138
+ * inside the map when the corner is off it.
121
139
  * @csspart tooltip - The hover tooltip.
122
140
  * @csspart density-key - The key to density's colours in items per cell, in the bottom-left corner.
123
141
  * Shown while density is drawn in a colour ramp or without the points.
124
- * @csspart note - Why no basemap is drawn under this view, in the bottom-right corner.
142
+ * @csspart note - Why no basemap is drawn under this view, in the bottom-right corner. It is in the
143
+ * page whether or not there is a note, and drawn only while there is one.
125
144
  * @csspart attribution - The tile server's credit, in the bottom-right corner while tiles are drawn.
126
145
  * @cssprop --mosaica-map-height - The map's height.
127
146
  * @cssprop --mosaica-map-bg - The canvas's background, behind the points and any basemap.
@@ -139,9 +158,9 @@ let MosaicaMap = (() => {
139
158
  let _budget_decorators;
140
159
  let _budget_initializers = [];
141
160
  let _budget_extraInitializers = [];
142
- let _clusterBudget_decorators;
143
- let _clusterBudget_initializers = [];
144
- let _clusterBudget_extraInitializers = [];
161
+ let _clusterSpacing_decorators;
162
+ let _clusterSpacing_initializers = [];
163
+ let _clusterSpacing_extraInitializers = [];
145
164
  let _tooltipFields_decorators;
146
165
  let _tooltipFields_initializers = [];
147
166
  let _tooltipFields_extraInitializers = [];
@@ -154,9 +173,9 @@ let MosaicaMap = (() => {
154
173
  let _palette_decorators;
155
174
  let _palette_initializers = [];
156
175
  let _palette_extraInitializers = [];
157
- let _clusterLevel_decorators;
158
- let _clusterLevel_initializers = [];
159
- let _clusterLevel_extraInitializers = [];
176
+ let _clusterLevels_decorators;
177
+ let _clusterLevels_initializers = [];
178
+ let _clusterLevels_extraInitializers = [];
160
179
  let _basemap_decorators;
161
180
  let _basemap_initializers = [];
162
181
  let _basemap_extraInitializers = [];
@@ -223,6 +242,9 @@ let MosaicaMap = (() => {
223
242
  let _noControls_decorators;
224
243
  let _noControls_initializers = [];
225
244
  let _noControls_extraInitializers = [];
245
+ let _quiet_decorators;
246
+ let _quiet_initializers = [];
247
+ let _quiet_extraInitializers = [];
226
248
  let _controlsCorner_decorators;
227
249
  let _controlsCorner_initializers = [];
228
250
  let _controlsCorner_extraInitializers = [];
@@ -247,12 +269,12 @@ let MosaicaMap = (() => {
247
269
  converter: { fromAttribute: (v) => (v ? v.split(/[\s,]+/).filter(Boolean) : []), toAttribute: (v) => v.join(' ') }
248
270
  })];
249
271
  _budget_decorators = [property({ type: Number })];
250
- _clusterBudget_decorators = [property({ type: Number, attribute: 'cluster-budget' })];
272
+ _clusterSpacing_decorators = [property({ type: Number, attribute: 'cluster-spacing' })];
251
273
  _tooltipFields_decorators = [property({ attribute: 'tooltip-fields' })];
252
274
  _titleField_decorators = [property({ attribute: 'title-field' })];
253
275
  _mode_decorators = [property({ reflect: true })];
254
276
  _palette_decorators = [property()];
255
- _clusterLevel_decorators = [property({ type: Number, attribute: 'cluster-level' })];
277
+ _clusterLevels_decorators = [property({ attribute: false })];
256
278
  _basemap_decorators = [property({ hasChanged: basemapChanged })];
257
279
  _ground_decorators = [property({ reflect: true })];
258
280
  _noPoints_decorators = [property({ type: Boolean, attribute: 'no-points', reflect: true })];
@@ -275,6 +297,7 @@ let MosaicaMap = (() => {
275
297
  _sizeMax_decorators = [property({ type: Number, attribute: 'size-max' })];
276
298
  _sizeScale_decorators = [property({ attribute: 'size-scale' })];
277
299
  _noControls_decorators = [property({ type: Boolean, attribute: 'no-controls' })];
300
+ _quiet_decorators = [property({ type: Boolean, reflect: true })];
278
301
  _controlsCorner_decorators = [property({ attribute: 'controls-corner' })];
279
302
  _hover_decorators = [state()];
280
303
  _hoveredArtifact_decorators = [state()];
@@ -283,12 +306,12 @@ let MosaicaMap = (() => {
283
306
  __esDecorate(this, null, _colourBy_decorators, { kind: "accessor", name: "colourBy", static: false, private: false, access: { has: obj => "colourBy" in obj, get: obj => obj.colourBy, set: (obj, value) => { obj.colourBy = value; } }, metadata: _metadata }, _colourBy_initializers, _colourBy_extraInitializers);
284
307
  __esDecorate(this, null, _layers_decorators, { kind: "accessor", name: "layers", static: false, private: false, access: { has: obj => "layers" in obj, get: obj => obj.layers, set: (obj, value) => { obj.layers = value; } }, metadata: _metadata }, _layers_initializers, _layers_extraInitializers);
285
308
  __esDecorate(this, null, _budget_decorators, { kind: "accessor", name: "budget", static: false, private: false, access: { has: obj => "budget" in obj, get: obj => obj.budget, set: (obj, value) => { obj.budget = value; } }, metadata: _metadata }, _budget_initializers, _budget_extraInitializers);
286
- __esDecorate(this, null, _clusterBudget_decorators, { kind: "accessor", name: "clusterBudget", static: false, private: false, access: { has: obj => "clusterBudget" in obj, get: obj => obj.clusterBudget, set: (obj, value) => { obj.clusterBudget = value; } }, metadata: _metadata }, _clusterBudget_initializers, _clusterBudget_extraInitializers);
309
+ __esDecorate(this, null, _clusterSpacing_decorators, { kind: "accessor", name: "clusterSpacing", static: false, private: false, access: { has: obj => "clusterSpacing" in obj, get: obj => obj.clusterSpacing, set: (obj, value) => { obj.clusterSpacing = value; } }, metadata: _metadata }, _clusterSpacing_initializers, _clusterSpacing_extraInitializers);
287
310
  __esDecorate(this, null, _tooltipFields_decorators, { kind: "accessor", name: "tooltipFields", static: false, private: false, access: { has: obj => "tooltipFields" in obj, get: obj => obj.tooltipFields, set: (obj, value) => { obj.tooltipFields = value; } }, metadata: _metadata }, _tooltipFields_initializers, _tooltipFields_extraInitializers);
288
311
  __esDecorate(this, null, _titleField_decorators, { kind: "accessor", name: "titleField", static: false, private: false, access: { has: obj => "titleField" in obj, get: obj => obj.titleField, set: (obj, value) => { obj.titleField = value; } }, metadata: _metadata }, _titleField_initializers, _titleField_extraInitializers);
289
312
  __esDecorate(this, null, _mode_decorators, { kind: "accessor", name: "mode", static: false, private: false, access: { has: obj => "mode" in obj, get: obj => obj.mode, set: (obj, value) => { obj.mode = value; } }, metadata: _metadata }, _mode_initializers, _mode_extraInitializers);
290
313
  __esDecorate(this, null, _palette_decorators, { kind: "accessor", name: "palette", static: false, private: false, access: { has: obj => "palette" in obj, get: obj => obj.palette, set: (obj, value) => { obj.palette = value; } }, metadata: _metadata }, _palette_initializers, _palette_extraInitializers);
291
- __esDecorate(this, null, _clusterLevel_decorators, { kind: "accessor", name: "clusterLevel", static: false, private: false, access: { has: obj => "clusterLevel" in obj, get: obj => obj.clusterLevel, set: (obj, value) => { obj.clusterLevel = value; } }, metadata: _metadata }, _clusterLevel_initializers, _clusterLevel_extraInitializers);
314
+ __esDecorate(this, null, _clusterLevels_decorators, { kind: "accessor", name: "clusterLevels", static: false, private: false, access: { has: obj => "clusterLevels" in obj, get: obj => obj.clusterLevels, set: (obj, value) => { obj.clusterLevels = value; } }, metadata: _metadata }, _clusterLevels_initializers, _clusterLevels_extraInitializers);
292
315
  __esDecorate(this, null, _basemap_decorators, { kind: "accessor", name: "basemap", static: false, private: false, access: { has: obj => "basemap" in obj, get: obj => obj.basemap, set: (obj, value) => { obj.basemap = value; } }, metadata: _metadata }, _basemap_initializers, _basemap_extraInitializers);
293
316
  __esDecorate(this, null, _ground_decorators, { kind: "accessor", name: "ground", static: false, private: false, access: { has: obj => "ground" in obj, get: obj => obj.ground, set: (obj, value) => { obj.ground = value; } }, metadata: _metadata }, _ground_initializers, _ground_extraInitializers);
294
317
  __esDecorate(this, null, _noPoints_decorators, { kind: "accessor", name: "noPoints", static: false, private: false, access: { has: obj => "noPoints" in obj, get: obj => obj.noPoints, set: (obj, value) => { obj.noPoints = value; } }, metadata: _metadata }, _noPoints_initializers, _noPoints_extraInitializers);
@@ -311,6 +334,7 @@ let MosaicaMap = (() => {
311
334
  __esDecorate(this, null, _sizeMax_decorators, { kind: "accessor", name: "sizeMax", static: false, private: false, access: { has: obj => "sizeMax" in obj, get: obj => obj.sizeMax, set: (obj, value) => { obj.sizeMax = value; } }, metadata: _metadata }, _sizeMax_initializers, _sizeMax_extraInitializers);
312
335
  __esDecorate(this, null, _sizeScale_decorators, { kind: "accessor", name: "sizeScale", static: false, private: false, access: { has: obj => "sizeScale" in obj, get: obj => obj.sizeScale, set: (obj, value) => { obj.sizeScale = value; } }, metadata: _metadata }, _sizeScale_initializers, _sizeScale_extraInitializers);
313
336
  __esDecorate(this, null, _noControls_decorators, { kind: "accessor", name: "noControls", static: false, private: false, access: { has: obj => "noControls" in obj, get: obj => obj.noControls, set: (obj, value) => { obj.noControls = value; } }, metadata: _metadata }, _noControls_initializers, _noControls_extraInitializers);
337
+ __esDecorate(this, null, _quiet_decorators, { kind: "accessor", name: "quiet", static: false, private: false, access: { has: obj => "quiet" in obj, get: obj => obj.quiet, set: (obj, value) => { obj.quiet = value; } }, metadata: _metadata }, _quiet_initializers, _quiet_extraInitializers);
314
338
  __esDecorate(this, null, _controlsCorner_decorators, { kind: "accessor", name: "controlsCorner", static: false, private: false, access: { has: obj => "controlsCorner" in obj, get: obj => obj.controlsCorner, set: (obj, value) => { obj.controlsCorner = value; } }, metadata: _metadata }, _controlsCorner_initializers, _controlsCorner_extraInitializers);
315
339
  __esDecorate(this, null, _hover_decorators, { kind: "accessor", name: "hover", static: false, private: false, access: { has: obj => "hover" in obj, get: obj => obj.hover, set: (obj, value) => { obj.hover = value; } }, metadata: _metadata }, _hover_initializers, _hover_extraInitializers);
316
340
  __esDecorate(this, null, _hoveredArtifact_decorators, { kind: "accessor", name: "hoveredArtifact", static: false, private: false, access: { has: obj => "hoveredArtifact" in obj, get: obj => obj.hoveredArtifact, set: (obj, value) => { obj.hoveredArtifact = value; } }, metadata: _metadata }, _hoveredArtifact_initializers, _hoveredArtifact_extraInitializers);
@@ -331,13 +355,26 @@ let MosaicaMap = (() => {
331
355
  outline: none;
332
356
  overflow: hidden;
333
357
  }
358
+ /* Draws nothing: a colour that follows the scheme, whose transition tells the map that the
359
+ ground it draws on has changed. It is inside the map's own tree, where no rule of the
360
+ page's or of an element holding the map reaches it, and its transition outranks the one
361
+ the theme takes away under reduced motion. */
362
+ #ground {
363
+ position: absolute;
364
+ width: 0;
365
+ height: 0;
366
+ pointer-events: none;
367
+ text-decoration-color: light-dark(#000, #fff);
368
+ transition: text-decoration-color 1ms !important;
369
+ }
334
370
  [part='canvas'] {
335
371
  position: absolute;
336
372
  inset: 0;
337
373
  }
338
- [part='canvas']:focus-visible {
339
- outline: 1.5px solid color-mix(in srgb, var(--_mosaica-accent) 40%, transparent);
340
- outline-offset: -1.5px;
374
+ /* Inside the canvas's edge, which the map clips at. deck's own canvas takes the focus on a click. */
375
+ [part='canvas']:focus-visible,
376
+ [part='canvas'] canvas:focus-visible {
377
+ outline-offset: -2px;
341
378
  }
342
379
  :host([mode='box']) [part='canvas'],
343
380
  :host([mode='lasso']) [part='canvas'] {
@@ -399,10 +436,14 @@ let MosaicaMap = (() => {
399
436
  background: var(--_mosaica-accent);
400
437
  color: var(--_mosaica-accent-ink);
401
438
  }
402
- /* The drawn region's count and clear button, on its top edge. */
439
+ /* The drawn region's count and clear button, above its top-left corner. It and the tooltip
440
+ are laid out at the map's own corner, so neither is squeezed against an edge, and moved
441
+ to their places once their sizes are known. */
403
442
  [part='region-tag'] {
404
443
  position: absolute;
405
444
  z-index: 3;
445
+ left: 0;
446
+ top: 0;
406
447
  display: flex;
407
448
  align-items: center;
408
449
  gap: 6px;
@@ -414,7 +455,6 @@ let MosaicaMap = (() => {
414
455
  font-size: 12px;
415
456
  color: var(--_mosaica-ink-2);
416
457
  white-space: nowrap;
417
- transform: translateY(calc(-100% - 4px));
418
458
  }
419
459
  [part='region-tag'] mosaica-count {
420
460
  font-size: inherit;
@@ -424,6 +464,7 @@ let MosaicaMap = (() => {
424
464
  font-weight: 600;
425
465
  }
426
466
  [part='region-tag'] button {
467
+ position: relative;
427
468
  width: 20px;
428
469
  height: 20px;
429
470
  display: grid;
@@ -431,21 +472,32 @@ let MosaicaMap = (() => {
431
472
  border-radius: 4px;
432
473
  color: var(--_mosaica-ink-2);
433
474
  }
475
+ /* The pointer's target is 24 px square around the button, which the tag does not grow for. */
476
+ [part='region-tag'] button::before {
477
+ content: '';
478
+ position: absolute;
479
+ inset: -2px;
480
+ }
434
481
  [part='region-tag'] button:hover {
435
482
  background: var(--_mosaica-surface-2);
436
483
  }
484
+ [part='region-tag'] button:focus-visible {
485
+ outline-offset: -2px;
486
+ }
437
487
  [part='tooltip'] {
438
488
  position: absolute;
439
489
  z-index: 4;
490
+ left: 0;
491
+ top: 0;
440
492
  pointer-events: none;
441
493
  padding: 8px 10px;
442
- max-width: 260px;
494
+ max-width: min(260px, 100%);
443
495
  background: var(--_mosaica-surface);
444
496
  border: 1px solid var(--_mosaica-line);
445
497
  border-radius: var(--_mosaica-radius);
446
498
  box-shadow: var(--_mosaica-shadow);
447
499
  font-size: 12px;
448
- transform: translate(14px, 14px);
500
+ overflow-wrap: anywhere;
449
501
  }
450
502
  [part='tooltip'] .t {
451
503
  font-weight: 500;
@@ -465,7 +517,8 @@ let MosaicaMap = (() => {
465
517
  justify-content: center;
466
518
  pointer-events: none;
467
519
  }
468
- [part='overlay'] [part='state'] {
520
+ /* A card while the state has words to show; otherwise it is kept, undrawn, for what it announces. */
521
+ [part='overlay'] [part='state']:not(.sr-only) {
469
522
  pointer-events: auto;
470
523
  min-height: 40px;
471
524
  padding: 6px 8px 6px 14px;
@@ -477,7 +530,7 @@ let MosaicaMap = (() => {
477
530
  font-weight: 500;
478
531
  color: var(--_mosaica-ink);
479
532
  }
480
- [part='overlay'] [part='state']:not(:has(button)) {
533
+ [part='overlay'] [part='state']:not(.sr-only, :has(button)) {
481
534
  padding-right: 14px;
482
535
  }
483
536
  [part='density-key'] {
@@ -509,11 +562,13 @@ let MosaicaMap = (() => {
509
562
  border: 0;
510
563
  background: color-mix(in srgb, var(--_mosaica-surface) 80%, transparent);
511
564
  }
565
+ /* The ramp's colours are what it shows, so forced colours leave them. */
512
566
  [part='density-key'] .ramp {
513
567
  display: block;
514
568
  width: 120px;
515
569
  height: 8px;
516
570
  border-radius: 2px;
571
+ forced-color-adjust: none;
517
572
  }
518
573
  [part='density-key'] .ends {
519
574
  display: grid;
@@ -545,14 +600,24 @@ let MosaicaMap = (() => {
545
600
  /** How many marks to aim for on screen. `0` keeps the store's budget, which starts at 250000. */
546
601
  get budget() { return this.#budget_accessor_storage; }
547
602
  set budget(value) { this.#budget_accessor_storage = value; }
548
- #clusterBudget_accessor_storage = (__runInitializers(this, _budget_extraInitializers), __runInitializers(this, _clusterBudget_initializers, 0));
603
+ #clusterSpacing_accessor_storage = (__runInitializers(this, _budget_extraInitializers), __runInitializers(this, _clusterSpacing_initializers, 0));
549
604
  /**
550
- * The most clusters a `nested` or `dag` layer is cut to (`Store.setClusterBudget`). `0` keeps the
551
- * store's setting; setting `0` after another value removes the limit, giving the finest cut.
605
+ * The side in CSS pixels of the square of the map each cluster is given (`Store.setClusterSpacing`),
606
+ * so the map shows about one cluster per square whatever its size: at 100, a map 1,200 by 800
607
+ * pixels shows 96. `0` keeps the store's setting; setting `0` after another value removes the
608
+ * limit.
609
+ *
610
+ * A `nested` or `dag` layer is cut to that many clusters. A `stacked` or `tiered` layer whose
611
+ * levels declare no zoom range and which `clusterLevels` leaves to follow the zoom is drawn at the
612
+ * finest level with no more than that many clusters in view and no tile in view holding the most a
613
+ * tile is served, so the world view shows a broad level and finer ones appear as the camera zooms
614
+ * in. Without a spacing only a full tile holds a level back, so such a layer is drawn at its
615
+ * finest level at every zoom until its tiles fill, world zoom included, and a `nested` or `dag`
616
+ * layer is drawn at its finest cut.
552
617
  */
553
- get clusterBudget() { return this.#clusterBudget_accessor_storage; }
554
- set clusterBudget(value) { this.#clusterBudget_accessor_storage = value; }
555
- #tooltipFields_accessor_storage = (__runInitializers(this, _clusterBudget_extraInitializers), __runInitializers(this, _tooltipFields_initializers, ''));
618
+ get clusterSpacing() { return this.#clusterSpacing_accessor_storage; }
619
+ set clusterSpacing(value) { this.#clusterSpacing_accessor_storage = value; }
620
+ #tooltipFields_accessor_storage = (__runInitializers(this, _clusterSpacing_extraInitializers), __runInitializers(this, _tooltipFields_initializers, ''));
556
621
  /**
557
622
  * The columns the hover tooltip shows under the point's title, separated by spaces or commas.
558
623
  * Only rendered columns arrive with the points, so another column shows nothing.
@@ -579,14 +644,17 @@ let MosaicaMap = (() => {
579
644
  */
580
645
  get palette() { return this.#palette_accessor_storage; }
581
646
  set palette(value) { this.#palette_accessor_storage = value; }
582
- #clusterLevel_accessor_storage = (__runInitializers(this, _palette_extraInitializers), __runInitializers(this, _clusterLevel_initializers, null));
647
+ #clusterLevels_accessor_storage = (__runInitializers(this, _palette_extraInitializers), __runInitializers(this, _clusterLevels_initializers, new Map()));
583
648
  /**
584
- * The level of a `stacked` or `tiered` layer at which clusters are coloured, labelled and
585
- * outlined. Unset, the coarsest level. A `nested` or `dag` layer ignores it.
649
+ * The level at which each `stacked` or `tiered` layer's clusters are coloured, labelled and
650
+ * outlined, keyed by layer name. A layer with no entry is drawn at the level that follows the zoom
651
+ * where its levels declare no zoom range (see `cluster-spacing`), and otherwise at the coarsest
652
+ * level the camera's zoom serves. A `nested` or `dag` layer ignores its entry. Set it as a
653
+ * property; it has no attribute.
586
654
  */
587
- get clusterLevel() { return this.#clusterLevel_accessor_storage; }
588
- set clusterLevel(value) { this.#clusterLevel_accessor_storage = value; }
589
- #basemap_accessor_storage = (__runInitializers(this, _clusterLevel_extraInitializers), __runInitializers(this, _basemap_initializers, null));
655
+ get clusterLevels() { return this.#clusterLevels_accessor_storage; }
656
+ set clusterLevels(value) { this.#clusterLevels_accessor_storage = value; }
657
+ #basemap_accessor_storage = (__runInitializers(this, _clusterLevels_extraInitializers), __runInitializers(this, _basemap_initializers, null));
590
658
  /**
591
659
  * What is drawn under the points. `world` is the server's outline of land, coastlines and country
592
660
  * borders, and needs a view with a map projection. `{tiles, attribution}` is a tile server's URL
@@ -604,8 +672,9 @@ let MosaicaMap = (() => {
604
672
  #ground_accessor_storage = (__runInitializers(this, _basemap_extraInitializers), __runInitializers(this, _ground_initializers, ''));
605
673
  /**
606
674
  * Whether the map's background is `light` or `dark`, for when it differs from the page's, such as
607
- * a light basemap on a dark page. Labels and density colours follow it; the toolbar and panels
608
- * follow the page. Unset, it follows the page's `color-scheme`.
675
+ * a light basemap on a dark page. The points' single colour, labels and density colours follow
676
+ * it; the toolbar and panels follow the page. Unset, it follows the page's `color-scheme`, and
677
+ * the system's where the page allows both.
609
678
  */
610
679
  get ground() { return this.#ground_accessor_storage; }
611
680
  set ground(value) { this.#ground_accessor_storage = value; }
@@ -751,7 +820,16 @@ let MosaicaMap = (() => {
751
820
  /** Hides the toolbar. */
752
821
  get noControls() { return this.#noControls_accessor_storage; }
753
822
  set noControls(value) { this.#noControls_accessor_storage = value; }
754
- #controlsCorner_accessor_storage = (__runInitializers(this, _noControls_extraInitializers), __runInitializers(this, _controlsCorner_initializers, 'top-left'));
823
+ #quiet_accessor_storage = (__runInitializers(this, _noControls_extraInitializers), __runInitializers(this, _quiet_initializers, false));
824
+ /**
825
+ * Leaves to another element what that element also says: the view's state over the canvas and
826
+ * the selected region's count are still shown, and neither is put in a live region. Set it when
827
+ * another element on the page says the same things, such as a status strip. What only the map
828
+ * knows is announced either way: the basemap's note, and a basemap it refuses.
829
+ */
830
+ get quiet() { return this.#quiet_accessor_storage; }
831
+ set quiet(value) { this.#quiet_accessor_storage = value; }
832
+ #controlsCorner_accessor_storage = (__runInitializers(this, _quiet_extraInitializers), __runInitializers(this, _controlsCorner_initializers, 'top-left'));
755
833
  /**
756
834
  * The corner the toolbar sits in: `top-left`, `top-right`, `bottom-left` or `bottom-right`.
757
835
  * Content in the same corner's slot sits below the toolbar in a top corner, and above it in a
@@ -794,7 +872,7 @@ let MosaicaMap = (() => {
794
872
  marks: 0,
795
873
  requests: 0,
796
874
  densityRequests: 0,
797
- encoding: 'uniform',
875
+ encoding: 'uniform|dark',
798
876
  basemap: [],
799
877
  view: { depth: 0, status: 'idle', stale: false, visible: 0, matched: 0, served: 0, provisional: 0 },
800
878
  region: null,
@@ -865,6 +943,7 @@ let MosaicaMap = (() => {
865
943
  super.connectedCallback();
866
944
  this.askPointColumns(this.resolvedStore);
867
945
  this.syncBasemap();
946
+ this.watchTooltipKeys();
868
947
  if (this.finalizeTimer) {
869
948
  clearTimeout(this.finalizeTimer);
870
949
  this.finalizeTimer = null;
@@ -892,6 +971,9 @@ let MosaicaMap = (() => {
892
971
  clearTimeout(this.describeTimer);
893
972
  this.describeTimer = null;
894
973
  }
974
+ this.watchTooltipKeys();
975
+ this.tagSized?.disconnect();
976
+ this.tagBox = null;
895
977
  this.stopFrameLoop();
896
978
  this.finalizeTimer = setTimeout(() => {
897
979
  this.finalizeTimer = null;
@@ -901,6 +983,9 @@ let MosaicaMap = (() => {
901
983
  }
902
984
  updated(changed) {
903
985
  this.ensureDeck();
986
+ this.watchTooltipKeys();
987
+ this.placeFloats();
988
+ this.actionFocus.keep(this.focusTarget);
904
989
  if (changed.has('measure')) {
905
990
  if (this.measure && this.isConnected)
906
991
  this.startFrameLoop();
@@ -915,7 +1000,7 @@ let MosaicaMap = (() => {
915
1000
  this.drawnBasemap.reset();
916
1001
  if (changed.has('basemap') || changed.has('ground'))
917
1002
  this.syncBasemap();
918
- const repaint = ['mode', 'drag', 'dragPolygon', 'basemap', 'ground', 'radius', 'clusterLevel', 'hoveredArtifact', 'noPoints', 'pointOpacity', 'density', 'densityColours', 'densityStrength', 'densityScale'];
1003
+ const repaint = ['mode', 'drag', 'dragPolygon', 'basemap', 'ground', 'radius', 'clusterLevels', 'hoveredArtifact', 'noPoints', 'pointOpacity', 'density', 'densityColours', 'densityStrength', 'densityScale'];
919
1004
  if (repaint.some((k) => changed.has(k)))
920
1005
  this.paint();
921
1006
  }
@@ -1017,6 +1102,17 @@ let MosaicaMap = (() => {
1017
1102
  return 'dark';
1018
1103
  return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1019
1104
  }
1105
+ /**
1106
+ * Draw for the other ground when the page's or the system's colour scheme changes, which the
1107
+ * colour of the `#ground` element follows: the basemap, the points and names, and the density key.
1108
+ */
1109
+ onGroundChange = () => {
1110
+ if (this.ground !== '')
1111
+ return;
1112
+ this.syncBasemap();
1113
+ this.paint();
1114
+ this.requestUpdate();
1115
+ };
1020
1116
  resetServerData() {
1021
1117
  // A hover's title is a record the server answered, and a pick names an item it served.
1022
1118
  this.hover = null;
@@ -1073,6 +1169,11 @@ let MosaicaMap = (() => {
1073
1169
  }
1074
1170
  /** A store adopted since the last update, to which the host's settings are not yet written. */
1075
1171
  unpushed = null;
1172
+ /** Keeps the focus on the map when the overlay's Retry goes with its state. */
1173
+ actionFocus = new ActionFocus();
1174
+ emptyAnswer = new EmptyAnswer();
1175
+ /** Whether the view is empty as a query's answer, which is announced, and not where a pan went. */
1176
+ emptyAnswered = false;
1076
1177
  /**
1077
1178
  * Write the host's settings to the store before the render: every one to a store adopted since
1078
1179
  * the last update, else those that changed. `layers` set or changed in this update fires
@@ -1082,7 +1183,11 @@ let MosaicaMap = (() => {
1082
1183
  */
1083
1184
  willUpdate(changed) {
1084
1185
  super.willUpdate(changed);
1186
+ this.actionFocus.note(this.renderRoot);
1085
1187
  const s = this.resolvedStore;
1188
+ const status = s?.get('status') ?? null;
1189
+ this.emptyAnswered = this.emptyAnswer.note(stateOf(status), status?.querying === true);
1190
+ this.hearRegion();
1086
1191
  if (!s)
1087
1192
  return;
1088
1193
  const all = this.unpushed === s;
@@ -1099,11 +1204,11 @@ let MosaicaMap = (() => {
1099
1204
  emit(this, 'mosaica-layerchange', { layers: this.layers });
1100
1205
  if (touched('budget') && this.budget > 0)
1101
1206
  s.setBudget(this.budget);
1102
- if (touched('clusterBudget')) {
1103
- if (this.clusterBudget > 0)
1104
- s.setClusterBudget(this.clusterBudget);
1105
- else if (!all && (changed.get('clusterBudget') ?? 0) > 0)
1106
- s.setClusterBudget(null);
1207
+ if (touched('clusterSpacing')) {
1208
+ if (this.clusterSpacing > 0)
1209
+ s.setClusterSpacing(this.clusterSpacing);
1210
+ else if (!all && (changed.get('clusterSpacing') ?? 0) > 0)
1211
+ s.setClusterSpacing(null);
1107
1212
  }
1108
1213
  if (touched('palette') && this.palette !== '')
1109
1214
  s.setPalette(this.palette);
@@ -1158,7 +1263,7 @@ let MosaicaMap = (() => {
1158
1263
  };
1159
1264
  const legend = s.get('legend');
1160
1265
  const clusterLayer = clusterLayerOf(legend.colourBy);
1161
- p.encoding = clusterLayer ? `cluster|${clusterLayer}` : encodingSignature(encodingOf(s.get('meta'), legend, colouringOf(s)));
1266
+ p.encoding = clusterLayer ? `cluster|${clusterLayer}` : encodingSignature(encodingOf(s.get('meta'), legend, colouringOf(s), this.scheme()));
1162
1267
  if (this.measure)
1163
1268
  this.measureStore(s);
1164
1269
  // Events for what arrived: the selected item's record, the opened artifact, the region's counts.
@@ -1257,15 +1362,16 @@ let MosaicaMap = (() => {
1257
1362
  return;
1258
1363
  this.probedArtifacts = artifacts;
1259
1364
  this.probedComposition = view.composition;
1260
- this.probe.cluster = this.clusterProbe(clusterLayerOf(s.get('legend').colourBy), artifacts, s.get('marks').bands);
1365
+ this.probe.cluster = this.clusterProbe(s.get('legend').colourBy, artifacts, s.get('marks').bands);
1261
1366
  }
1262
1367
  /** A sample of carried ordinals, each resolved through the table; see {@link MapProbe.cluster}. */
1263
- clusterProbe(clusterLayer, a, bands) {
1264
- const layer = clusterLayer ?? a.layers[0] ?? null;
1368
+ clusterProbe(colourBy, a, bands) {
1369
+ const clusterLayer = clusterLayerOf(colourBy);
1370
+ const layer = colourLayerOf(a, colourBy) ?? null;
1265
1371
  const rows = clusterLayer ? a.colourServed : a.served;
1266
1372
  const rowOrdinals = clusterLayer ? new Set(rows.map((x) => a.table.ordinalOf(x.layer, x.mosaicaId))) : a.servedOrdinals;
1267
1373
  const sample = [];
1268
- const level = this.drawnLevel();
1374
+ const level = this.levelOf(layer);
1269
1375
  let coloured = 0;
1270
1376
  if (layer) {
1271
1377
  for (const band of bands) {
@@ -1305,8 +1411,7 @@ let MosaicaMap = (() => {
1305
1411
  parent,
1306
1412
  views: VIEW,
1307
1413
  viewState: this.viewState,
1308
- // The map takes the keys itself, on its canvas, so deck's keyboard is off.
1309
- controller: { keyboard: false },
1414
+ controller: this.controller(),
1310
1415
  pickingRadius: MosaicaMap.PICK_RADIUS_PX,
1311
1416
  // Presses draw no pick, which would count against the press's time to be a click (`clickPick`).
1312
1417
  layerFilter: ({ isPicking, renderPass }) => !isPicking || renderPass.startsWith('picking:hover') || this.picking,
@@ -1331,7 +1436,9 @@ let MosaicaMap = (() => {
1331
1436
  return viewState;
1332
1437
  },
1333
1438
  onClick: (info) => this.onClick(this.clickPick(info)),
1334
- onHover: (info) => this.onHover(info)
1439
+ onHover: (info) => this.onHover(info),
1440
+ // The store's cluster count follows the canvas's size.
1441
+ onResize: () => this.pushView()
1335
1442
  });
1336
1443
  this.paint();
1337
1444
  }
@@ -1375,7 +1482,7 @@ let MosaicaMap = (() => {
1375
1482
  id: 'mosaica',
1376
1483
  store: s,
1377
1484
  slab: this.slab,
1378
- clusterLevel: this.drawnLevel(),
1485
+ clusterLevels: this.clusterLevels,
1379
1486
  selectedWorldXY: this.selectedWorldXY,
1380
1487
  openedArtifact: s.get('selection').artifact?.id ?? null,
1381
1488
  hoveredArtifact: this.hoveredArtifact,
@@ -1420,16 +1527,19 @@ let MosaicaMap = (() => {
1420
1527
  }
1421
1528
  }));
1422
1529
  }
1423
- this.deck.setProps({
1424
- layers,
1425
- viewState: this.viewState,
1426
- // In box and lasso modes the drag is the selection's; the wheel still zooms.
1427
- controller: this.mode === 'box' || this.mode === 'lasso' ? { dragPan: false, dragRotate: false } : true
1428
- });
1530
+ this.deck.setProps({ layers, viewState: this.viewState, controller: this.controller() });
1531
+ }
1532
+ /**
1533
+ * What deck's own input handling does. Its keyboard is off in every mode, since the map takes
1534
+ * the keys itself and a key would otherwise move the camera twice. In box and lasso modes the
1535
+ * drag is the selection's; the wheel still zooms.
1536
+ */
1537
+ controller() {
1538
+ return this.mode === 'box' || this.mode === 'lasso' ? { keyboard: false, dragPan: false, dragRotate: false } : { keyboard: false };
1429
1539
  }
1430
1540
  /**
1431
1541
  * The shapes a hover or click is resolved against ({@link contourShapes}), held until the served
1432
- * set, the fetched shapes, the level or the roster change. Each is the artifact's box until its
1542
+ * set, the fetched shapes, the levels or the roster change. Each is the artifact's box until its
1433
1543
  * shape is fetched; where boxes overlap, depth and the mark under the pointer decide.
1434
1544
  */
1435
1545
  contoursHeld = null;
@@ -1439,13 +1549,14 @@ let MosaicaMap = (() => {
1439
1549
  if (!a)
1440
1550
  return [];
1441
1551
  const meta = s?.get('meta') ?? null;
1442
- const level = this.drawnLevel() ?? null;
1552
+ const levels = this.drawnLevels();
1553
+ const key = levelsKey(levels);
1443
1554
  const opened = s?.get('selection').artifact?.id ?? null;
1444
1555
  const held = this.contoursHeld;
1445
- if (held && held.served === a.served && held.fetched === a.shapes && held.level === level && held.meta === meta && held.opened === opened)
1556
+ if (held && held.served === a.served && held.fetched === a.shapes && held.levels === key && held.meta === meta && held.opened === opened)
1446
1557
  return held.shapes;
1447
- const shapes = contourShapes(a, { level: level ?? undefined, meta, opened });
1448
- this.contoursHeld = { served: a.served, fetched: a.shapes, level, meta, opened, shapes };
1558
+ const shapes = contourShapes(a, { levels, meta, opened });
1559
+ this.contoursHeld = { served: a.served, fetched: a.shapes, levels: key, meta, opened, shapes };
1449
1560
  return shapes;
1450
1561
  }
1451
1562
  /** How far past a hovered shape's edge the pointer goes before the hover lets go, in pixels. */
@@ -1466,7 +1577,7 @@ let MosaicaMap = (() => {
1466
1577
  /** What decides what is under a pixel: the camera, the map's size, and the marks and names drawn and their sizes. */
1467
1578
  drawnKey() {
1468
1579
  const s = this.resolvedStore;
1469
- return [this.viewState, this.clientWidth, this.clientHeight, s?.get('marks'), s?.get('artifacts'), s?.get('legend'), s?.get('selection').artifact?.id, this.drawnLevel(), this.noPoints, this.radius];
1580
+ return [this.viewState, this.clientWidth, this.clientHeight, s?.get('marks'), s?.get('artifacts'), s?.get('legend'), s?.get('selection').artifact?.id, levelsKey(this.drawnLevels()), this.noPoints, this.radius];
1470
1581
  }
1471
1582
  /** Whether the map is running a pick of its own, which the deck's `layerFilter` lets draw. */
1472
1583
  picking = false;
@@ -1497,13 +1608,16 @@ let MosaicaMap = (() => {
1497
1608
  const at = slot ? this.slab.markAt(Number(slot[1]), info.index) : null;
1498
1609
  const artifacts = this.resolvedStore?.get('artifacts') ?? null;
1499
1610
  // The mark's own artifact, preferred where shapes interleave.
1500
- const own = at && artifacts ? artifactOfMark(at.band, at.i, artifacts, this.drawnLevel()) : null;
1611
+ const own = at && artifacts ? artifactOfMark(at.band, at.i, artifacts, this.levelOf(artifacts.layers[0])) : null;
1501
1612
  const world = this.worldAt(info.x, info.y);
1502
1613
  this.hoveredArtifact = picked.kind === 'artifact' ? picked.id : world ? this.artifactAt(world, own) : null;
1503
1614
  // The viewport carries no shapes; fetch the hovered one. `needShape` asks once per artifact.
1504
1615
  if (this.hoveredArtifact !== null)
1505
1616
  this.resolvedStore?.needShape(this.hoveredArtifact);
1506
- if (picked.kind !== 'mark' || this.pressed || picked.id === this.resolvedStore?.get('selection').item?.id) {
1617
+ // A tooltip Escape dismissed stays away while the pointer is on its mark.
1618
+ if (picked.kind !== 'mark' || picked.id !== this.dismissedHover)
1619
+ this.dismissedHover = null;
1620
+ if (picked.kind !== 'mark' || this.pressed || picked.id === this.dismissedHover || picked.id === this.resolvedStore?.get('selection').item?.id) {
1507
1621
  if (this.hover)
1508
1622
  this.hover = null;
1509
1623
  return;
@@ -1554,6 +1668,94 @@ let MosaicaMap = (() => {
1554
1668
  });
1555
1669
  }, HOVER_DESCRIBE_MS);
1556
1670
  }
1671
+ /** The mark whose tooltip Escape dismissed, while the pointer stays on it. */
1672
+ dismissedHover = null;
1673
+ /** Whether the document is listened to for the Escape that dismisses the tooltip. */
1674
+ tooltipKeys = false;
1675
+ /** Listen to the document for Escape while a tooltip shows, wherever the focus is. */
1676
+ watchTooltipKeys() {
1677
+ const on = this.hover !== null && this.isConnected;
1678
+ if (on === this.tooltipKeys)
1679
+ return;
1680
+ this.tooltipKeys = on;
1681
+ if (on)
1682
+ this.ownerDocument.addEventListener('keydown', this.onTooltipKey, true);
1683
+ else
1684
+ this.ownerDocument.removeEventListener('keydown', this.onTooltipKey, true);
1685
+ }
1686
+ /**
1687
+ * Escape dismisses the tooltip. Pressed in the map, or in an element whose shadow tree holds the
1688
+ * map, such as an explorer, it does nothing else: it is marked as taken, so the same press clears
1689
+ * no selection there. Pressed anywhere else on the page it is left as it was, for the page.
1690
+ */
1691
+ onTooltipKey = (e) => {
1692
+ if (e.key !== 'Escape' || !this.hover)
1693
+ return;
1694
+ this.dismissedHover = this.hover.id;
1695
+ this.hover = null;
1696
+ const path = e.composedPath();
1697
+ for (let at = this; at;) {
1698
+ if (path.includes(at)) {
1699
+ e.preventDefault();
1700
+ return;
1701
+ }
1702
+ const root = at.getRootNode();
1703
+ at = root instanceof ShadowRoot ? root.host : null;
1704
+ }
1705
+ };
1706
+ /** The region's tag as last measured: its size, the map's, and how far in the top-left corner's content starts. */
1707
+ tagBox = null;
1708
+ /** Measures the tag again when it or the map changes size, so that placing it as the camera moves reads no layout. */
1709
+ tagSized = typeof ResizeObserver === 'undefined'
1710
+ ? null
1711
+ : new ResizeObserver(() => {
1712
+ if (!this.tagBox)
1713
+ return;
1714
+ this.measureTag(this.tagBox.tag);
1715
+ this.placeTag();
1716
+ });
1717
+ measureTag(tag) {
1718
+ // No further left than the top-left corner's content starts, right of whatever the host keeps
1719
+ // over the map's left side.
1720
+ const inset = this.renderRoot.querySelector('.corner.top-left')?.offsetLeft ?? 0;
1721
+ const { width, height } = this.size;
1722
+ this.tagBox = { tag, size: [tag.offsetWidth, tag.offsetHeight], map: [width, height], inset };
1723
+ }
1724
+ /** Put the region's tag above its region's top-left corner, inside the map, from the sizes last measured. */
1725
+ placeTag() {
1726
+ const corner = this.regionCorner();
1727
+ if (!this.tagBox || !corner)
1728
+ return;
1729
+ const { tag, size, map, inset } = this.tagBox;
1730
+ const scale = 2 ** this.viewState.zoom;
1731
+ const [x, y] = [(corner[0] - this.viewState.target[0]) * scale + map[0] / 2, (corner[1] - this.viewState.target[1]) * scale + map[1] / 2];
1732
+ const [left, top] = keptInside([x, y - size[1] - REGION_TAG_GAP_PX], size, [inset, 0], map);
1733
+ tag.style.transform = `translate(${left}px, ${top}px)`;
1734
+ }
1735
+ /**
1736
+ * Move the tooltip beside the pointer and the region's tag above its region's corner, each kept
1737
+ * inside the map. The tooltip is measured each time it is drawn; the tag when it is first drawn
1738
+ * and when it or the map changes size.
1739
+ */
1740
+ placeFloats() {
1741
+ const tip = this.renderRoot.querySelector('[part="tooltip"]');
1742
+ if (tip && this.hover) {
1743
+ const { width, height } = this.size;
1744
+ const [left, top] = tooltipCorner([this.hover.x, this.hover.y], [tip.offsetWidth, tip.offsetHeight], [width, height]);
1745
+ tip.style.transform = `translate(${left}px, ${top}px)`;
1746
+ }
1747
+ const tag = this.renderRoot.querySelector('[part="region-tag"]');
1748
+ if (tag !== (this.tagBox?.tag ?? null)) {
1749
+ this.tagSized?.disconnect();
1750
+ this.tagBox = null;
1751
+ if (tag) {
1752
+ this.measureTag(tag);
1753
+ this.tagSized?.observe(tag);
1754
+ this.tagSized?.observe(this);
1755
+ }
1756
+ }
1757
+ this.placeTag();
1758
+ }
1557
1759
  /** The element that takes the map's focus: the canvas, a tab stop of its own. @internal */
1558
1760
  get focusTarget() {
1559
1761
  return this.renderRoot.querySelector('[part="canvas"]');
@@ -1744,38 +1946,57 @@ let MosaicaMap = (() => {
1744
1946
  this.requestUpdate();
1745
1947
  }
1746
1948
  /**
1747
- * The drawn region's tag at its top-left corner: how many items match inside it (or outside, for
1748
- * its complement) and a button that clears it. A region drawn as an artifact's outline has none.
1949
+ * The drawn region's tag: how many items match inside it (or outside, for its complement) and a
1950
+ * button that clears it. A region drawn as an artifact's outline has none. {@link placeFloats}
1951
+ * puts it above the region's top-left corner.
1749
1952
  */
1750
1953
  regionTag() {
1751
1954
  const region = this.resolvedStore?.get('region');
1752
- if (!region)
1753
- return nothing;
1754
- let corner = null;
1755
- if (this.regionWorld)
1756
- corner = [this.regionWorld[0], this.regionWorld[1]];
1757
- else if (this.regionPolygon && this.regionPolygon.length > 0) {
1758
- corner = [Math.min(...this.regionPolygon.map((p) => p[0])), Math.min(...this.regionPolygon.map((p) => p[1]))];
1759
- }
1760
- if (!corner)
1955
+ if (!region || !this.regionCorner())
1761
1956
  return nothing;
1762
- // The orthographic camera, y down: world units scale by 2^zoom about the target at the centre.
1763
- const { width, height } = this.size;
1764
- const scale = 2 ** this.viewState.zoom;
1765
- const [tx, ty] = this.viewState.target;
1766
- // Kept inside the map where the corner is off its top edge, and no further left than the
1767
- // top-left corner's content starts, right of whatever the host keeps over the map's left side.
1768
- const left = (corner[0] - tx) * scale + width / 2;
1769
- const top = Math.max(30, (corner[1] - ty) * scale + height / 2);
1770
- return html `<div part="region-tag" style=${`left:max(calc(var(--_mosaica-space) + var(--mosaica-map-inset-left, 0px)), ${left}px);top:${top}px`}>
1957
+ return html `<div part="region-tag">
1771
1958
  <mosaica-count .masked=${region.matched}></mosaica-count><span>${region.shape.outside ? 'outside' : 'inside'}</span>
1772
1959
  <button type="button" aria-label="Clear selection" title="Clear selection" @click=${() => this.select(null)}>${icon('close', 12, 1.4)}</button>
1773
1960
  </div>`;
1774
1961
  }
1775
- /** The level clusters are drawn at: `cluster-level` where set, else the top level of a levelled layer. */
1776
- drawnLevel() {
1962
+ /** The top-left corner of the drawn region's bounds, in world coordinates; `null` where no shape is drawn. */
1963
+ regionCorner() {
1964
+ if (this.regionWorld)
1965
+ return [this.regionWorld[0], this.regionWorld[1]];
1966
+ if (!this.regionPolygon || this.regionPolygon.length === 0)
1967
+ return null;
1968
+ return [Math.min(...this.regionPolygon.map((p) => p[0])), Math.min(...this.regionPolygon.map((p) => p[1]))];
1969
+ }
1970
+ /** What the map last announced of the selected region, and the shape it said it of. */
1971
+ regionHeard = '';
1972
+ heardShape = null;
1973
+ /**
1974
+ * Decide what the map announces of the selected region: how many items match inside it (or
1975
+ * outside), once the query that counts it has finished loading, since the figure a frame gives
1976
+ * first is an estimate. The words change only with the figure or the shape, so a count is
1977
+ * announced once, whatever else the map says in the meantime.
1978
+ */
1979
+ hearRegion() {
1980
+ const s = this.resolvedStore;
1981
+ const region = s?.get('region') ?? null;
1982
+ const shape = region?.shape ?? null;
1983
+ if (shape !== this.heardShape) {
1984
+ this.heardShape = shape;
1985
+ this.regionHeard = '';
1986
+ }
1987
+ if (!s || region?.status !== 'shown' || s.get('status').querying)
1988
+ return;
1989
+ this.regionHeard = `${formatMasked(region.matched)} ${region.shape.outside ? 'outside' : 'inside'} the selection`;
1990
+ }
1991
+ /** The level the layer `name` is drawn at: its entry in `clusterLevels`, else the level in view of a levelled layer. */
1992
+ levelOf(name) {
1777
1993
  const s = this.resolvedStore;
1778
- return drawnLevel(s?.get('meta') ?? null, s?.get('artifacts') ?? null, s?.get('legend').colourBy, this.clusterLevel ?? undefined);
1994
+ return drawnLevel(s?.get('meta') ?? null, s?.get('artifacts') ?? null, name ?? undefined, this.clusterLevels);
1995
+ }
1996
+ /** {@link levelOf} for each layer drawn. */
1997
+ drawnLevels() {
1998
+ const s = this.resolvedStore;
1999
+ return drawnLevels(s?.get('meta') ?? null, s?.get('artifacts') ?? null, this.clusterLevels);
1779
2000
  }
1780
2001
  /** Whether the map is drawn on a light or dark background now: `ground` if set, else the page's scheme. */
1781
2002
  get drawnGround() {
@@ -1866,8 +2087,9 @@ let MosaicaMap = (() => {
1866
2087
  return true;
1867
2088
  }
1868
2089
  onKey = (e) => {
1869
- // From the canvas's container, or deck's canvas inside it, which a click focuses.
1870
- if (e.target !== e.currentTarget && !(e.target instanceof HTMLCanvasElement))
2090
+ // From the canvas's container, or deck's canvas inside it, which a click focuses. A key that
2091
+ // has already dismissed the tooltip does no more.
2092
+ if (e.defaultPrevented || (e.target !== e.currentTarget && !(e.target instanceof HTMLCanvasElement)))
1871
2093
  return;
1872
2094
  const step = 64 / 2 ** this.viewState.zoom;
1873
2095
  const [x, y] = this.viewState.target;
@@ -1904,6 +2126,10 @@ let MosaicaMap = (() => {
1904
2126
  else if (this.pickedAt || this.selectedWorldXY) {
1905
2127
  this.clearPick();
1906
2128
  }
2129
+ else {
2130
+ // Nothing to clear, so the key is left for whatever holds the map, such as a dialog.
2131
+ return;
2132
+ }
1907
2133
  break;
1908
2134
  default:
1909
2135
  return;
@@ -1953,22 +2179,25 @@ let MosaicaMap = (() => {
1953
2179
  const scale = drawnDensityScale(this.densityScale);
1954
2180
  const middle = Math.round(densityCountAt(0.5, max, scale));
1955
2181
  const label = `Density in ${DENSITY_COLOUR_TITLES[colours]}, ${max > 0 ? `from 0 to ${countText(max)} items per cell on a ${scale} scale` : 'from fewer items to more'}`;
1956
- return html `<div part="density-key" role="img" aria-label=${label}>
2182
+ // What the largest figure counts is a tooltip on the figure and, for a reader of the key's
2183
+ // name, its description.
2184
+ const densest = 'The densest cell in view and around it';
2185
+ return html `<div part="density-key" role="img" aria-label=${label} aria-describedby=${max > 0 ? 'densest' : nothing}>
1957
2186
  <span class="caption">${max > 0 ? 'Items per cell' : 'Density'}</span>
1958
2187
  <span class="ramp" style=${`background:linear-gradient(to right, ${stops.join(', ')})`}></span>
1959
2188
  <span class="ends"
1960
2189
  >${max > 0
1961
- ? html `<span>0</span><span>${middle >= 1 && middle < max ? countText(middle) : ''}</span><span title="The densest cell in view and around it">${countText(max)}</span>`
2190
+ ? html `<span>0</span><span>${middle >= 1 && middle < max ? countText(middle) : ''}</span><span title=${densest}>${countText(max)}</span>`
1962
2191
  : html `<span>Fewer</span><span></span><span>More items</span>`}</span
1963
- >
2192
+ >${max > 0 ? html `<span id="densest" class="sr-only">${densest}</span>` : nothing}
1964
2193
  </div>`;
1965
2194
  }
1966
2195
  /** The note saying why no basemap is drawn under this view, and the tile server's credit. */
1967
2196
  basemapCorner() {
1968
2197
  const { note, attribution } = this.drawnBasemap;
1969
- if (!note && !attribution)
1970
- return nothing;
1971
- return html `${note ? html `<span part="note" role="status">${note}</span>` : nothing}${attribution ? html `<span part="attribution">${attribution}</span>` : nothing}`;
2198
+ // The note's element is there before its words are, so they are announced, and is not drawn
2199
+ // while it has none.
2200
+ return html `<span part="note" class=${note ? nothing : 'sr-only'} role="status">${note ?? nothing}</span>${attribution ? html `<span part="attribution">${attribution}</span>` : nothing}`;
1972
2201
  }
1973
2202
  render() {
1974
2203
  const status = this.resolvedStore?.get('status') ?? null;
@@ -1979,13 +2208,23 @@ let MosaicaMap = (() => {
1979
2208
  // strip's alone.
1980
2209
  const refresh = () => this.resolvedStore?.refresh();
1981
2210
  const nothingVisible = seesNothing(this.resolvedStore, state);
1982
- const overlay = nothingVisible
1983
- ? html `<div part="overlay"><span part="state" data-state="empty" data-visible="none">${NOTHING_VISIBLE}</span></div>`
2211
+ const ofView = nothingVisible
2212
+ ? { state, line: { dot: null, words: NOTHING_VISIBLE, action: nothing } }
1984
2213
  : state === 'refused' || state === 'unanswered' || state === 'busy' || state === 'expired' || state === 'empty' || (state === 'retrying' && status?.unreachable)
1985
- ? html `<div part="overlay">${renderState(state, status, { onRefresh: refresh, onRetry: refresh })}</div>`
1986
- : this.drawnBasemap.refusal
1987
- ? html `<div part="overlay">${refusedState(this.drawnBasemap.refusal, 'basemap')}</div>`
1988
- : nothing;
2214
+ ? { state, line: stateLine(state, status, { onRefresh: refresh, onRetry: refresh }) }
2215
+ : null;
2216
+ const said = ofView ?? (this.drawnBasemap.refusal ? { state: 'refused', line: refusedLine(this.drawnBasemap.refusal, 'basemap') } : null);
2217
+ // One template whatever the state, so the live element is there before its words are and
2218
+ // each change of them is announced. With no state to show it is not drawn. Nothing in view
2219
+ // is announced as a query's answer; where a pan went, it is shown and not said. `quiet`
2220
+ // leaves the view's states to another element; a basemap's refusal is the map's alone to
2221
+ // say. The selected region's count has a live element of its own, so it is said once,
2222
+ // whatever the state does.
2223
+ const unsaid = ofView !== null && (this.quiet || (state === 'empty' && !nothingVisible && !this.emptyAnswered));
2224
+ const overlay = html `<div part="overlay">
2225
+ <span part="state" class=${said ? nothing : 'sr-only'} data-state=${said?.state ?? state} data-visible=${nothingVisible ? 'none' : nothing}>${liveLine(said?.line ?? NO_LINE, unsaid ? UNSAID : null)}</span>
2226
+ <span class="sr-only" role="status">${this.quiet ? nothing : this.regionHeard}</span>
2227
+ </div>`;
1989
2228
  const controls = this.noControls
1990
2229
  ? nothing
1991
2230
  : html `<div part="controls" role="toolbar" aria-label="Map tools">
@@ -2014,12 +2253,14 @@ let MosaicaMap = (() => {
2014
2253
  part="canvas"
2015
2254
  tabindex=${this.canvasTab}
2016
2255
  role="application"
2017
- aria-label=${this.getAttribute('aria-label') ?? 'map: arrow keys pan, + and - zoom'}
2256
+ aria-label=${this.getAttribute('aria-label') ?? 'Map'}
2257
+ aria-describedby="keys"
2018
2258
  @keydown=${this.onKey}
2019
2259
  @pointerleave=${() => {
2020
2260
  // deck reports picks only while the pointer is over the canvas, so clear the hover here.
2021
2261
  if (this.hover)
2022
2262
  this.hover = null;
2263
+ this.dismissedHover = null;
2023
2264
  }}
2024
2265
  @pointerdown=${{ handleEvent: this.onPointerDown, capture: true }}
2025
2266
  @pointermove=${{ handleEvent: this.onPointerMove, capture: true }}
@@ -2027,11 +2268,13 @@ let MosaicaMap = (() => {
2027
2268
  @pointercancel=${{ handleEvent: this.onPointerUp, capture: true }}
2028
2269
  @lostpointercapture=${{ handleEvent: this.onPointerUp, capture: true }}
2029
2270
  ></div>
2271
+ <span id="keys" class="sr-only">Arrow keys pan, plus and minus zoom, Escape clears the selection.</span>
2272
+ <span id="ground" aria-hidden="true" @transitionrun=${this.onGroundChange}></span>
2030
2273
  ${overlay}
2031
2274
  ${this.regionTag()}
2032
2275
  ${corners.filter((c) => c !== this.controlsCorner).map(corner)}
2033
2276
  ${this.hover
2034
- ? html `<div part="tooltip" style=${`left:${this.hover.x}px;top:${this.hover.y}px`}>
2277
+ ? html `<div part="tooltip" role="tooltip">
2035
2278
  <slot name="tooltip"><div class="t">${this.hover.title}</div>${this.hover.lines.length > 0 ? html `<div class="s">${this.hover.lines.join(' · ')}</div>` : nothing}</slot>
2036
2279
  </div>`
2037
2280
  : nothing}`;
@@ -2043,6 +2286,33 @@ export { MosaicaMap };
2043
2286
  function countText(n) {
2044
2287
  return n.toLocaleString('en-GB', { maximumFractionDigits: 0 });
2045
2288
  }
2289
+ /** The overlay's line while the map has no state to show. */
2290
+ const NO_LINE = { dot: null, words: nothing, action: nothing };
2291
+ /** How far the tooltip sits from the pointer along each axis, in pixels. */
2292
+ const TOOLTIP_GAP_PX = 14;
2293
+ /** The gap between the region's tag and its region, in pixels. */
2294
+ const REGION_TAG_GAP_PX = 4;
2295
+ /**
2296
+ * A box's top-left corner, moved as little as keeps the box between `min` and `max` on each axis.
2297
+ * A box larger than that room starts at `min`.
2298
+ *
2299
+ * @internal
2300
+ */
2301
+ export function keptInside(at, size, min, max) {
2302
+ const axis = (i) => Math.max(min[i], Math.min(at[i], max[i] - size[i]));
2303
+ return [axis(0), axis(1)];
2304
+ }
2305
+ /**
2306
+ * The tooltip's top-left corner for a pointer at `pointer` on a map of size `map`: below and right
2307
+ * of the pointer, on the pointer's other side along an axis where it would cross the map's far
2308
+ * edge, and inside the map either way.
2309
+ *
2310
+ * @internal
2311
+ */
2312
+ export function tooltipCorner(pointer, size, map) {
2313
+ const axis = (i) => (pointer[i] + TOOLTIP_GAP_PX + size[i] > map[i] ? pointer[i] - TOOLTIP_GAP_PX - size[i] : pointer[i] + TOOLTIP_GAP_PX);
2314
+ return keptInside([axis(0), axis(1)], size, [0, 0], map);
2315
+ }
2046
2316
  /** The density scale drawn: `linear` where set, else `log`. */
2047
2317
  export function drawnDensityScale(scale) {
2048
2318
  return scale === 'linear' ? 'linear' : 'log';