@mosaicajs/components 0.3.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 +116 -37
  17. package/dist/explorer.js +476 -210
  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 +47 -16
  27. package/dist/item-card.js +241 -108
  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 +1837 -833
  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/explorer.js CHANGED
@@ -37,9 +37,9 @@ import { css, html, nothing, svg } from 'lit';
37
37
  import { property, state } from 'lit/decorators.js';
38
38
  import { repeat } from 'lit/directives/repeat.js';
39
39
  import { CATEGORY_PALETTES, DEFAULT_DENSITY_CELL_PX, DEFAULT_DENSITY_SCALE, DENSITY_CELL_SIZES, RAMPS, cellDepth, nearestStop } from '@mosaicajs/deck';
40
- import { CLUSTER_PREFIX, PALETTES, WORLD_SIZE, activeCount, artifactName, colourLayers, emptyDraft } from '@mosaicajs/client';
41
- import { hasOneLayout, sizesPoints, topLevelServed } from '@mosaicajs/client/internal';
42
- import { DENSITY_COLOUR_TITLES, colourOfFraction, css as rgb, hexOf } from '@mosaicajs/deck/internal';
40
+ import { CLUSTER_PREFIX, PALETTES, WORLD_SIZE, activeCount, artifactName, clustersInView, colourLayers, emptyDraft } from '@mosaicajs/client';
41
+ import { follows, hasOneLayout, levelFollowsZoom, levelOfLayer, sizesPoints } from '@mosaicajs/client/internal';
42
+ import { DENSITY_COLOUR_TITLES, clusterLayerOf, colourOfFraction, css as rgb, hexOf } from '@mosaicajs/deck/internal';
43
43
  import { MosaicaElement, columnCaption, emit, shortCount } from './base.js';
44
44
  import { placeCallout } from './callout.js';
45
45
  import { colouringOf, setColouring, sizingOf, watchChoices } from './colouring.js';
@@ -110,6 +110,8 @@ const px = (n) => n.toLocaleString('en-GB', { maximumFractionDigits: 1 });
110
110
  const fill = (v, min, max) => `--fill:${(Math.min(1, Math.max(0, (v - min) / (max - min))) * 100).toFixed(1)}%`;
111
111
  /** A ramp as a CSS gradient, low to high. */
112
112
  const rampGradient = (ramp, reverse) => `linear-gradient(to right, ${Array.from({ length: 8 }, (_, k) => rgb(colourOfFraction(k / 7, ramp, reverse))).join(', ')})`;
113
+ /** The tick on the entry chosen in a list of choices. */
114
+ const tick = (chosen) => (chosen ? html `<span class="tick">${icon('check', 11, 1.6)}</span>` : nothing);
113
115
  /** The Density choices in the Layers popover, in order. */
114
116
  const DENSITY_MODES = [
115
117
  { mode: 'none', label: 'None' },
@@ -124,7 +126,16 @@ const CALLOUT_SIZE = { width: 300, height: 220 };
124
126
  const CALLOUT_REST_MS = 200;
125
127
  /** The shortest a callout is made to fit beside its point; what does not fit scrolls inside it. */
126
128
  const CALLOUT_MIN_HEIGHT = 180;
127
- /** The narrow layout's tabs, each opening a sheet, drawn where its panel is. */
129
+ /** The controls Tab stops at. */
130
+ const FOCUSABLE = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
131
+ /** Whether `node` is `container` or inside it, through slots and shadow roots. */
132
+ function within(node, container) {
133
+ for (let at = node; at; at = (at instanceof Element ? at.assignedSlot : null) ?? at.parentNode ?? (at instanceof ShadowRoot ? at.host : null))
134
+ if (at === container)
135
+ return true;
136
+ return false;
137
+ }
138
+ /** The narrow layout's buttons, each opening a sheet, drawn where its panel is. */
128
139
  const TABS = [
129
140
  { sheet: 'filters', icon: 'filter', label: 'Fields', panel: 'filters' },
130
141
  { sheet: 'layers', icon: 'layers', label: 'Layers', panel: 'legend' },
@@ -153,7 +164,20 @@ const TABS = [
153
164
  * HTML.
154
165
  *
155
166
  * Below 1000 px the explorer uses the overlay layout whatever `layout` says. At 720 px or less it
156
- * opens its panels as sheets from a tab bar.
167
+ * opens its panels as sheets from a bar of buttons under the map, and a picked item's card opens
168
+ * in the Item sheet. A sheet that reaches up over the map's tools makes the map behind it inert
169
+ * until it closes.
170
+ *
171
+ * The status strip speaks for the explorer: its map and its selection card are set `quiet`, so a
172
+ * change is announced once. Where you put something other than a `<mosaica-status>` in the
173
+ * `status` slot, they announce for themselves.
174
+ *
175
+ * An `aria-label` on the explorer names its map, so a page with several explorers can tell them
176
+ * apart.
177
+ *
178
+ * A dataset title, a view's name or a layer's name too long for its line is cut with an ellipsis
179
+ * and keeps all of it in its `title`. Set `--mosaica-long-text: normal` to wrap them. The narrow
180
+ * layout wraps them itself.
157
181
  *
158
182
  * @summary A complete map application in a single element.
159
183
  * @tagname mosaica-explorer
@@ -163,7 +187,7 @@ const TABS = [
163
187
  * @slot filters - Replaces the filter panel.
164
188
  * @slot selection - Replaces the selection panel, shown while a region is selected.
165
189
  * @slot detail - Replaces the item and artifact cards, in the card beside the point.
166
- * @slot status - Replaces the status strip drawn on the map.
190
+ * @slot status - Replaces the status strip: on the map, or under it in the narrow layout.
167
191
  * @slot tooltip - Replaces the map's hover tooltip.
168
192
  * @slot top-right - Content in the map's top-right corner, above the selection's card.
169
193
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-viewchange']>} mosaica-viewchange - The map's camera moved.
@@ -176,11 +200,11 @@ const TABS = [
176
200
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-selectchange']>} mosaica-selectchange - A selection was drawn, changed or cleared, or its counts arrived.
177
201
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-layerchange']>} mosaica-layerchange - The layers chosen changed.
178
202
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-colourchange']>} mosaica-colourchange - Colour by changed, in the Layers popover or by a card's paint button.
179
- * @fires {CustomEvent<MosaicaEventDetails['mosaica-levelchange']>} mosaica-levelchange - The level a layer is coloured and labelled at changed.
203
+ * @fires {CustomEvent<MosaicaEventDetails['mosaica-levelchange']>} mosaica-levelchange - The level one layer is counted, coloured and labelled at changed.
180
204
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-displaychange']>} mosaica-displaychange - A setting in the Points or Density section changed.
181
205
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-sizechange']>} mosaica-sizechange - Size by, the size range or the scale changed in the Points section.
182
206
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-budgetchange']>} mosaica-budgetchange - Most points was let go at a new number.
183
- * @fires {CustomEvent<MosaicaEventDetails['mosaica-clusterbudgetchange']>} mosaica-clusterbudgetchange - Most clusters was let go at a new number.
207
+ * @fires {CustomEvent<MosaicaEventDetails['mosaica-clusterspacingchange']>} mosaica-clusterspacingchange - Cluster density was let go at a new spacing.
184
208
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-fold']>} mosaica-fold - A field card was folded to one line or opened.
185
209
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-valuecolour']>} mosaica-valuecolour - Colours were chosen or reset for values of a category, on a field card or in Edit colours.
186
210
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-clustercolour']>} mosaica-clustercolour - Colours were chosen or reset for clusters of a layer, on a field card or in Edit colours.
@@ -217,8 +241,9 @@ const TABS = [
217
241
  * @csspart points-toggle - The Points switch, with `aria-checked`.
218
242
  * @csspart most-points - The Most points slider.
219
243
  * @csspart most-points-value - The number of points it stands at, such as "250K".
220
- * @csspart most-clusters - The Most clusters slider, while a `nested` or `dag` layer is drawn.
221
- * @csspart most-clusters-value - The number of clusters it stands at, such as "1K".
244
+ * @csspart cluster-density - The Cluster density slider, while a `nested` or `dag` layer is drawn, or a
245
+ * levelled layer whose level follows the zoom is drawn or coloured by.
246
+ * @csspart cluster-density-value - How many clusters it gives the map at its size, such as "96".
222
247
  * @csspart size-by - The Size by button, naming the column the points are sized by or None, with
223
248
  * `aria-expanded` while its menu is open.
224
249
  * @csspart size-menu - The Size by menu, while it is open.
@@ -235,7 +260,8 @@ const TABS = [
235
260
  * `aria-expanded` while its menu is open.
236
261
  * @csspart colour-menu - The Colour by menu, while it is open.
237
262
  * @csspart colour-option - An entry in the Colour by menu, with `data-value` (empty for None,
238
- * `cluster:<layer>@<level>` for one level of a levelled layer) and `aria-checked`.
263
+ * `cluster:<layer>@<level>` for one level of a levelled layer, `cluster:<layer>@` for Follow zoom
264
+ * on one whose levels declare no zoom range) and `aria-checked`.
239
265
  * @csspart palette - The Palette button, while the points are coloured by a layer or a category.
240
266
  * @csspart palette-menu - The Palette menu, while it is open.
241
267
  * @csspart palette-option - A palette in that menu, with `data-value` and `aria-checked`.
@@ -259,9 +285,10 @@ const TABS = [
259
285
  * @csspart detail - The item or artifact card in the map's top-right corner, used when the picked
260
286
  * item or artifact has no position on the map, such as after following an item into another
261
287
  * view.
262
- * @csspart sheet - The open sheet, in the narrow layout.
288
+ * @csspart sheet - The open sheet, over the map in the narrow layout.
263
289
  * @csspart strip-row - The full-width status strip, in the narrow layout.
264
- * @csspart tabs - The tab bar, in the narrow layout.
290
+ * @csspart tabs - The bar of buttons that open the sheets, in the narrow layout, each with
291
+ * `data-sheet` and `aria-expanded`.
265
292
  * @csspart map-<part> - A part of the inner `<mosaica-map>`, such as `map-controls`.
266
293
  * @csspart status-<part> - A part of an inner `<mosaica-status>`.
267
294
  * @csspart view-picker-<part> - A part of the inner `<mosaica-view-picker>`.
@@ -323,15 +350,15 @@ let MosaicaExplorer = (() => {
323
350
  let _budgetMax_decorators;
324
351
  let _budgetMax_initializers = [];
325
352
  let _budgetMax_extraInitializers = [];
326
- let _clusterBudget_decorators;
327
- let _clusterBudget_initializers = [];
328
- let _clusterBudget_extraInitializers = [];
329
- let _clusterBudgetMin_decorators;
330
- let _clusterBudgetMin_initializers = [];
331
- let _clusterBudgetMin_extraInitializers = [];
332
- let _clusterBudgetMax_decorators;
333
- let _clusterBudgetMax_initializers = [];
334
- let _clusterBudgetMax_extraInitializers = [];
353
+ let _clusterSpacing_decorators;
354
+ let _clusterSpacing_initializers = [];
355
+ let _clusterSpacing_extraInitializers = [];
356
+ let _clusterSpacingMin_decorators;
357
+ let _clusterSpacingMin_initializers = [];
358
+ let _clusterSpacingMin_extraInitializers = [];
359
+ let _clusterSpacingMax_decorators;
360
+ let _clusterSpacingMax_initializers = [];
361
+ let _clusterSpacingMax_extraInitializers = [];
335
362
  let _basemap_decorators;
336
363
  let _basemap_initializers = [];
337
364
  let _basemap_extraInitializers = [];
@@ -422,9 +449,9 @@ let MosaicaExplorer = (() => {
422
449
  let _tabFocus_decorators;
423
450
  let _tabFocus_initializers = [];
424
451
  let _tabFocus_extraInitializers = [];
425
- let _level_decorators;
426
- let _level_initializers = [];
427
- let _level_extraInitializers = [];
452
+ let _levels_decorators;
453
+ let _levels_initializers = [];
454
+ let _levels_extraInitializers = [];
428
455
  let _pinned_decorators;
429
456
  let _pinned_initializers = [];
430
457
  let _pinned_extraInitializers = [];
@@ -457,9 +484,9 @@ let MosaicaExplorer = (() => {
457
484
  _budget_decorators = [property({ type: Number })];
458
485
  _budgetMin_decorators = [property({ type: Number, attribute: 'budget-min' })];
459
486
  _budgetMax_decorators = [property({ type: Number, attribute: 'budget-max' })];
460
- _clusterBudget_decorators = [property({ type: Number, attribute: 'cluster-budget' })];
461
- _clusterBudgetMin_decorators = [property({ type: Number, attribute: 'cluster-budget-min' })];
462
- _clusterBudgetMax_decorators = [property({ type: Number, attribute: 'cluster-budget-max' })];
487
+ _clusterSpacing_decorators = [property({ type: Number, attribute: 'cluster-spacing' })];
488
+ _clusterSpacingMin_decorators = [property({ type: Number, attribute: 'cluster-spacing-min' })];
489
+ _clusterSpacingMax_decorators = [property({ type: Number, attribute: 'cluster-spacing-max' })];
463
490
  _basemap_decorators = [property({ hasChanged: basemapChanged })];
464
491
  _noPoints_decorators = [property({ type: Boolean, attribute: 'no-points' })];
465
492
  _radius_decorators = [property({ type: Number })];
@@ -490,7 +517,7 @@ let MosaicaExplorer = (() => {
490
517
  _densityColoursOpen_decorators = [state()];
491
518
  _sheet_decorators = [state()];
492
519
  _tabFocus_decorators = [state()];
493
- _level_decorators = [state()];
520
+ _levels_decorators = [state()];
494
521
  _pinned_decorators = [state()];
495
522
  _layersOpen_decorators = [state()];
496
523
  _compact_decorators = [state()];
@@ -510,9 +537,9 @@ let MosaicaExplorer = (() => {
510
537
  __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);
511
538
  __esDecorate(this, null, _budgetMin_decorators, { kind: "accessor", name: "budgetMin", static: false, private: false, access: { has: obj => "budgetMin" in obj, get: obj => obj.budgetMin, set: (obj, value) => { obj.budgetMin = value; } }, metadata: _metadata }, _budgetMin_initializers, _budgetMin_extraInitializers);
512
539
  __esDecorate(this, null, _budgetMax_decorators, { kind: "accessor", name: "budgetMax", static: false, private: false, access: { has: obj => "budgetMax" in obj, get: obj => obj.budgetMax, set: (obj, value) => { obj.budgetMax = value; } }, metadata: _metadata }, _budgetMax_initializers, _budgetMax_extraInitializers);
513
- __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);
514
- __esDecorate(this, null, _clusterBudgetMin_decorators, { kind: "accessor", name: "clusterBudgetMin", static: false, private: false, access: { has: obj => "clusterBudgetMin" in obj, get: obj => obj.clusterBudgetMin, set: (obj, value) => { obj.clusterBudgetMin = value; } }, metadata: _metadata }, _clusterBudgetMin_initializers, _clusterBudgetMin_extraInitializers);
515
- __esDecorate(this, null, _clusterBudgetMax_decorators, { kind: "accessor", name: "clusterBudgetMax", static: false, private: false, access: { has: obj => "clusterBudgetMax" in obj, get: obj => obj.clusterBudgetMax, set: (obj, value) => { obj.clusterBudgetMax = value; } }, metadata: _metadata }, _clusterBudgetMax_initializers, _clusterBudgetMax_extraInitializers);
540
+ __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);
541
+ __esDecorate(this, null, _clusterSpacingMin_decorators, { kind: "accessor", name: "clusterSpacingMin", static: false, private: false, access: { has: obj => "clusterSpacingMin" in obj, get: obj => obj.clusterSpacingMin, set: (obj, value) => { obj.clusterSpacingMin = value; } }, metadata: _metadata }, _clusterSpacingMin_initializers, _clusterSpacingMin_extraInitializers);
542
+ __esDecorate(this, null, _clusterSpacingMax_decorators, { kind: "accessor", name: "clusterSpacingMax", static: false, private: false, access: { has: obj => "clusterSpacingMax" in obj, get: obj => obj.clusterSpacingMax, set: (obj, value) => { obj.clusterSpacingMax = value; } }, metadata: _metadata }, _clusterSpacingMax_initializers, _clusterSpacingMax_extraInitializers);
516
543
  __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);
517
544
  __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);
518
545
  __esDecorate(this, null, _radius_decorators, { kind: "accessor", name: "radius", static: false, private: false, access: { has: obj => "radius" in obj, get: obj => obj.radius, set: (obj, value) => { obj.radius = value; } }, metadata: _metadata }, _radius_initializers, _radius_extraInitializers);
@@ -543,7 +570,7 @@ let MosaicaExplorer = (() => {
543
570
  __esDecorate(this, null, _densityColoursOpen_decorators, { kind: "accessor", name: "densityColoursOpen", static: false, private: false, access: { has: obj => "densityColoursOpen" in obj, get: obj => obj.densityColoursOpen, set: (obj, value) => { obj.densityColoursOpen = value; } }, metadata: _metadata }, _densityColoursOpen_initializers, _densityColoursOpen_extraInitializers);
544
571
  __esDecorate(this, null, _sheet_decorators, { kind: "accessor", name: "sheet", static: false, private: false, access: { has: obj => "sheet" in obj, get: obj => obj.sheet, set: (obj, value) => { obj.sheet = value; } }, metadata: _metadata }, _sheet_initializers, _sheet_extraInitializers);
545
572
  __esDecorate(this, null, _tabFocus_decorators, { kind: "accessor", name: "tabFocus", static: false, private: false, access: { has: obj => "tabFocus" in obj, get: obj => obj.tabFocus, set: (obj, value) => { obj.tabFocus = value; } }, metadata: _metadata }, _tabFocus_initializers, _tabFocus_extraInitializers);
546
- __esDecorate(this, null, _level_decorators, { kind: "accessor", name: "level", static: false, private: false, access: { has: obj => "level" in obj, get: obj => obj.level, set: (obj, value) => { obj.level = value; } }, metadata: _metadata }, _level_initializers, _level_extraInitializers);
573
+ __esDecorate(this, null, _levels_decorators, { kind: "accessor", name: "levels", static: false, private: false, access: { has: obj => "levels" in obj, get: obj => obj.levels, set: (obj, value) => { obj.levels = value; } }, metadata: _metadata }, _levels_initializers, _levels_extraInitializers);
547
574
  __esDecorate(this, null, _pinned_decorators, { kind: "accessor", name: "pinned", static: false, private: false, access: { has: obj => "pinned" in obj, get: obj => obj.pinned, set: (obj, value) => { obj.pinned = value; } }, metadata: _metadata }, _pinned_initializers, _pinned_extraInitializers);
548
575
  __esDecorate(this, null, _layersOpen_decorators, { kind: "accessor", name: "layersOpen", static: false, private: false, access: { has: obj => "layersOpen" in obj, get: obj => obj.layersOpen, set: (obj, value) => { obj.layersOpen = value; } }, metadata: _metadata }, _layersOpen_initializers, _layersOpen_extraInitializers);
549
576
  __esDecorate(this, null, _compact_decorators, { kind: "accessor", name: "compact", static: false, private: false, access: { has: obj => "compact" in obj, get: obj => obj.compact, set: (obj, value) => { obj.compact = value; } }, metadata: _metadata }, _compact_initializers, _compact_extraInitializers);
@@ -598,6 +625,13 @@ let MosaicaExplorer = (() => {
598
625
  height: 100%;
599
626
  min-height: 320px;
600
627
  }
628
+ /* The narrow layout's map takes the height its bars leave. */
629
+ @container explorer (max-width: 720px) {
630
+ mosaica-map,
631
+ ::slotted(mosaica-map) {
632
+ min-height: 0;
633
+ }
634
+ }
601
635
  /* What the map draws at its top-left, its tools or a region's tag, sits right of the card; in
602
636
  the narrow layout there is no card. */
603
637
  @container explorer (width > 720px) {
@@ -694,11 +728,17 @@ let MosaicaExplorer = (() => {
694
728
  min-width: 0;
695
729
  }
696
730
  .title {
731
+ display: block;
732
+ margin: 0;
697
733
  font-size: 15px;
698
734
  font-weight: 600;
735
+ letter-spacing: 0;
736
+ text-transform: none;
737
+ color: var(--_mosaica-ink);
699
738
  overflow: hidden;
700
739
  text-overflow: ellipsis;
701
- white-space: nowrap;
740
+ white-space: var(--_mosaica-long-text);
741
+ overflow-wrap: anywhere;
702
742
  }
703
743
  .compact .title {
704
744
  font-size: 14px;
@@ -777,6 +817,10 @@ let MosaicaExplorer = (() => {
777
817
  linear-gradient(transparent, color-mix(in srgb, var(--_mosaica-ink) 16%, transparent)) bottom / 100% 10px no-repeat scroll,
778
818
  var(--_mosaica-surface);
779
819
  }
820
+ [part='layers-popover']:focus-visible,
821
+ [part='sheet']:focus-visible {
822
+ outline-offset: -2px;
823
+ }
780
824
  [part='layers-popover'] mosaica-layer-picker,
781
825
  [part='sheet'] mosaica-layer-picker {
782
826
  display: block;
@@ -857,6 +901,7 @@ let MosaicaExplorer = (() => {
857
901
  }
858
902
  [part='pin'][aria-pressed='true'] {
859
903
  background: var(--_mosaica-surface-3);
904
+ box-shadow: inset 0 -2px 0 var(--_mosaica-accent);
860
905
  color: var(--_mosaica-ink);
861
906
  }
862
907
  /* The selection keeps its heading, counts and actions; only its list of marks scrolls, so the
@@ -903,18 +948,22 @@ let MosaicaExplorer = (() => {
903
948
  padding: 10px 16px;
904
949
  }
905
950
  @container explorer (max-width: 720px) {
951
+ /* The map keeps a floor under its height; a frame too short for it and the bars scrolls.
952
+ A long title or name wraps here, in the explorer and in the elements it holds. */
906
953
  [part='frame'],
907
954
  [part='frame'].docked {
955
+ --mosaica-long-text: normal;
956
+ --_mosaica-long-text: normal;
908
957
  grid-template-columns: minmax(0, 1fr);
909
- grid-template-rows: minmax(0, 1fr) auto;
958
+ grid-template-rows: minmax(120px, 1fr) auto auto;
959
+ overflow: hidden auto;
910
960
  }
911
961
  [part='frame'].titled {
912
- grid-template-rows: auto minmax(0, 1fr) auto;
962
+ grid-template-rows: auto minmax(120px, 1fr) auto auto;
913
963
  }
914
964
  [part='sidebar'],
915
965
  [part='panel'],
916
966
  .layers,
917
- .right,
918
967
  .callouts,
919
968
  .in-map-strip {
920
969
  display: none;
@@ -938,30 +987,37 @@ let MosaicaExplorer = (() => {
938
987
  font-size: 11px;
939
988
  font-weight: 500;
940
989
  }
941
- [part='tabs'] button[aria-selected='true'] {
990
+ [part='tabs'] button[aria-expanded='true'] {
991
+ box-shadow: inset 0 2px 0 var(--_mosaica-accent);
942
992
  color: var(--_mosaica-ink);
943
993
  font-weight: 600;
944
994
  }
995
+ /* The sheet opens over the map alone, so the strip and the bar under it stay in reach. A
996
+ short map gives the sheet all of its height. */
945
997
  [part='sheet'] {
946
998
  position: absolute;
947
999
  left: 0;
948
1000
  right: 0;
949
- bottom: 56px;
950
- max-height: 70%;
951
- overflow-y: auto;
1001
+ bottom: 0;
1002
+ max-height: max(80%, min(100%, 240px));
1003
+ display: flex;
1004
+ flex-direction: column;
1005
+ overflow: hidden;
952
1006
  background: var(--_mosaica-surface);
953
1007
  border-top: 1px solid var(--_mosaica-line);
954
1008
  border-radius: 12px 12px 0 0;
955
1009
  box-shadow: var(--_mosaica-shadow);
956
1010
  z-index: 6;
957
1011
  }
958
- /* The sheet takes focus as it opens so a keyboard lands in it; that is not a focus to show. */
959
- [part='sheet']:focus-visible {
960
- outline: none;
1012
+ /* What the sheet holds scrolls between its handle and its footer, so nothing goes under the footer. */
1013
+ .sheet-body {
1014
+ flex: 0 1 auto;
1015
+ min-height: 0;
1016
+ overflow-y: auto;
1017
+ overscroll-behavior: contain;
961
1018
  }
962
1019
  .sheet-footer {
963
- position: sticky;
964
- bottom: 0;
1020
+ flex: none;
965
1021
  display: flex;
966
1022
  gap: 10px;
967
1023
  padding: 10px 16px;
@@ -978,7 +1034,7 @@ let MosaicaExplorer = (() => {
978
1034
  }
979
1035
  [part='sheet']::before {
980
1036
  content: '';
981
- display: block;
1037
+ flex: none;
982
1038
  width: 36px;
983
1039
  height: 4px;
984
1040
  border-radius: 2px;
@@ -997,8 +1053,18 @@ let MosaicaExplorer = (() => {
997
1053
  box-shadow: none;
998
1054
  border-radius: 0;
999
1055
  border-width: 1px 0 0;
1000
- height: 44px;
1001
- overflow-x: auto;
1056
+ min-height: 44px;
1057
+ }
1058
+ }
1059
+ @media (forced-colors: active) {
1060
+ [part='layers-toggle'] .on {
1061
+ forced-color-adjust: none;
1062
+ background: Highlight;
1063
+ }
1064
+ [part='tabs'] button[aria-expanded='true'] {
1065
+ forced-color-adjust: none;
1066
+ background: Highlight;
1067
+ color: HighlightText;
1002
1068
  }
1003
1069
  }
1004
1070
  `
@@ -1070,22 +1136,24 @@ let MosaicaExplorer = (() => {
1070
1136
  /** The highest value of the Most points slider. */
1071
1137
  get budgetMax() { return this.#budgetMax_accessor_storage; }
1072
1138
  set budgetMax(value) { this.#budgetMax_accessor_storage = value; }
1073
- #clusterBudget_accessor_storage = (__runInitializers(this, _budgetMax_extraInitializers), __runInitializers(this, _clusterBudget_initializers, 1_000));
1139
+ #clusterSpacing_accessor_storage = (__runInitializers(this, _budgetMax_extraInitializers), __runInitializers(this, _clusterSpacing_initializers, 100));
1074
1140
  /**
1075
- * The most clusters a `nested` or `dag` layer is cut to; `0` removes the limit. Most clusters in
1076
- * the Layers popover changes it.
1141
+ * The side in CSS pixels of the square of the map each cluster is given, as for `<mosaica-map>`'s
1142
+ * `cluster-spacing`: at 100, a map 1,200 by 800 pixels cuts a `nested` or `dag` layer to 96
1143
+ * clusters, and a level that follows the zoom may have 96 in view. `0` removes the limit. Most
1144
+ * clusters in the Layers popover changes it.
1077
1145
  */
1078
- get clusterBudget() { return this.#clusterBudget_accessor_storage; }
1079
- set clusterBudget(value) { this.#clusterBudget_accessor_storage = value; }
1080
- #clusterBudgetMin_accessor_storage = (__runInitializers(this, _clusterBudget_extraInitializers), __runInitializers(this, _clusterBudgetMin_initializers, 10));
1081
- /** The lowest value of the Most clusters slider. */
1082
- get clusterBudgetMin() { return this.#clusterBudgetMin_accessor_storage; }
1083
- set clusterBudgetMin(value) { this.#clusterBudgetMin_accessor_storage = value; }
1084
- #clusterBudgetMax_accessor_storage = (__runInitializers(this, _clusterBudgetMin_extraInitializers), __runInitializers(this, _clusterBudgetMax_initializers, 10_000));
1085
- /** The highest value of the Most clusters slider. */
1086
- get clusterBudgetMax() { return this.#clusterBudgetMax_accessor_storage; }
1087
- set clusterBudgetMax(value) { this.#clusterBudgetMax_accessor_storage = value; }
1088
- #basemap_accessor_storage = (__runInitializers(this, _clusterBudgetMax_extraInitializers), __runInitializers(this, _basemap_initializers, null));
1146
+ get clusterSpacing() { return this.#clusterSpacing_accessor_storage; }
1147
+ set clusterSpacing(value) { this.#clusterSpacing_accessor_storage = value; }
1148
+ #clusterSpacingMin_accessor_storage = (__runInitializers(this, _clusterSpacing_extraInitializers), __runInitializers(this, _clusterSpacingMin_initializers, 50));
1149
+ /** The smallest spacing Cluster density offers, at its More end. */
1150
+ get clusterSpacingMin() { return this.#clusterSpacingMin_accessor_storage; }
1151
+ set clusterSpacingMin(value) { this.#clusterSpacingMin_accessor_storage = value; }
1152
+ #clusterSpacingMax_accessor_storage = (__runInitializers(this, _clusterSpacingMin_extraInitializers), __runInitializers(this, _clusterSpacingMax_initializers, 400));
1153
+ /** The largest spacing Cluster density offers, at its Fewer end. */
1154
+ get clusterSpacingMax() { return this.#clusterSpacingMax_accessor_storage; }
1155
+ set clusterSpacingMax(value) { this.#clusterSpacingMax_accessor_storage = value; }
1156
+ #basemap_accessor_storage = (__runInitializers(this, _clusterSpacingMax_extraInitializers), __runInitializers(this, _basemap_initializers, null));
1089
1157
  /**
1090
1158
  * What is drawn under the points, as for `<mosaica-map>`'s `basemap`. The attribute takes `world`
1091
1159
  * and `none`. Unset, a view with a map projection gets the world outline and any other view gets
@@ -1200,7 +1268,7 @@ let MosaicaExplorer = (() => {
1200
1268
  get pointsDragged() { return this.#pointsDragged_accessor_storage; }
1201
1269
  set pointsDragged(value) { this.#pointsDragged_accessor_storage = value; }
1202
1270
  #clustersDragged_accessor_storage = (__runInitializers(this, _pointsDragged_extraInitializers), __runInitializers(this, _clustersDragged_initializers, null));
1203
- /** Where Most clusters is being dragged, before it is let go. @internal */
1271
+ /** The cluster spacing Cluster density is being dragged to, before it is let go. @internal */
1204
1272
  get clustersDragged() { return this.#clustersDragged_accessor_storage; }
1205
1273
  set clustersDragged(value) { this.#clustersDragged_accessor_storage = value; }
1206
1274
  #densityColoursOpen_accessor_storage = (__runInitializers(this, _clustersDragged_extraInitializers), __runInitializers(this, _densityColoursOpen_initializers, false));
@@ -1212,14 +1280,33 @@ let MosaicaExplorer = (() => {
1212
1280
  get sheet() { return this.#sheet_accessor_storage; }
1213
1281
  set sheet(value) { this.#sheet_accessor_storage = value; }
1214
1282
  #tabFocus_accessor_storage = (__runInitializers(this, _sheet_extraInitializers), __runInitializers(this, _tabFocus_initializers, null));
1215
- /** The narrow layout's tab focused last, which keeps the tab list's one place in the tab order. */
1283
+ /** The narrow layout's button focused last, which keeps the bar's one place in the tab order. */
1216
1284
  get tabFocus() { return this.#tabFocus_accessor_storage; }
1217
1285
  set tabFocus(value) { this.#tabFocus_accessor_storage = value; }
1218
- #level_accessor_storage = (__runInitializers(this, _tabFocus_extraInitializers), __runInitializers(this, _level_initializers, null));
1219
- /** The level a layer is coloured and labelled at, chosen on its card or in Colour by. @internal */
1220
- get level() { return this.#level_accessor_storage; }
1221
- set level(value) { this.#level_accessor_storage = value; }
1222
- #pinned_accessor_storage = (__runInitializers(this, _level_extraInitializers), __runInitializers(this, _pinned_initializers, []));
1286
+ /**
1287
+ * What the selection's card shows, as last seen: the record or the refusal the store holds, or
1288
+ * the map's broken pick; null for no card. Picking the same item again brings a new record.
1289
+ */
1290
+ cardSeen = (__runInitializers(this, _tabFocus_extraInitializers), null);
1291
+ /** Where focus was before the update being drawn: in the open sheet, elsewhere in the explorer, or outside it. */
1292
+ focusBefore = null;
1293
+ /** Whether the host put something other than a status strip in the `status` slot, so no strip speaks. */
1294
+ get stripReplaced() {
1295
+ const given = Array.from(this.children).filter((el) => el.slot === 'status');
1296
+ return given.length > 0 && !given.some((el) => el.localName === 'mosaica-status' || el.querySelector('mosaica-status') !== null);
1297
+ }
1298
+ /** What the host put in the `status` slot changed, and with it whether a strip speaks. */
1299
+ onStatusSlot = () => this.requestUpdate();
1300
+ /** What the status region says: the card that opened last. */
1301
+ announced = '';
1302
+ #levels_accessor_storage = __runInitializers(this, _levels_initializers, new Map());
1303
+ /**
1304
+ * The level each layer is counted, coloured and labelled at, chosen on its card or in Colour by,
1305
+ * keyed by layer name. A layer with no entry takes its default level. @internal
1306
+ */
1307
+ get levels() { return this.#levels_accessor_storage; }
1308
+ set levels(value) { this.#levels_accessor_storage = value; }
1309
+ #pinned_accessor_storage = (__runInitializers(this, _levels_extraInitializers), __runInitializers(this, _pinned_initializers, []));
1223
1310
  /** The cards kept open by Pin, oldest first. @internal */
1224
1311
  get pinned() { return this.#pinned_accessor_storage; }
1225
1312
  set pinned(value) { this.#pinned_accessor_storage = value; }
@@ -1294,6 +1381,61 @@ let MosaicaExplorer = (() => {
1294
1381
  }
1295
1382
  super.onStoreChange();
1296
1383
  }
1384
+ static get observedAttributes() {
1385
+ return [...super.observedAttributes, 'aria-label'];
1386
+ }
1387
+ /** An `aria-label` on the explorer is passed to its map. */
1388
+ attributeChangedCallback(name, old, value) {
1389
+ super.attributeChangedCallback(name, old, value);
1390
+ if (name === 'aria-label')
1391
+ this.requestUpdate();
1392
+ }
1393
+ /**
1394
+ * As the selection's card comes, changes or goes. The wide layouts say which card opened in the
1395
+ * status region. The narrow layout opens the Item sheet for it, and closes that sheet with it; a
1396
+ * card that arrives while the viewer's focus is in another sheet leaves that sheet open. Sheets
1397
+ * are the narrow layout's: growing wider closes the open one, and growing narrow with a card
1398
+ * open opens the Item sheet.
1399
+ */
1400
+ willUpdate(changed) {
1401
+ super.willUpdate(changed);
1402
+ const sheet = this.renderRoot?.querySelector('[part="sheet"]') ?? null;
1403
+ this.focusBefore = sheet && within(this.shadowRoot?.activeElement ?? null, sheet) ? 'sheet' : this.holdsFocus() ? 'explorer' : null;
1404
+ const card = this.cardSubject();
1405
+ const fresh = card !== this.cardSeen;
1406
+ this.cardSeen = card;
1407
+ if (fresh)
1408
+ this.announced = card && !this.narrow ? `${this.cardName(this.liveKey())}, card opened` : '';
1409
+ if (!this.narrow) {
1410
+ this.sheet = null;
1411
+ return;
1412
+ }
1413
+ if (card && (fresh || changed.has('narrow'))) {
1414
+ if (this.sheet === null || this.focusBefore === 'explorer')
1415
+ this.sheet = 'detail';
1416
+ }
1417
+ else if (fresh && this.sheet === 'detail')
1418
+ this.sheet = null;
1419
+ }
1420
+ /** Whether focus is in the explorer: in its own tree, or in an element the host put in a slot. */
1421
+ holdsFocus() {
1422
+ const active = this.getRootNode().activeElement;
1423
+ return active !== null && this.contains(active);
1424
+ }
1425
+ /** What the selection's card shows, or null where it has nothing to show or `panels` leaves the card out. */
1426
+ cardSubject() {
1427
+ if (!this.has('detail'))
1428
+ return null;
1429
+ const sel = this.resolvedStore?.get('selection');
1430
+ const broken = this.map?.lastPick?.kind === 'broken' ? this.map.lastPick : null;
1431
+ const artifact = sel?.artifact ?? sel?.artifactRefusal ?? null;
1432
+ return (this.showsArtifact() ? artifact : (sel?.item ?? sel?.itemRefusal ?? artifact)) ?? broken;
1433
+ }
1434
+ /** Whether the selection has a card to show: a click that found nothing shows none, and a broken pick shows its fault. */
1435
+ hasDetail() {
1436
+ const selection = this.resolvedStore?.get('selection');
1437
+ return Boolean(selection?.item || selection?.artifact || selection?.artifactRefusal || selection?.itemRefusal || this.map?.lastPick?.kind === 'broken');
1438
+ }
1297
1439
  connectedCallback() {
1298
1440
  super.connectedCallback();
1299
1441
  if (typeof ResizeObserver === 'undefined')
@@ -1350,6 +1492,19 @@ let MosaicaExplorer = (() => {
1350
1492
  if (group && !path.includes(group) && !(editor && path.includes(editor)))
1351
1493
  this.closeLayers();
1352
1494
  };
1495
+ /**
1496
+ * Focus going from the Layers button, its popover or their menus to somewhere else in the page
1497
+ * closes the popover. The Edit colours dialog it opens is not somewhere else.
1498
+ */
1499
+ onLayersFocusOut = (e) => {
1500
+ const to = e.relatedTarget;
1501
+ if (!this.layersOpen || !(to instanceof Node))
1502
+ return;
1503
+ const editor = this.renderRoot.querySelector('mosaica-colour-editor');
1504
+ if (within(to, e.currentTarget) || (editor && within(to, editor)))
1505
+ return;
1506
+ this.closeLayers();
1507
+ };
1353
1508
  openLayers() {
1354
1509
  this.layersOpen = true;
1355
1510
  document.addEventListener('pointerdown', this.onOutside, true);
@@ -1357,12 +1512,12 @@ let MosaicaExplorer = (() => {
1357
1512
  closeLayers() {
1358
1513
  this.layersOpen = false;
1359
1514
  this.menu = null;
1515
+ this.densityColoursOpen = false;
1360
1516
  document.removeEventListener('pointerdown', this.onOutside, true);
1361
1517
  }
1362
1518
  render() {
1363
1519
  const s = this.resolvedStore;
1364
1520
  const region = s?.get('region') ?? null;
1365
- const selection = s?.get('selection');
1366
1521
  const meta = s?.get('meta') ?? null;
1367
1522
  const artifacts = s?.get('artifacts');
1368
1523
  const layersOn = artifacts?.layers.length ?? 0;
@@ -1371,9 +1526,8 @@ let MosaicaExplorer = (() => {
1371
1526
  const narrow = this.narrow;
1372
1527
  // Short of room, the docked layout takes the overlay's form.
1373
1528
  const floating = this.layout === 'overlay' || compact;
1374
- const level = this.level;
1375
- // A click that found nothing shows no card; a broken pick shows its fault.
1376
- const hasDetail = Boolean(selection?.item || selection?.artifact || selection?.artifactRefusal || selection?.itemRefusal || this.map?.lastPick?.kind === 'broken');
1529
+ const levels = this.levels;
1530
+ const hasDetail = this.hasDetail();
1377
1531
  // The detail shows whichever changed last.
1378
1532
  const showArtifact = this.showsArtifact();
1379
1533
  const detail = this.liveCard(showArtifact);
@@ -1386,43 +1540,50 @@ let MosaicaExplorer = (() => {
1386
1540
  const shown = meta?.views.find((v) => v.id === s?.get('view').id) ?? (meta?.views.length === 1 ? meta.views[0] : undefined);
1387
1541
  // A name that is only the view's id says nothing to a reader.
1388
1542
  const viewName = shown && shown.displayName !== shown.id ? shown.displayName : '';
1389
- const viewText = (cls) => (pickersShown || !viewName ? nothing : html `<span part="view-name" class=${cls}>${viewName}</span>`);
1543
+ // The heading is the dataset's title, or without one the view's name.
1390
1544
  const names = this.datasetTitle
1391
- ? html `<div class="names"><span part="dataset-title" class="title">${this.datasetTitle}</span><div class="sub">${pickers}${viewText('')}</div></div>`
1392
- : html `<div class="names">${pickers}${viewText('title')}</div>`;
1545
+ ? html `<div class="names"><h2 part="dataset-title" class="title" title=${this.datasetTitle}>${this.datasetTitle}</h2><div class="sub">${pickers}${pickersShown || !viewName ? nothing : html `<span part="view-name">${viewName}</span>`}</div></div>`
1546
+ : html `<div class="names">${pickers}${pickersShown || !viewName ? nothing : html `<h2 part="view-name" class="title" title=${viewName}>${viewName}</h2>`}</div>`;
1393
1547
  const named = pickersShown || viewName !== '' || this.datasetTitle !== '';
1394
1548
  const layersPanel = html `<slot name="layers"><mosaica-layer-picker exportparts=${FORWARD['layer-picker']}></mosaica-layer-picker></slot>`;
1395
- const filters = html `<slot name="filters"><mosaica-filter-panel exportparts=${FORWARD['filter-panel']} pinned=${this.pinnedFilters || nothing} ?compact=${compact || narrow} .clusterLevel=${level}></mosaica-filter-panel></slot>`;
1396
- const selectionPanel = this.has('selection') && region ? html `<slot name="selection"><mosaica-selection exportparts=${FORWARD.selection}></mosaica-selection></slot>` : nothing;
1549
+ const filters = html `<slot name="filters"><mosaica-filter-panel exportparts=${FORWARD['filter-panel']} pinned=${this.pinnedFilters || nothing} ?compact=${compact || narrow} .clusterLevels=${levels}></mosaica-filter-panel></slot>`;
1550
+ // The status strip speaks for the map and the selection card, unless the host replaced it with something else.
1551
+ const quiet = !this.stripReplaced;
1552
+ const selectionPanel = this.has('selection') && region ? html `<slot name="selection"><mosaica-selection exportparts=${FORWARD.selection} ?quiet=${quiet}></mosaica-selection></slot>` : nothing;
1397
1553
  const selectionCard = selectionPanel === nothing ? nothing : html `<div part="selection-card" class="card">${selectionPanel}</div>`;
1398
1554
  const head = html `<div class="head" ?hidden=${!named}>${names}</div>`;
1399
1555
  // The card beside the point, where the map holds a position for what is selected; else the
1400
1556
  // card stands in the top-right corner.
1401
1557
  const anchor = this.liveAnchor(showArtifact);
1402
1558
  const detailInColumn = this.has('detail') && hasDetail && anchor === null;
1403
- const right = html `<div slot="top-right" class="right"><slot name="top-right"></slot>${selectionCard}${detailInColumn ? html `<div part="detail" class="card">${detail}</div>` : nothing}</div>`;
1404
- const docked = html `<aside part="sidebar" aria-label="Explorer panels">
1559
+ // The narrow layout shows the selection and the detail in the Item sheet.
1560
+ const right = html `<div slot="top-right" class="right"><slot name="top-right"></slot>${narrow ? nothing : selectionCard}${detailInColumn && !narrow ? html `<div part="detail" class="card">${detail}</div>` : nothing}</div>`;
1561
+ // The column is named for what it holds.
1562
+ const columnName = this.has('filters') ? 'Fields' : 'View';
1563
+ const docked = html `<aside part="sidebar" aria-label=${columnName}>
1405
1564
  ${this.has('toolbar') ? head : nothing}
1406
1565
  ${this.has('filters') ? filters : nothing}
1407
1566
  </aside>`;
1408
1567
  const panel = this.has('toolbar') || this.has('filters')
1409
- ? html `<div part="panel" class="card" role="region" aria-label="Explorer panels">${this.has('toolbar') ? head : nothing}${this.has('filters') ? filters : nothing}</div>`
1568
+ ? html `<div part="panel" class="card" role="region" aria-label=${columnName}>${this.has('toolbar') ? head : nothing}${this.has('filters') ? filters : nothing}</div>`
1410
1569
  : nothing;
1411
1570
  const layersButton = this.has('legend')
1412
- ? html `<div class="layers" slot=${compact ? 'bottom-left' : 'top-left'} @keydown=${this.onLayersKey}>
1571
+ ? html `<div class="layers" slot=${compact ? 'bottom-left' : 'top-left'} @keydown=${this.onLayersKey} @focusout=${this.onLayersFocusOut}>
1413
1572
  <div class="group"><button part="layers-toggle" type="button" aria-label=${`Layers and display, ${layersOn} layers on`} aria-expanded=${this.layersOpen ? 'true' : 'false'}
1414
- aria-controls="layers-popover" ?data-on=${layersOn > 0} @click=${() => (this.layersOpen ? this.closeLayers() : this.openLayers())}>${icon('layers', 16, 1.2)}${layersOn > 0 ? html `<span class="on"></span>` : nothing}</button></div>
1573
+ aria-controls=${this.layersOpen ? 'layers-popover' : nothing} ?data-on=${layersOn > 0} @click=${() => (this.layersOpen ? this.closeLayers() : this.openLayers())}>${icon('layers', 16, 1.2)}${layersOn > 0 ? html `<span class="on"></span>` : nothing}</button></div>
1415
1574
  ${this.layersOpen
1416
- ? html `<div part="layers-popover" id="layers-popover" class="card" role="dialog" aria-label="Layers and display" @pointerdown=${this.onPopoverPress}>${this.displaySection()}${layersPanel}</div>${this.choiceMenu()}`
1575
+ ? html `<div part="layers-popover" id="layers-popover" class="card" role="dialog" aria-label="Layers and display" tabindex="-1" @pointerdown=${this.onPopoverPress}>${this.displaySection()}${layersPanel}</div>${this.choiceMenu()}`
1417
1576
  : nothing}
1418
1577
  </div>`
1419
1578
  : nothing;
1420
1579
  const tabs = TABS.filter((t) => this.has(t.panel));
1421
- // One tab is in the page's tab order: the one focused last, else the open sheet's, else the first.
1580
+ // One button is in the page's tab order: the one focused last, else the open sheet's, else the first.
1422
1581
  const reachable = tabs.find((t) => t.sheet === this.tabFocus) ?? tabs.find((t) => t.sheet === this.sheet) ?? tabs[0];
1423
- const tab = ({ sheet, icon: ic, label }) => html `<button type="button" role="tab" id=${`tab-${sheet}`} data-sheet=${sheet} tabindex=${reachable?.sheet === sheet ? '0' : '-1'}
1424
- aria-selected=${this.sheet === sheet ? 'true' : 'false'} aria-controls=${this.sheet === sheet ? 'sheet' : nothing}
1425
- @click=${() => {
1582
+ const tab = ({ sheet, icon: ic, label }) => html `<button type="button" id=${`opens-${sheet}`} data-sheet=${sheet} tabindex=${reachable?.sheet === sheet ? '0' : '-1'}
1583
+ aria-expanded=${this.sheet === sheet ? 'true' : 'false'} aria-controls=${this.sheet === sheet ? 'sheet' : nothing}
1584
+ @click=${(e) => {
1585
+ // A press does not focus a button in every browser, and the sheet takes the focus the explorer holds.
1586
+ e.currentTarget.focus();
1426
1587
  this.tabFocus = sheet;
1427
1588
  this.sheet = this.sheet === sheet ? null : sheet;
1428
1589
  }}>${icon(ic, 18)}${label}</button>`;
@@ -1442,16 +1603,16 @@ let MosaicaExplorer = (() => {
1442
1603
  <button class="btn primary" type="button" @click=${() => (this.sheet = null)}>${matchedText}</button>
1443
1604
  </div>`;
1444
1605
  const sheetBody = this.sheet === 'filters'
1445
- ? html `${filters}${sheetFooter}`
1606
+ ? html `<div class="sheet-body">${filters}</div>${sheetFooter}`
1446
1607
  : this.sheet === 'layers'
1447
- ? html `${this.displaySection()}${layersPanel}${this.choiceMenu()}`
1608
+ ? html `<div class="sheet-body">${this.displaySection()}${layersPanel}${this.choiceMenu()}</div>`
1448
1609
  : this.sheet === 'detail'
1449
- ? html `${selectionPanel}${detail}`
1610
+ ? html `<div class="sheet-body">${selectionPanel}${detail}</div>`
1450
1611
  : nothing;
1451
1612
  // The tooltip slot is forwarded only when the host supplied one: a slot assigned another slot
1452
1613
  // counts as filled even when that slot is empty, which would hide the map's own tooltip.
1453
1614
  return html `<div part="frame" class=${`${floating ? 'floating' : 'docked'}${compact ? ' compact' : ''}${narrow && this.has('toolbar') && named ? ' titled' : ''}`}
1454
- @mosaica-levelchange=${(e) => (this.level = e.detail.level)}
1615
+ @mosaica-levelchange=${(e) => this.chooseLevel(e.detail.layer, e.detail.level)}
1455
1616
  @mosaica-artifactfit=${(e) => this.map?.fitTo(BigInt(e.detail.id))}
1456
1617
  @mosaica-viewfollow=${(e) => this.followItem(e.detail)}
1457
1618
  @mosaica-close=${(e) => this.closeDetail(e)}>
@@ -1461,14 +1622,16 @@ let MosaicaExplorer = (() => {
1461
1622
  ${floating && !narrow ? panel : nothing}
1462
1623
  <mosaica-map
1463
1624
  exportparts=${FORWARD.map}
1625
+ aria-label=${this.getAttribute('aria-label') ?? nothing}
1626
+ ?quiet=${quiet}
1464
1627
  colour-by=${this.colourBy || nothing}
1465
1628
  layers=${this.layers || nothing}
1466
1629
  tooltip-fields=${this.tooltipFields}
1467
1630
  title-field=${this.titleField || nothing}
1468
1631
  budget=${this.budget || nothing}
1469
- .clusterBudget=${this.clusterBudget}
1632
+ .clusterSpacing=${this.clusterSpacing}
1470
1633
  controls-corner=${compact ? 'bottom-left' : 'top-left'}
1471
- .clusterLevel=${level}
1634
+ .clusterLevels=${levels}
1472
1635
  .basemap=${this.basemap}
1473
1636
  .noPoints=${this.noPoints}
1474
1637
  .radius=${this.radius}
@@ -1493,22 +1656,24 @@ let MosaicaExplorer = (() => {
1493
1656
  @mosaica-pick=${() => this.requestUpdate()}
1494
1657
  @mosaica-itemopen=${() => this.requestUpdate()}
1495
1658
  @mosaica-miss=${() => this.onMiss()}
1496
- @keydown=${this.onMapKey}
1497
1659
  @click=${() => this.requestUpdate()}
1498
1660
  >
1499
1661
  ${narrow ? nothing : layersButton}
1500
- ${narrow ? nothing : right}
1501
- ${narrow ? nothing : html `<div slot="bottom-right" class="in-map-strip"><slot name="status"><mosaica-status exportparts=${FORWARD.status} ?compact=${compact}></mosaica-status></slot></div>`}
1662
+ ${right}
1663
+ ${narrow ? nothing : html `<div slot="bottom-right" class="in-map-strip"><slot name="status" @slotchange=${this.onStatusSlot}><mosaica-status exportparts=${FORWARD.status} ?compact=${compact}></mosaica-status></slot></div>`}
1502
1664
  ${this.querySelector('[slot="tooltip"]') ? html `<slot name="tooltip" slot="tooltip"></slot>` : nothing}
1503
1665
  </mosaica-map>
1504
1666
  ${narrow ? nothing : this.callouts(this.has('detail') && hasDetail ? anchor : null, detail)}
1667
+ ${narrow && this.sheet && sheetBody !== nothing
1668
+ ? html `<div part="sheet" id="sheet" role="dialog" aria-labelledby=${`opens-${this.sheet}`} tabindex="-1" @keydown=${this.onSheetKey}>${sheetBody}</div>`
1669
+ : nothing}
1505
1670
  </div>
1506
- ${this.sheet && sheetBody !== nothing
1507
- ? html `<div part="sheet" id="sheet" role="dialog" aria-labelledby=${`tab-${this.sheet}`} tabindex="-1" @keydown=${this.onSheetKey}>${sheetBody}</div>`
1671
+ <div class="sr-only" role="status">${this.announced}</div>
1672
+ ${narrow
1673
+ ? html `<div part="strip-row"><slot name="status" @slotchange=${this.onStatusSlot}><mosaica-status exportparts=${FORWARD.status}></mosaica-status></slot></div>
1674
+ <div part="tabs" role="toolbar" aria-label="Panels" @keydown=${this.onTabKey}>${tabs.map(tab)}</div>`
1508
1675
  : nothing}
1509
- <div part="strip-row"><mosaica-status exportparts=${FORWARD.status}></mosaica-status></div>
1510
- <div part="tabs" role="tablist" aria-label="Explorer panels" @keydown=${this.onTabKey}>${tabs.map(tab)}</div>
1511
- <mosaica-colour-editor exportparts=${FORWARD['colour-editor']} field=${s?.get('legend').colourBy ?? ''} .level=${level}></mosaica-colour-editor>
1676
+ <mosaica-colour-editor exportparts=${FORWARD['colour-editor']} field=${s?.get('legend').colourBy ?? ''} .clusterLevels=${levels}></mosaica-colour-editor>
1512
1677
  </div>`;
1513
1678
  }
1514
1679
  /** The item or artifact card for what is selected, or the host's in the `detail` slot. */
@@ -1600,19 +1765,22 @@ let MosaicaExplorer = (() => {
1600
1765
  if (!m)
1601
1766
  return nothing;
1602
1767
  const live = this.liveKey();
1603
- const pinButton = (pressed, onPress) => html `<button part="pin" slot="actions" type="button" aria-pressed=${pressed ? 'true' : 'false'} aria-label=${pressed ? 'Unpin' : 'Pin'} title=${pressed ? 'Unpin' : 'Keep open'} @click=${onPress}>${icon('pin', 13, 1.5)}</button>`;
1768
+ // Pin keeps one name, the card's, and says whether it is on by its pressed state; its hint to a pointer says what a press does.
1769
+ const pinButton = (pressed, name, onPress) => html `<button part="pin" slot="actions" type="button" aria-pressed=${pressed ? 'true' : 'false'} aria-label=${`Pin ${name}`} title=${pressed ? 'Unpin' : 'Keep open'} @click=${onPress}>${icon('pin', 13, 1.5)}</button>`;
1604
1770
  const cards = this.pinned
1605
1771
  .filter((p) => p.view === view)
1606
1772
  .map((p) => {
1607
- const unpin = pinButton(true, () => (this.pinned = this.pinned.filter((q) => q.key !== p.key)));
1773
+ const name = this.cardName(p.key);
1774
+ const unpin = pinButton(true, name, () => (this.pinned = this.pinned.filter((q) => q.key !== p.key)));
1608
1775
  const body = p.kind === 'item'
1609
1776
  ? this.itemCard(p.item, unpin)
1610
1777
  : html `<mosaica-artifact-card exportparts=${FORWARD['artifact-card']} .artifact=${p.artifact}>${unpin}</mosaica-artifact-card>`;
1611
- return { key: p.key, world: p.world, body, pinned: true, name: this.cardName(p.key) };
1778
+ return { key: p.key, world: p.world, body, pinned: true, name };
1612
1779
  });
1613
1780
  if (anchor && !(live && this.pinned.some((p) => p.key === live && p.view === view))) {
1614
- const body = live ? this.liveCard(this.showsArtifact(), pinButton(false, () => this.togglePin())) : detail;
1615
- cards.push({ key: 'live', world: anchor, body, pinned: false, name: this.cardName(live) });
1781
+ const name = this.cardName(live);
1782
+ const body = live ? this.liveCard(this.showsArtifact(), pinButton(false, name, () => this.togglePin())) : detail;
1783
+ cards.push({ key: 'live', world: anchor, body, pinned: false, name });
1616
1784
  }
1617
1785
  this.cards = cards.map(({ key, world, pinned }) => ({ key, world, pinned }));
1618
1786
  if (cards.length === 0)
@@ -1627,7 +1795,8 @@ let MosaicaExplorer = (() => {
1627
1795
  /**
1628
1796
  * Place each callout beside its point under the camera as it stands, clear of the cards over the
1629
1797
  * map and of the callouts placed before it, and join it to its point. A callout whose point is off
1630
- * the map is hidden with its leader. Reads no layout: the sizes are those {@link measure} took.
1798
+ * the map is hidden with its leader, and gives the focus it holds to the map. Reads no layout: the
1799
+ * sizes are those {@link measure} took.
1631
1800
  */
1632
1801
  placeCallouts(firm = this.moving !== null) {
1633
1802
  const m = this.map;
@@ -1650,6 +1819,8 @@ let MosaicaExplorer = (() => {
1650
1819
  const last = this.placements.get(key);
1651
1820
  const kept = last && last.world[0] === card.world[0] && last.world[1] === card.world[1] ? { side: last.side, offset: last.offset, firm } : null;
1652
1821
  const at = size.width > 0 && size.height > 0 ? placeCallout(point, box, size, [...this.keepClear, ...placed], undefined, kept, CALLOUT_MIN_HEIGHT) : null;
1822
+ if (at === null && !el.hidden && within(this.renderRoot.activeElement, el))
1823
+ m.focus();
1653
1824
  el.hidden = at === null;
1654
1825
  leader?.setAttribute('visibility', at === null ? 'hidden' : 'visible');
1655
1826
  if (!at)
@@ -1694,6 +1865,7 @@ let MosaicaExplorer = (() => {
1694
1865
  });
1695
1866
  // A card that grew, as under Show all, keeps its side and grows down where that is clear.
1696
1867
  this.placeCallouts();
1868
+ this.coverMap();
1697
1869
  }
1698
1870
  /** The cards over the map that callouts keep clear of, and the map's tools and the Layers button. */
1699
1871
  keptClear() {
@@ -1717,7 +1889,8 @@ let MosaicaExplorer = (() => {
1717
1889
  this.measure();
1718
1890
  return;
1719
1891
  }
1720
- const now = new Set([...(m ? [m] : []), ...this.keptClear(), ...callouts]);
1892
+ const sheet = this.renderRoot.querySelector('[part="sheet"]');
1893
+ const now = new Set([...(m ? [m] : []), ...this.keptClear(), ...callouts, ...(sheet ? [sheet] : [])]);
1721
1894
  for (const el of this.observed)
1722
1895
  if (!now.has(el))
1723
1896
  this.sizes.unobserve(el);
@@ -1754,24 +1927,9 @@ let MosaicaExplorer = (() => {
1754
1927
  }
1755
1928
  });
1756
1929
  }
1757
- /**
1758
- * Tab from the map itself goes to the card beside the point, the selection's where it shows, so
1759
- * the card is the next stop after the map; from there Tab goes on in the page's order.
1760
- */
1761
- onMapKey = (e) => {
1762
- const from = e.composedPath()[0];
1763
- if (e.key !== 'Tab' || e.shiftKey || !this.map || (from !== this.map && from !== this.map.focusTarget))
1764
- return;
1765
- const shown = Array.from(this.renderRoot.querySelectorAll('[part~="callout"]')).filter((c) => !c.hidden);
1766
- const card = shown.find((c) => c.dataset.callout === 'live') ?? shown[0];
1767
- if (!card)
1768
- return;
1769
- e.preventDefault();
1770
- card.focus();
1771
- };
1772
1930
  /** Escape on a card: the card not pinned closes, dropping the selection; focus goes back to the map. */
1773
1931
  onCalloutKey = (e) => {
1774
- if (e.key !== 'Escape')
1932
+ if (e.key !== 'Escape' || e.defaultPrevented)
1775
1933
  return;
1776
1934
  e.stopPropagation();
1777
1935
  const key = e.currentTarget.dataset.callout;
@@ -1810,11 +1968,15 @@ let MosaicaExplorer = (() => {
1810
1968
  const change = (patch) => this.changeDisplay(patch);
1811
1969
  const number = (e) => Number(e.target.value);
1812
1970
  const choices = Object.keys(DENSITY_COLOUR_TITLES);
1971
+ // The arrow keys move the choice and leave the list open; a press chooses and closes it.
1813
1972
  const colourList = this.densityColoursOpen
1814
- ? html `<div id="density-colour-list" class="ramp-list" role="radiogroup" aria-labelledby="colours-label">
1973
+ ? html `<div id="density-colour-list" class="ramp-list" role="radiogroup" aria-labelledby="colours-label" @focusout=${this.onDensityColoursFocusOut} @keydown=${this.onDensityColoursKey}>
1815
1974
  ${choices.map((c, i) => html `<button type="button" role="radio" data-colours=${c} aria-checked=${c === colours ? 'true' : 'false'} tabindex=${c === colours ? '0' : '-1'}
1816
- @click=${() => change({ densityColours: c })}
1817
- @keydown=${(e) => radioKeys(e, choices.length, i, (j) => change({ densityColours: choices[j] }))}><span class="bar" style=${`background:${densityGradient(c, scheme)}`}></span>${DENSITY_COLOUR_TITLES[c]}</button>`)}
1975
+ @click=${() => {
1976
+ change({ densityColours: c });
1977
+ this.closeDensityColours();
1978
+ }}
1979
+ @keydown=${(e) => radioKeys(e, choices.length, i, (j) => change({ densityColours: choices[j] }))}>${tick(c === colours)}<span class="bar" style=${`background:${densityGradient(c, scheme)}`}></span>${DENSITY_COLOUR_TITLES[c]}</button>`)}
1818
1980
  </div>`
1819
1981
  : nothing;
1820
1982
  const densityControls = s.density === 'none'
@@ -1824,22 +1986,23 @@ let MosaicaExplorer = (() => {
1824
1986
  ${this.densityScaleControl(s.densityScale, change)}
1825
1987
  ${ramped
1826
1988
  ? html `<span id="colours-label">Colours</span>
1827
- <button part="density-colours" class="ramp-choice" type="button" aria-labelledby="colours-label" aria-expanded=${this.densityColoursOpen ? 'true' : 'false'} aria-controls="density-colour-list"
1989
+ <button part="density-colours" class="ramp-choice" type="button" aria-label=${`Colours: ${DENSITY_COLOUR_TITLES[colours]}`} aria-expanded=${this.densityColoursOpen ? 'true' : 'false'}
1990
+ aria-controls=${this.densityColoursOpen ? 'density-colour-list' : nothing} @focusout=${this.onDensityColoursFocusOut}
1828
1991
  @click=${() => (this.densityColoursOpen = !this.densityColoursOpen)}><span class="bar" style=${`background:${densityGradient(colours, scheme)}`}></span>${DENSITY_COLOUR_TITLES[colours]}${icon('chev', 12, 1.4)}</button>
1829
1992
  ${colourList}`
1830
1993
  : nothing}
1831
1994
  <label for="density-strength">Strength</label>
1832
1995
  <div class="with-readout"><input id="density-strength" part="density-strength" class="slider" type="range" min="0.1" max="1" step="0.05" .value=${String(s.densityStrength)}
1833
- style=${fill(s.densityStrength, 0.1, 1)} @input=${(e) => change({ densityStrength: number(e) })} /><span class="readout">${Math.round(s.densityStrength * 100)}%</span></div>
1996
+ style=${fill(s.densityStrength, 0.1, 1)} aria-valuetext=${`${Math.round(s.densityStrength * 100)}%`} @input=${(e) => change({ densityStrength: number(e) })} /><span class="readout">${Math.round(s.densityStrength * 100)}%</span></div>
1834
1997
  </div>`;
1835
1998
  return html `<div part="display" class="display">
1836
- <section class="sec">
1999
+ <section class="sec" aria-labelledby="points-label">
1837
2000
  <div class="sec-head">
1838
- <span class="hd" id="points-label">Points</span>
2001
+ <h2 class="hd" id="points-label">Points</h2>
1839
2002
  <button part="points-toggle" class="switch small" type="button" role="switch" aria-checked=${s.points ? 'true' : 'false'} aria-label="Show points"
1840
2003
  @click=${() => change({ points: !s.points })}><span class="knob"></span></button>
1841
2004
  </div>
1842
- ${this.mostPoints(!s.points)}${this.mostClusters()}
2005
+ ${this.mostPoints(!s.points)}${this.clusterDensity()}
1843
2006
  <div class="sliders">
1844
2007
  ${this.sizeControls(radius, !s.points)}
1845
2008
  <label for="point-opacity">Opacity</label>
@@ -1848,10 +2011,10 @@ let MosaicaExplorer = (() => {
1848
2011
  </div>
1849
2012
  </section>
1850
2013
  ${this.colourSection()}
1851
- <section class="sec">
1852
- <div class="hd" id="density-label">Density</div>
2014
+ <section class="sec" aria-labelledby="density-label">
2015
+ <h2 class="hd" id="density-label">Density</h2>
1853
2016
  <div part="density-mode" class="modes" role="radiogroup" aria-labelledby="density-label">
1854
- ${DENSITY_MODES.map((m, i) => html `<button type="button" role="radio" data-mode=${m.mode} aria-checked=${m.mode === s.density ? 'true' : 'false'} tabindex=${i === modeAt ? '0' : '-1'}
2017
+ ${DENSITY_MODES.map((m, i) => html `<button type="button" role="radio" data-mode=${m.mode} aria-checked=${m.mode === s.density ? 'true' : 'false'} tabindex=${i === Math.max(0, modeAt) ? '0' : '-1'}
1855
2018
  @click=${() => change({ density: m.mode })}
1856
2019
  @keydown=${(e) => radioKeys(e, DENSITY_MODES.length, i, (j) => change({ density: DENSITY_MODES[j].mode }))}>${m.label}</button>`)}
1857
2020
  </div>
@@ -1865,67 +2028,89 @@ let MosaicaExplorer = (() => {
1865
2028
  */
1866
2029
  mostPoints(disabled) {
1867
2030
  const s = this.resolvedStore;
2031
+ const min = Math.max(1, this.budgetMin);
2032
+ const max = Math.max(min * 10, this.budgetMax);
2033
+ const span = Math.log(max / min);
2034
+ const at = (n) => Math.min(1, Math.max(0, Math.log(n / min) / span));
2035
+ const shown = this.pointsDragged ?? s?.budget ?? 250_000;
2036
+ // A tick at each power of ten between the ends, where it leaves room for the last end's label.
2037
+ const ticks = [];
2038
+ for (let p = 10 ** Math.ceil(Math.log10(min) + 1e-9); p < max; p *= 10)
2039
+ if (at(p) > 0.08 && at(p) < 0.85)
2040
+ ticks.push({ at: at(p), text: pointsText(p) });
1868
2041
  return this.mostSlider({
1869
2042
  id: 'most-points',
1870
2043
  label: 'Most points',
1871
- unit: 'points',
1872
- range: [this.budgetMin, this.budgetMax],
1873
- shown: this.pointsDragged ?? s?.budget ?? 250_000,
2044
+ at: at(shown),
2045
+ readout: pointsText(shown),
2046
+ valueText: `${pointsText(shown)} points`,
2047
+ ends: [pointsText(min), pointsText(max)],
2048
+ ticks,
1874
2049
  disabled: disabled || !s,
2050
+ of: (t) => Math.min(max, Math.max(min, roundPoints(min * Math.exp(t * span)))),
1875
2051
  drag: (n) => (this.pointsDragged = n),
1876
2052
  set: (n) => this.setBudget(n)
1877
2053
  });
1878
2054
  }
1879
2055
  /**
1880
- * Most clusters, while a `nested` or `dag` layer is drawn: a slider from `cluster-budget-min` to
1881
- * `cluster-budget-max` as Most points is. Letting go sets the most clusters such a layer is cut to.
2056
+ * Cluster density, while a `nested` or `dag` layer is drawn, or a levelled layer following the zoom
2057
+ * is drawn or coloured by: a slider over the cluster spacing on a log scale, from
2058
+ * `cluster-spacing-max` at its Fewer end to `cluster-spacing-min` at its More end, with how many
2059
+ * clusters the spacing gives the map at its size. Letting go sets the store's spacing.
1882
2060
  */
1883
- mostClusters() {
2061
+ clusterDensity() {
1884
2062
  const s = this.resolvedStore;
1885
2063
  const meta = s?.get('meta') ?? null;
1886
2064
  if (!s || !meta)
1887
2065
  return nothing;
1888
- const treed = s.get('artifacts').layers.some((name) => {
1889
- const kind = meta.layers.find((l) => l.name === name)?.hierarchy.kind;
1890
- return kind === 'nested' || kind === 'dag';
2066
+ const coloured = clusterLayerOf(s.get('legend').colourBy);
2067
+ const steered = [...s.get('artifacts').layers, ...(coloured === null ? [] : [coloured])].some((name) => {
2068
+ const layer = meta.layers.find((l) => l.name === name);
2069
+ if (!layer)
2070
+ return false;
2071
+ return layer.hierarchy.kind === 'nested' || layer.hierarchy.kind === 'dag' || follows(layer, this.levels.get(name));
1891
2072
  });
1892
- if (!treed)
2073
+ if (!steered)
1893
2074
  return nothing;
2075
+ const dense = Math.max(1, this.clusterSpacingMin);
2076
+ const sparse = Math.max(dense * 2, this.clusterSpacingMax);
2077
+ const span = Math.log(sparse / dense);
2078
+ // With no spacing the map shows every cluster, as at the More end.
2079
+ const shown = this.clustersDragged ?? s.clusterSpacing ?? dense;
2080
+ const map = this.renderRoot.querySelector('mosaica-map');
2081
+ const count = map && map.clientWidth > 0 && map.clientHeight > 0 ? clustersInView(shown, map.clientWidth, map.clientHeight) : null;
2082
+ const square = `one per ${Math.round(shown)} px square`;
1894
2083
  return this.mostSlider({
1895
- id: 'most-clusters',
1896
- label: 'Most clusters',
1897
- unit: 'clusters',
1898
- range: [this.clusterBudgetMin, this.clusterBudgetMax],
1899
- shown: this.clustersDragged ?? s.clusterBudget ?? (this.clusterBudget || this.clusterBudgetMax),
2084
+ id: 'cluster-density',
2085
+ label: 'Cluster density',
2086
+ at: Math.min(1, Math.max(0, Math.log(sparse / shown) / span)),
2087
+ readout: count === null ? `1 per ${Math.round(shown)} px` : pointsText(count),
2088
+ valueText: count === null ? square : `${count} clusters, ${square}`,
2089
+ ends: ['Fewer', 'More'],
2090
+ ticks: [],
1900
2091
  disabled: false,
2092
+ of: (t) => Math.min(sparse, Math.max(dense, Math.round(sparse * Math.exp(-t * span)))),
1901
2093
  drag: (n) => (this.clustersDragged = n),
1902
- set: (n) => this.setClusterBudget(n)
2094
+ set: (n) => this.setClusterSpacing(n)
1903
2095
  });
1904
2096
  }
1905
- /** A log-scale slider over `range` on round numbers, with the number it stands at. */
2097
+ /**
2098
+ * A slider standing `at` of the way along, with its readout, its ends' labels and its ticks. `of`
2099
+ * gives the value at a share of the way along; dragging passes it to `drag`, and letting go to
2100
+ * `set`.
2101
+ */
1906
2102
  mostSlider(o) {
1907
- const min = Math.max(1, o.range[0]);
1908
- const max = Math.max(min * 10, o.range[1]);
1909
- const span = Math.log(max / min);
1910
- const at = (n) => Math.min(1, Math.max(0, Math.log(n / min) / span));
1911
- const of = (t) => Math.min(max, Math.max(min, roundPoints(min * Math.exp(t * span))));
1912
- const t = at(o.shown);
1913
- // A tick at each power of ten between the ends, where it leaves room for the last end's label.
1914
- const ticks = [];
1915
- for (let p = 10 ** Math.ceil(Math.log10(min) + 1e-9); p < max; p *= 10)
1916
- if (at(p) > 0.08 && at(p) < 0.85)
1917
- ticks.push(p);
1918
- const read = (e) => of(Number(e.target.value) / 1000);
2103
+ const read = (e) => o.of(Number(e.target.value) / 1000);
1919
2104
  return html `<div class="most">
1920
- <div class="most-top"><label for=${o.id}>${o.label}</label><span part=${o.id === 'most-points' ? 'most-points-value' : 'most-clusters-value'} class="most-value">${pointsText(o.shown)}</span></div>
1921
- <input id=${o.id} part=${o.id === 'most-points' ? 'most-points' : 'most-clusters'} class="slider" type="range" min="0" max="1000" step="1" .value=${String(Math.round(t * 1000))} ?disabled=${o.disabled}
1922
- style=${`--fill:${(t * 100).toFixed(1)}%`} aria-valuetext=${`${pointsText(o.shown)} ${o.unit}`}
2105
+ <div class="most-top"><label for=${o.id}>${o.label}</label><span part=${o.id === 'most-points' ? 'most-points-value' : 'cluster-density-value'} class="most-value">${o.readout}</span></div>
2106
+ <input id=${o.id} part=${o.id === 'most-points' ? 'most-points' : 'cluster-density'} class="slider" type="range" min="0" max="1000" step="1" .value=${String(Math.round(o.at * 1000))} ?disabled=${o.disabled}
2107
+ style=${`--fill:${(o.at * 100).toFixed(1)}%`} aria-valuetext=${o.valueText}
1923
2108
  @input=${(e) => o.drag(read(e))}
1924
2109
  @change=${(e) => o.set(read(e))} />
1925
2110
  <div class="ticks" aria-hidden="true">
1926
- <span style="left:0">${pointsText(min)}</span>
1927
- ${ticks.map((p) => html `<span class="mid" style=${`left:${(at(p) * 100).toFixed(1)}%`}>${pointsText(p)}</span>`)}
1928
- <span style="right:0">${pointsText(max)}</span>
2111
+ <span style="left:0">${o.ends[0]}</span>
2112
+ ${o.ticks.map((p) => html `<span class="mid" style=${`left:${(p.at * 100).toFixed(1)}%`}>${p.text}</span>`)}
2113
+ <span style="right:0">${o.ends[1]}</span>
1929
2114
  </div>
1930
2115
  </div>`;
1931
2116
  }
@@ -1939,15 +2124,15 @@ let MosaicaExplorer = (() => {
1939
2124
  emit(this, 'mosaica-budgetchange', { budget });
1940
2125
  this.requestUpdate();
1941
2126
  }
1942
- /** Set the most clusters a treed layer is cut to from Most clusters, and report it. */
1943
- setClusterBudget(budget) {
2127
+ /** Set the store's cluster spacing from Cluster density, and report it. */
2128
+ setClusterSpacing(spacing) {
1944
2129
  this.clustersDragged = null;
1945
2130
  const s = this.resolvedStore;
1946
- if (!s || budget === s.clusterBudget)
2131
+ if (!s || spacing === s.clusterSpacing)
1947
2132
  return;
1948
- s.setClusterBudget(budget);
1949
- this.clusterBudget = budget;
1950
- emit(this, 'mosaica-clusterbudgetchange', { budget });
2133
+ s.setClusterSpacing(spacing);
2134
+ this.clusterSpacing = spacing;
2135
+ emit(this, 'mosaica-clusterspacingchange', { spacing });
1951
2136
  }
1952
2137
  /**
1953
2138
  * The Colour section: Colour by, then the palette for a category, or the ramp, its scale and
@@ -1965,7 +2150,7 @@ let MosaicaExplorer = (() => {
1965
2150
  const clusters = colourBy?.startsWith(CLUSTER_PREFIX) ?? false;
1966
2151
  const palette = s && clusters ? PALETTES[s.get('artifacts').palette] : CATEGORY_PALETTES[colouring.palette];
1967
2152
  const choice = (menu, label, body, stretch = false) => html `<button part=${{ size: 'size-by', colour: 'colour-by', palette: 'palette', ramp: 'ramp' }[menu]} class=${`ramp-choice${stretch ? ' stretch' : ''}`} type="button" aria-haspopup="menu" aria-expanded=${this.menu === menu ? 'true' : 'false'}
1968
- aria-label=${label} ?disabled=${!meta} @click=${() => (this.menu = this.menu === menu ? null : menu)} @keydown=${this.onMenuButtonKey(menu)}>${body}${icon('chev', 12, 1.4)}</button>`;
2153
+ aria-label=${label} title=${label} ?disabled=${!meta} @click=${() => (this.menu = this.menu === menu ? null : menu)} @keydown=${this.onMenuButtonKey(menu)}>${body}${icon('chev', 12, 1.4)}</button>`;
1969
2154
  const byName = current?.title ?? 'None';
1970
2155
  const palettes = (column?.category || clusters) && !this.hidePalettes
1971
2156
  ? html `<span>Palette</span>${choice('palette', `Palette: ${palette.title}`, html `<span class="strip">${palette.colours.slice(0, 10).map((c) => html `<span style=${`background:${hexOf(c)}`}></span>`)}</span><span class="t">${palette.title}</span>`, true)}`
@@ -1984,8 +2169,8 @@ let MosaicaExplorer = (() => {
1984
2169
  : nothing;
1985
2170
  // A category's values and a layer's clusters have colours of their own to edit.
1986
2171
  const editable = Boolean(column?.category) || clusters;
1987
- return html `<section class="sec">
1988
- <div class="hd">Colour</div>
2172
+ return html `<section class="sec" aria-labelledby="colour-label">
2173
+ <h2 class="hd" id="colour-label">Colour</h2>
1989
2174
  <div class="sliders">
1990
2175
  <span>Colour by</span>${choice('colour', `Colour by: ${byName}`, html `<span class="t">${byName}</span>`)}
1991
2176
  ${palettes}${ramps}
@@ -1995,7 +2180,8 @@ let MosaicaExplorer = (() => {
1995
2180
  }
1996
2181
  /**
1997
2182
  * What Colour by offers: None, every layer that can colour (a levelled layer once per level, by
1998
- * its levels' titles), and the rendered category and number columns.
2183
+ * its levels' titles, after Follow zoom where no level declares a zoom range), and the rendered
2184
+ * category and number columns.
1999
2185
  */
2000
2186
  colourOptions() {
2001
2187
  const s = this.resolvedStore;
@@ -2007,13 +2193,19 @@ let MosaicaExplorer = (() => {
2007
2193
  const value = `${CLUSTER_PREFIX}${decl.name}`;
2008
2194
  if (decl.levels.length <= 1)
2009
2195
  return [{ value, title: decl.title || decl.name, kind: 'Clusters', checked: colourBy === value }];
2010
- const drawn = this.level ?? topLevelServed(decl, s?.get('artifacts').served ?? []);
2011
- return decl.levels.map((lv) => ({ value: `${value}@${lv.level}`, title: lv.title || `${decl.title || decl.name}, level ${lv.level}`, kind: 'Clusters', checked: colourBy === value && drawn === lv.level }));
2196
+ const chosen = this.levels.get(decl.name);
2197
+ const following = follows(decl, chosen);
2198
+ const drawn = levelOfLayer(decl, chosen, s?.get('artifacts').followedLevels);
2199
+ const follow = levelFollowsZoom(decl) ? [{ value: `${value}@`, title: `${decl.title || decl.name}, follow zoom`, kind: 'Clusters', checked: colourBy === value && following }] : [];
2200
+ return [...follow, ...decl.levels.map((lv) => ({ value: `${value}@${lv.level}`, title: lv.title || `${decl.title || decl.name}, level ${lv.level}`, kind: 'Clusters', checked: colourBy === value && !following && drawn === lv.level }))];
2012
2201
  });
2013
2202
  const columns = meta.declaredScalars.filter((c) => c.render).map((c) => ({ value: c.name, title: columnCaption(c.name), kind: c.category ? 'Category' : c.arrowType === 'timestamp_us' ? 'Date' : 'Number', checked: colourBy === c.name }));
2014
2203
  return [{ value: '', title: 'None', kind: '', checked: colourBy === null }, ...layers, ...columns];
2015
2204
  }
2016
- /** Colour by `value`: a column, `cluster:<layer>`, or `cluster:<layer>@<level>` for one level of a layer. */
2205
+ /**
2206
+ * Colour by `value`: a column, `cluster:<layer>`, `cluster:<layer>@<level>` for one level of a
2207
+ * layer, or `cluster:<layer>@` for the level that follows the zoom.
2208
+ */
2017
2209
  chooseColour(value) {
2018
2210
  const s = this.resolvedStore;
2019
2211
  if (!s)
@@ -2022,11 +2214,22 @@ let MosaicaExplorer = (() => {
2022
2214
  const colourBy = value === '' ? null : at > 0 ? value.slice(0, at) : value;
2023
2215
  s.setColourBy(colourBy);
2024
2216
  emit(this, 'mosaica-colourchange', { colourBy });
2025
- if (at > 0) {
2026
- this.level = Number(value.slice(at + 1));
2027
- emit(this, 'mosaica-levelchange', { level: this.level });
2217
+ if (at > 0 && colourBy !== null) {
2218
+ const layer = colourBy.slice(CLUSTER_PREFIX.length);
2219
+ const level = at === value.length - 1 ? null : Number(value.slice(at + 1));
2220
+ this.chooseLevel(layer, level);
2221
+ emit(this, 'mosaica-levelchange', { layer, level });
2028
2222
  }
2029
2223
  }
2224
+ /** Count, colour and label `layer` at `level`, or at its default level where `level` is null; other layers keep theirs. */
2225
+ chooseLevel(layer, level) {
2226
+ const levels = new Map(this.levels);
2227
+ if (level === null)
2228
+ levels.delete(layer);
2229
+ else
2230
+ levels.set(layer, level);
2231
+ this.levels = levels;
2232
+ }
2030
2233
  /** Colour a layer's clusters from `palette`, and report it. */
2031
2234
  chooseClusterPalette(palette) {
2032
2235
  const s = this.resolvedStore;
@@ -2093,7 +2296,7 @@ let MosaicaExplorer = (() => {
2093
2296
  const at = DENSITY_SCALES.findIndex((x) => x.scale === scale);
2094
2297
  return html `<span id="density-scale-label">Scale</span>
2095
2298
  <div part="density-scale" class="seg" role="radiogroup" aria-labelledby="density-scale-label">
2096
- ${DENSITY_SCALES.map((x, i) => html `<button type="button" role="radio" data-scale=${x.scale} aria-checked=${x.scale === scale ? 'true' : 'false'} tabindex=${i === at ? '0' : '-1'}
2299
+ ${DENSITY_SCALES.map((x, i) => html `<button type="button" role="radio" data-scale=${x.scale} aria-checked=${x.scale === scale ? 'true' : 'false'} tabindex=${i === Math.max(0, at) ? '0' : '-1'}
2097
2300
  @click=${() => change({ densityScale: x.scale })}
2098
2301
  @keydown=${(e) => radioKeys(e, DENSITY_SCALES.length, i, (j) => change({ densityScale: DENSITY_SCALES[j].scale }))}>${x.label}</button>`)}
2099
2302
  </div>`;
@@ -2122,10 +2325,15 @@ let MosaicaExplorer = (() => {
2122
2325
  const sizing = this.sizing;
2123
2326
  const number = (e) => Number(e.target.value);
2124
2327
  const { min, max, step } = SIZE_RANGE;
2328
+ // With no column to offer, the button says why: to a pointer in its `title`, and in the text that describes it.
2329
+ const none = this.resolvedStore?.get('meta') && this.sizeColumns.length === 0;
2330
+ const why = 'No number column to size by';
2331
+ const shown = sizeBy === null ? 'None' : columnCaption(sizeBy);
2125
2332
  const choice = html `<span id="size-by-label">Size by</span>
2126
- <button part="size-by" class="ramp-choice" type="button" aria-haspopup="menu" aria-expanded=${this.menu === 'size' ? 'true' : 'false'} aria-label=${`Size by: ${sizeBy === null ? 'None' : columnCaption(sizeBy)}`}
2127
- ?disabled=${disabled || (sizeBy === null && this.sizeColumns.length === 0)} title=${this.sizeColumns.length === 0 ? 'No number column to size by' : nothing}
2128
- @click=${() => (this.menu = this.menu === 'size' ? null : 'size')} @keydown=${this.onMenuButtonKey('size')}><span class="t">${sizeBy === null ? 'None' : columnCaption(sizeBy)}</span>${icon('chev', 12, 1.4)}</button>`;
2333
+ <button part="size-by" class="ramp-choice" type="button" aria-haspopup="menu" aria-expanded=${this.menu === 'size' ? 'true' : 'false'} aria-label=${`Size by: ${shown}`}
2334
+ ?disabled=${disabled || (sizeBy === null && this.sizeColumns.length === 0)} title=${none ? why : shown} aria-describedby=${none ? 'size-by-note' : nothing}
2335
+ @click=${() => (this.menu = this.menu === 'size' ? null : 'size')} @keydown=${this.onMenuButtonKey('size')}><span class="t">${shown}</span>${icon('chev', 12, 1.4)}</button>
2336
+ ${none ? html `<span id="size-by-note" class="sr-only">${why}</span>` : nothing}`;
2129
2337
  if (sizeBy === null) {
2130
2338
  if (this.hideSize)
2131
2339
  return choice;
@@ -2138,14 +2346,14 @@ let MosaicaExplorer = (() => {
2138
2346
  return html `${choice}<span id="size-range-label">Range</span>
2139
2347
  <div class="range" role="group" aria-labelledby="size-range-label">
2140
2348
  <input part="size-min" class="slider" type="range" min=${min} max=${max} step=${step} aria-label="Smallest size" .value=${String(sizing.min)} ?disabled=${disabled}
2141
- style=${fill(sizing.min, min, max)} @input=${(e) => this.changeSize({ min: number(e), max: Math.max(number(e), sizing.max) })} />
2349
+ style=${fill(sizing.min, min, max)} aria-valuetext=${`${px(sizing.min)} px`} @input=${(e) => this.changeSize({ min: number(e), max: Math.max(number(e), sizing.max) })} />
2142
2350
  <span part="size-range" class="readout">${px(sizing.min)} – ${px(sizing.max)} px</span>
2143
2351
  <input part="size-max" class="slider" type="range" min=${min} max=${max} step=${step} aria-label="Largest size" .value=${String(sizing.max)} ?disabled=${disabled}
2144
- style=${fill(sizing.max, min, max)} @input=${(e) => this.changeSize({ max: number(e), min: Math.min(number(e), sizing.min) })} />
2352
+ style=${fill(sizing.max, min, max)} aria-valuetext=${`${px(sizing.max)} px`} @input=${(e) => this.changeSize({ max: number(e), min: Math.min(number(e), sizing.min) })} />
2145
2353
  </div>
2146
2354
  <span id="size-scale-label">Scale</span>
2147
2355
  <div part="size-scale" class="seg" role="radiogroup" aria-labelledby="size-scale-label">
2148
- ${SIZE_SCALES.map((x, i) => html `<button type="button" role="radio" data-scale=${x.scale} aria-checked=${x.scale === sizing.scale ? 'true' : 'false'} tabindex=${i === at ? '0' : '-1'} ?disabled=${disabled}
2356
+ ${SIZE_SCALES.map((x, i) => html `<button type="button" role="radio" data-scale=${x.scale} aria-checked=${x.scale === sizing.scale ? 'true' : 'false'} tabindex=${i === Math.max(0, at) ? '0' : '-1'} ?disabled=${disabled}
2149
2357
  @click=${() => this.changeSize({ scale: x.scale })}
2150
2358
  @keydown=${(e) => radioKeys(e, SIZE_SCALES.length, i, (j) => this.changeSize({ scale: SIZE_SCALES[j].scale }))}>${x.label}</button>`)}
2151
2359
  </div>`;
@@ -2218,10 +2426,19 @@ let MosaicaExplorer = (() => {
2218
2426
  const items = Array.from(this.renderRoot.querySelectorAll(`[part~="${menu}-option"]`));
2219
2427
  const next = { ArrowDown: (i + 1) % items.length, ArrowUp: (i - 1 + items.length) % items.length, Home: 0, End: items.length - 1 }[e.key];
2220
2428
  if (e.key === 'Escape') {
2429
+ // An Escape that has dismissed the map's tooltip does no more.
2430
+ if (e.defaultPrevented)
2431
+ return;
2221
2432
  e.stopPropagation();
2222
2433
  this.closeMenu(true);
2223
2434
  return;
2224
2435
  }
2436
+ // Tab closes the menu and goes back to its button, in the popover, and on from there.
2437
+ if (e.key === 'Tab') {
2438
+ e.preventDefault();
2439
+ this.closeMenu(true);
2440
+ return;
2441
+ }
2225
2442
  if (next === undefined)
2226
2443
  return;
2227
2444
  e.preventDefault();
@@ -2230,8 +2447,8 @@ let MosaicaExplorer = (() => {
2230
2447
  };
2231
2448
  const at = Math.max(0, entries.findIndex((o) => o.value === checked));
2232
2449
  const row = (o) => o.line === undefined
2233
- ? html `<span class="lead">${o.swatch ?? nothing}<span>${o.title}</span></span><span class="kind">${o.kind}</span>`
2234
- : html `${o.swatch ?? nothing}<span class="named"><span class="t">${o.title}</span><span class="line">${o.line}</span></span>`;
2450
+ ? html `<span class="lead">${tick(o.value === checked)}${o.swatch ?? nothing}<span>${o.title}</span></span><span class="kind">${o.kind}</span>`
2451
+ : html `${tick(o.value === checked)}${o.swatch ?? nothing}<span class="named"><span class="t">${o.title}</span><span class="line">${o.line}</span></span>`;
2235
2452
  return html `<div part=${{ size: 'size-menu', colour: 'colour-menu', palette: 'palette-menu', ramp: 'ramp-menu' }[menu]} class=${`size-menu${menu === 'ramp' ? ' wide' : menu === 'palette' ? ' palettes' : ''}`} popover="manual" role="menu" aria-labelledby="menu-label" @focusout=${this.onMenuFocusOut}>
2236
2453
  <div class="hd" id="menu-label">${title}</div>
2237
2454
  ${entries.map((o, i) => html `<button part=${{ size: 'size-option', colour: 'colour-option', palette: 'palette-option', ramp: 'ramp-option' }[menu]} type="button" role="menuitemradio" data-value=${o.value} aria-checked=${o.value === checked ? 'true' : 'false'}
@@ -2254,7 +2471,7 @@ let MosaicaExplorer = (() => {
2254
2471
  this.menu = menu;
2255
2472
  };
2256
2473
  }
2257
- /** Focus leaving the open menu, by Tab or otherwise, closes it; Tab goes on from where focus went. */
2474
+ /** Focus leaving the open menu closes it. */
2258
2475
  onMenuFocusOut = (e) => {
2259
2476
  const to = e.relatedTarget;
2260
2477
  if (to && e.currentTarget.contains(to))
@@ -2300,10 +2517,8 @@ let MosaicaExplorer = (() => {
2300
2517
  this.pointOpacity = patch.pointOpacity;
2301
2518
  if (patch.density !== undefined)
2302
2519
  this.density = patch.density;
2303
- if (patch.densityColours !== undefined) {
2520
+ if (patch.densityColours !== undefined)
2304
2521
  this.densityColours = patch.densityColours ?? '';
2305
- this.densityColoursOpen = false;
2306
- }
2307
2522
  if (patch.densityStrength !== undefined)
2308
2523
  this.densityStrength = patch.densityStrength;
2309
2524
  if (patch.densityResolution !== undefined)
@@ -2312,17 +2527,36 @@ let MosaicaExplorer = (() => {
2312
2527
  this.densityScale = patch.densityScale;
2313
2528
  emit(this, 'mosaica-displaychange', this.display);
2314
2529
  }
2530
+ /** Close the list of density colours, with focus on its button. */
2531
+ closeDensityColours() {
2532
+ this.renderRoot.querySelector('[part="density-colours"]')?.focus();
2533
+ this.densityColoursOpen = false;
2534
+ }
2535
+ /** Escape in the list of density colours closes the list alone. */
2536
+ onDensityColoursKey = (e) => {
2537
+ if (e.key !== 'Escape' || e.defaultPrevented)
2538
+ return;
2539
+ e.stopPropagation();
2540
+ this.closeDensityColours();
2541
+ };
2542
+ /** Focus leaving the list of density colours and its button closes the list. */
2543
+ onDensityColoursFocusOut = (e) => {
2544
+ const to = e.relatedTarget;
2545
+ const held = [this.renderRoot.querySelector('#density-colour-list'), this.renderRoot.querySelector('[part="density-colours"]')];
2546
+ if (!held.some((el) => el && to && el.contains(to)))
2547
+ this.densityColoursOpen = false;
2548
+ };
2315
2549
  /** Escape closes the Layers popover and returns focus to its button. */
2316
2550
  onLayersKey = (e) => {
2317
- if (e.key !== 'Escape' || !this.layersOpen)
2551
+ if (e.key !== 'Escape' || e.defaultPrevented || !this.layersOpen)
2318
2552
  return;
2319
2553
  e.stopPropagation();
2320
2554
  this.closeLayers();
2321
2555
  this.renderRoot.querySelector('[part="layers-toggle"]')?.focus();
2322
2556
  };
2323
- /** The tab list's keys: the arrows move between tabs, wrapping, and Home and End go to the ends. */
2557
+ /** The bar's keys: the arrows move between its buttons, wrapping, and Home and End go to the ends. */
2324
2558
  onTabKey = (e) => {
2325
- const buttons = Array.from(this.renderRoot.querySelectorAll('[part="tabs"] [role="tab"]'));
2559
+ const buttons = Array.from(this.renderRoot.querySelectorAll('[part="tabs"] button'));
2326
2560
  const at = buttons.indexOf(e.target);
2327
2561
  if (at < 0)
2328
2562
  return;
@@ -2337,24 +2571,56 @@ let MosaicaExplorer = (() => {
2337
2571
  };
2338
2572
  /** Escape closes the sheet. */
2339
2573
  onSheetKey = (e) => {
2340
- if (e.key !== 'Escape')
2574
+ if (e.key !== 'Escape' || e.defaultPrevented)
2341
2575
  return;
2342
2576
  e.stopPropagation();
2343
2577
  this.sheet = null;
2344
2578
  };
2345
- /** Focus goes into a sheet as it opens, and back to its tab as it closes. */
2579
+ /**
2580
+ * Focus goes to the first control of the Layers popover as it opens. A sheet that opens takes
2581
+ * the focus the explorer held, and none from the page around it. A sheet that closes gives the
2582
+ * focus it held to its button, or to the map where the layout has grown wide and has no button.
2583
+ */
2346
2584
  updated(changed) {
2347
2585
  this.toggleAttribute('data-compact', this.compact);
2348
2586
  this.fitLayers();
2349
2587
  this.placeMenu(changed.has('menu') && this.menu !== null);
2350
2588
  this.followLayout();
2589
+ this.coverMap();
2590
+ if (changed.has('layersOpen') && this.layersOpen)
2591
+ this.renderRoot.querySelector('[part="layers-popover"]')?.querySelector(FOCUSABLE)?.focus();
2351
2592
  if (!changed.has('sheet'))
2352
2593
  return;
2353
2594
  const before = changed.get('sheet');
2354
- if (this.sheet)
2355
- this.renderRoot.querySelector('[part="sheet"]')?.focus();
2356
- else if (before)
2357
- this.renderRoot.querySelector(`[role="tab"][data-sheet="${before}"]`)?.focus();
2595
+ if (this.sheet) {
2596
+ if (this.focusBefore)
2597
+ this.renderRoot.querySelector('[part="sheet"]')?.focus();
2598
+ }
2599
+ else if (before && this.focusBefore === 'sheet') {
2600
+ if (this.narrow)
2601
+ this.renderRoot.querySelector(`[part="tabs"] [data-sheet="${before}"]`)?.focus();
2602
+ else
2603
+ this.map?.focus();
2604
+ }
2605
+ }
2606
+ /**
2607
+ * While an open sheet reaches up over the map's tools, the map behind it is inert: nothing under
2608
+ * the sheet takes focus or a press. A shorter sheet leaves the map above it in use.
2609
+ */
2610
+ coverMap() {
2611
+ const m = this.map;
2612
+ if (!m)
2613
+ return;
2614
+ const sheet = this.renderRoot.querySelector('[part="sheet"]');
2615
+ // Nothing is measured where no sheet is open, which is every draw of the wide layouts.
2616
+ if (!sheet) {
2617
+ if (m.inert)
2618
+ m.inert = false;
2619
+ return;
2620
+ }
2621
+ const tools = m.shadowRoot?.querySelector('[part="controls"]');
2622
+ const clear = tools ? tools.getBoundingClientRect().bottom : m.getBoundingClientRect().top;
2623
+ m.inert = sheet.getBoundingClientRect().top < clear;
2358
2624
  }
2359
2625
  /**
2360
2626
  * Hold the open Layers popover within the explorer's visible box: the part of it inside the