@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
@@ -34,23 +34,29 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
34
34
  };
35
35
  import { css, html, nothing } from 'lit';
36
36
  import { property, state } from 'lit/decorators.js';
37
- import { CLUSTER_PREFIX, PALETTES, artifactColour, colourLayers, composeFilters, isPopulated, withMember, withoutMember } from '@mosaicajs/client';
37
+ import { repeat } from 'lit/directives/repeat.js';
38
+ import { CLUSTER_PREFIX, PALETTES, artifactColour, colourLayers, composeFilters, isPopulated, memberLeaf, paletteSize, withMember, withoutMember } from '@mosaicajs/client';
38
39
  import { CATEGORY_PALETTES, RAMPS } from '@mosaicajs/deck';
39
40
  import { colourOfFraction, css as rgb, fractionOf, hexOf } from '@mosaicajs/deck/internal';
40
- import { HeldAggregate, artifactGroupings, countsByKey, levelOf, listedGroups, rankedGrouping } from './aggregate.js';
41
- import { MosaicaElement, UNNAMED, columnCaption, countText, dateRangeText, emit, idString, keyTitle, shortDateText } from './base.js';
41
+ import { follows, levelFollowsZoom, levelOfLayer } from '@mosaicajs/client/internal';
42
+ import { HeldAggregate, artifactGroupings, countsByKey, isLinked, listedGroups, rankedGrouping } from './aggregate.js';
43
+ import { BOOL_KEYS, BOOL_NUMBERS, BOOL_WORDS, MosaicaElement, boolClause, called, calledList, calledText, labelOf, boolKeys, columnCaption, countText, dateRangeText, emit, focusLost, idString, isBoolColumn, keyTitle, parseDateText, shortDateText } from './base.js';
42
44
  import { ColourPicker, pickerStyles } from './colour-picker.js';
43
45
  import { clusterColour, colouringOf, holdColours, paletteValueColour, setClusterColours, setValueColours, valueColour, valueMet, watchChoices } from './colouring.js';
44
46
  import { attachContextRoot, defineOnce } from './define.js';
45
47
  import { icon } from './icons.js';
48
+ import { Lineage, focusAdded, spokenCounts } from './lineage.js';
46
49
  import { exportparts } from './parts.js';
47
- import { ClusterPaths } from './paths.js';
48
50
  import { chrome, tokens } from './tokens.js';
49
51
  import './filter.js';
50
52
  import './cluster-filter.js';
53
+ /** The rows, crumbs and chips whose place keeps focus across a render, by part. */
54
+ const HOLDERS = '[part="row"], [part="crumb"], [part="chip"]';
51
55
  /** The rows a category or cluster card lists, and the rows "N more" opens it to. */
52
56
  const ROWS = 5;
53
57
  const MORE_ROWS = 20;
58
+ /** The rows of a browse page of a linked layer's card, which More adds to. */
59
+ const PAGE_ROWS = 20;
54
60
  /** The narrowest a cluster's bar is drawn where it is not empty, in pixels. */
55
61
  const MIN_BAR_PX = 3;
56
62
  /** The values the counts over everything matching are asked for, so the rows in view find theirs. */
@@ -74,6 +80,81 @@ function formatNumber(n, whole) {
74
80
  return v.toExponential(2);
75
81
  return v.toLocaleString('en-GB', size >= 1 ? { maximumFractionDigits: 2 } : { maximumSignificantDigits: 3 });
76
82
  }
83
+ /** A row's or a step's name and where it came from. */
84
+ const nameFrom = (c) => ({ name: c.text, from: c.from });
85
+ /** The marks that may group a number's thousands: a comma, a space and a narrow no-break space. */
86
+ const GROUP_MARKS = /[, \u202f]/g;
87
+ /** Digits in threes between one such mark, as a count is written: `1,234,567` and `1 250.5`, not `1,5` or `1,234 567`. */
88
+ const GROUPED = /^[+-]?\d{1,3}([, \u202f])\d{3}(\1\d{3})*(\.\d*)?$/;
89
+ /**
90
+ * The value a range box's field stands for; `upper` reads a date as the last instant of the period
91
+ * it names. In a number a comma or a space is taken only where it groups thousands, so `1,5` is no
92
+ * number.
93
+ */
94
+ function endValue(text, timestamp, upper) {
95
+ const t = text.trim();
96
+ if (t === '')
97
+ return null;
98
+ if (timestamp)
99
+ return parseDateText(t, upper) ?? undefined;
100
+ const n = Number(GROUPED.test(t) ? t.replace(GROUP_MARKS, '') : t);
101
+ return Number.isFinite(n) ? n : undefined;
102
+ }
103
+ /**
104
+ * A value as a range box's field holds it. A number is written in full, so that typing what the
105
+ * field shows gives the same number; the axis rounds it and writes a large one with an exponent.
106
+ */
107
+ function endText(v, timestamp) {
108
+ return timestamp ? shortDateText(v) : String(v);
109
+ }
110
+ /** Whether both ends hold a value and the lower is above the upper. */
111
+ function backwards(entry) {
112
+ return typeof entry.from.value === 'number' && typeof entry.to.value === 'number' && entry.from.value > entry.to.value;
113
+ }
114
+ /** A range as the card says it: an open end as a bound, and no end as every value. */
115
+ function rangeWords(gte, lte, timestamp, whole) {
116
+ if (gte === null && lte === null)
117
+ return 'Any value';
118
+ if (timestamp)
119
+ return dateRangeText(gte, lte);
120
+ if (lte === null)
121
+ return `≥ ${formatNumber(gte, whole)}`;
122
+ if (gte === null)
123
+ return `≤ ${formatNumber(lte, whole)}`;
124
+ return `${formatNumber(gte, whole)} – ${formatNumber(lte, whole)}`;
125
+ }
126
+ /**
127
+ * The items `bins` count in a range whose ends are bin edges or open; `null` for a range that cuts
128
+ * a bin. With `discrete`, a bin's last value is one below the next bin's edge.
129
+ */
130
+ function countWithin(bins, gte, lte, discrete) {
131
+ const first = gte === null ? 0 : bins.findIndex((b) => b.lower === gte);
132
+ const last = lte === null ? bins.length - 1 : bins.findIndex((b) => b.upper - (discrete ? 1 : 0) === lte);
133
+ return first < 0 || last < first ? null : bins.slice(first, last + 1).reduce((t, b) => t + b.count, 0);
134
+ }
135
+ /** Whether a cluster card lists `layer` by its children: a layer with parent edges that the browse route serves. */
136
+ function drills(layer) {
137
+ return isLinked(layer) && layer.depsOn.length === 0;
138
+ }
139
+ /** The level of `layer` after `rung`, the next finer one, where it has one. */
140
+ function levelAfter(layer, rung) {
141
+ return layer.levels
142
+ .map((l) => l.level)
143
+ .filter((l) => l > rung)
144
+ .sort((a, b) => a - b)[0];
145
+ }
146
+ /** A level's title, or its number where it has none. */
147
+ function levelTitle(layer, level) {
148
+ return layer.levels.find((l) => l.level === level)?.title || `Level ${level}`;
149
+ }
150
+ /** `n` and a noun, singular for one. */
151
+ function counted(n, one, many) {
152
+ return `${n.toLocaleString('en-GB')} ${n === 1 ? one : many}`;
153
+ }
154
+ /** "≈" where either table's counts were taken from a sample, else nothing. */
155
+ function approxMark(...tables) {
156
+ return tables.some((t) => t?.sample?.sampled) ? '≈' : '';
157
+ }
77
158
  /** The listed bins of a histogram table, in order, with the edges in the column's units. */
78
159
  function binsOf(table, timestamp) {
79
160
  if (!table)
@@ -104,33 +185,77 @@ function binsOf(table, timestamp) {
104
185
  * `<mosaica-filter-panel>` shows one for each field. Set `field` to a column `meta.filterOperands`
105
186
  * lists, or to `cluster:<layer>` for a layer's clusters.
106
187
  *
107
- * The solid bars count the items in view that the filters match, or the highlighted items while
108
- * there is a highlight. The pale bars count every item the filters match. Neither count applies the
109
- * card's own filter, so a value the viewer has filtered out keeps its count.
188
+ * On a column's card and a flat layer's, the solid bars count the items in view that the filters
189
+ * match, or the highlighted items while there is a highlight. The pale bars count every item the
190
+ * filters match. Neither count applies the card's own filter, so a value the viewer has filtered
191
+ * out keeps its count.
110
192
  *
111
193
  * What the card shows depends on the kind of field:
112
194
  *
113
- * - A category or cluster card lists the values with most items in view, each with Highlight and
114
- * Filter buttons.
115
- * - A number or date card draws a histogram, and the viewer drags across it to pick a range to
116
- * filter or highlight by. From the keyboard, they press Shift and the arrow keys on the focused
117
- * plot, then Enter. Counts over about 100,000 items come from a sample, so they are marked "≈".
195
+ * - A category card, and the card of a `flat` layer, lists the values with most items in view,
196
+ * each with its counts. A row's Highlight and Filter buttons take the counts' place while the
197
+ * pointer is over the row or the keyboard's focus is in it, and are always drawn on a screen with
198
+ * no pointer that hovers. A button is filled, and its icon solid, while its value is highlighted
199
+ * or filtered to, and before the counts a tick marks a value the filter keeps and a pen one that
200
+ * is highlighted. A screen reader is given each count with what it counts.
201
+ * - The card of a `tiered`, `nested` or `dag` layer walks the layer by its parent edges, and
202
+ * walking it leaves the map's filter as it is. A breadcrumb above the rows holds the card's
203
+ * position, from All, the layer's top level, down to the cluster whose children are listed. Each
204
+ * crumb is a button that steps back to it, and a tick marks a crumb whose cluster the filter is
205
+ * on. The rows are the served children of the position, in the server's order, a page at a time:
206
+ * More loads the next page. Each row counts the items that match every filter, this layer's
207
+ * clauses among them, beside every item of it the viewer may see, so a row whose count is 0 is
208
+ * still listed, and each count is what filtering to the row would leave. A row with children
209
+ * ends in a button with their number, which steps into the row. Filtering to a row adds a
210
+ * clause, and a layer's clauses combine with AND. The rows of a `tiered` layer stand under the
211
+ * titles of their levels, and a row of a `dag` layer names its other served parents. Once every
212
+ * child of the position has loaded, a last row counts the items of the position that match every
213
+ * filter and are in none of its children.
214
+ * - The card of a `stacked` layer lists the clusters with most items in view, under every filter,
215
+ * this layer's clauses among them, at the chosen level or at the level the map draws. A row at a
216
+ * level with a finer level after it ends in a button that steps into it, and the card then lists
217
+ * the clusters of that finer level by their items inside the row, under a heading that says so.
218
+ * - On the card of a `tiered`, `nested`, `dag` or `stacked` layer, the layer's filter and highlight
219
+ * clauses stand in a line of their own under the breadcrumb, each with a × that removes it, so a
220
+ * clause set by another control is shown wherever the card's position is. Set `hide-chips` to
221
+ * leave that line out where the clauses are shown already, as `<mosaica-filter-panel>` does on
222
+ * the cards it shows while it shows the clauses as chips of its own. The ticks on the crumbs and
223
+ * the rows stay.
224
+ * - A number or date card draws a histogram of up to 20 bins, and the viewer presses a bin or drags
225
+ * across several to pick a range to filter or highlight by. From the keyboard, they press Shift
226
+ * and the arrow keys on the focused plot, then Enter. Either opens a box under the range with its
227
+ * two ends in fields that take a typed number or date. A field left empty leaves that end open.
228
+ * The box closes on Escape, on a press outside it and as focus leaves it.
229
+ * A field that holds no number or date, or a lower end above the upper, is not applied, and the
230
+ * box says what to type. Set range, a button drawn only while the keyboard has it, opens the
231
+ * same box with nothing chosen. A screen reader is given the range each of the filter and the
232
+ * highlight holds, and the bins' counts in a table. Counts over about 100,000 items come from a
233
+ * sample, so they are marked "≈".
234
+ * - A bool card lists yes and no as a category card lists values. Its counts are taken as a
235
+ * histogram's, so those over about 100,000 items are marked "≈".
118
236
  * - A text or keyword field's card is a search box, `<mosaica-filter>`.
119
237
  *
120
238
  * While the paint button colours the map by the field, clicking a value's colour opens a picker
121
239
  * that changes the colour on the map.
122
240
  *
241
+ * A cluster with no name is called by its key, drawn in the secondary ink and the identifiers'
242
+ * font, and a clause on it carries the key as its label. A cluster with neither is called Unnamed.
243
+ *
244
+ * A long name is one line, cut with an ellipsis. Where `--mosaica-long-text` is `normal`, as in the
245
+ * explorer's narrow form, the card's name, what it notes and its rows' names wrap.
246
+ *
123
247
  * @summary A card that charts one field's values and filters or highlights by them.
124
248
  * @tagname mosaica-field-card
125
249
  * @category Elements
126
250
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-filterchange']>} mosaica-filterchange - A row's
127
251
  * Highlight or Filter, or the range box, changed the field's clause.
128
252
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-clausechange']>} mosaica-clausechange - A cluster
129
- * row, its Highlight or its Filter put a `member_of` clause on or took it off.
253
+ * row, its Highlight or its Filter, or a clause's × put a `member_of` clause on or took it off.
130
254
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-colourchange']>} mosaica-colourchange - The paint
131
255
  * button changed what the map is coloured by.
132
256
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-levelchange']>} mosaica-levelchange - The Level
133
- * choice of a levelled layer's card changed.
257
+ * choice of a levelled layer's card changed. The detail names the card's layer, and a `level` of
258
+ * `null` for Follow zoom.
134
259
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-valuecolour']>} mosaica-valuecolour - A category
135
260
  * value's colour was chosen or reset in the colour picker.
136
261
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-clustercolour']>} mosaica-clustercolour - A
@@ -138,34 +263,71 @@ function binsOf(table, timestamp) {
138
263
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-fold']>} mosaica-fold - The fold button folded or
139
264
  * opened the card.
140
265
  * @csspart head - The heading: the name, what it notes, and the buttons.
141
- * @csspart title - The field's name.
266
+ * @csspart title - The field's name, a heading that names the card.
142
267
  * @csspart sub - What the heading notes: the field's figures, or the Level choice.
143
- * @csspart level-select - The Level choice, on a levelled layer's card.
268
+ * @csspart level-select - The Level choice, on a levelled layer's card. Where no level declares a
269
+ * zoom range, its first option is Follow zoom.
144
270
  * @csspart spark - The small chart of a folded card.
145
271
  * @csspart paint - The paint button, with `aria-pressed`.
146
272
  * @csspart fold - The fold button, with `aria-expanded`.
147
- * @csspart rows - A category or cluster card's rows.
273
+ * @csspart crumbs - The breadcrumb of a layer's card that steps: a navigation landmark named by
274
+ * the layer, holding a list of the card's position from All down.
275
+ * @csspart crumb - One crumb, with `data-id`, which is empty for All, and `data-filtered` while the
276
+ * layer's filter is on its cluster.
277
+ * @csspart crumb-name - A crumb's button, which steps back to it, with `aria-current` on the
278
+ * deepest. A tick before the name marks a cluster the filter is on, and the name carries
279
+ * `data-key-name` where it is the cluster's key.
280
+ * @csspart chips - The layer's filter and highlight clauses on a card that steps, a list, while it
281
+ * has any and `hide-chips` is not set.
282
+ * @csspart chip - One clause, with `data-id` and `data-verb` (`filter` or `highlight`).
283
+ * @csspart chip-name - A clause's cluster, after a tick for a filter or a pen for a highlight. The
284
+ * name carries `data-key-name` where it is the cluster's key.
285
+ * @csspart chip-remove - The × that removes a clause.
286
+ * @csspart level - A heading over a list of rows: a level's title on a `tiered` layer's card, or
287
+ * what a `stacked` layer's card lists inside its position.
288
+ * @csspart rows - A category or cluster card's rows, one list for each level on a `tiered` layer's
289
+ * card.
148
290
  * @csspart row - One row, with `data-key` and `data-state`: `in` if the field's filter keeps the
149
291
  * value, `out` if it leaves it out, `lit` if it is highlighted, otherwise empty.
150
- * @csspart swatch - A row's colour button, while the map is coloured by the field. It opens the
151
- * colour picker. It carries `data-unmet`, and is drawn as an outline, for a value the map has not
152
- * drawn yet and so has not given a colour.
153
- * @csspart name - A row's name, with `data-unnamed` for a cluster that has none.
154
- * @csspart path - A cluster row's parents.
292
+ * @csspart swatch - A row's colour button, while the map is coloured by the field: a 24 px target
293
+ * with the colour, set as `--c`, in a 10 px square at its middle. It opens the colour picker. It
294
+ * carries `data-unmet`, and the square is drawn as an outline, for a value the map has not drawn
295
+ * yet and so has not given a colour.
296
+ * @csspart name - A row's name, with the whole name as its `title`. Where a cluster has none it is
297
+ * the cluster's key, with `data-key-name`, and where it has no key either it is the word Unnamed,
298
+ * with `data-unnamed`.
299
+ * @csspart step - A row's button that steps into it, with the number of its children on a layer
300
+ * with parent edges.
301
+ * @csspart also - The other served parents a `dag` row of a layer's card is under, each with
302
+ * `data-key-name` where it is called by its key.
303
+ * @csspart remainder - The last row of the last list, which counts the position's items in none of
304
+ * its children.
305
+ * @csspart refusal - The words "Clusters unavailable", with `data-code`, where the rows were
306
+ * refused.
155
307
  * @csspart bar-match - A row's or a bin's pale bar, its share of the whole match.
156
308
  * @csspart bar-subject - A row's or a bin's solid bar, its share of the subject.
157
- * @csspart count - A row's counts.
158
- * @csspart verbs - A row's Highlight and Filter buttons.
309
+ * @csspart count - A row's counts as they are drawn, after its tick and its pen, with what they
310
+ * count as its `title`. A screen reader is given them beside it, each with what it counts.
311
+ * @csspart verbs - A row's Highlight and Filter buttons, in its counts' place.
159
312
  * @csspart highlight - A row's Highlight button, with `aria-pressed`.
160
313
  * @csspart filter - A row's Filter button, with `aria-pressed`.
161
- * @csspart more - The "N more" button.
314
+ * @csspart more - The "N more" button. On a layer's card walked by its parent edges it loads the
315
+ * next page and moves focus to the first row it added; elsewhere it shows more rows, or fewer. Its
316
+ * accessible name says what it shows.
162
317
  * @csspart plot - The histogram, a focusable group.
163
318
  * @csspart bin - One bin, with `data-index`.
164
319
  * @csspart band - An outlined range on the histogram, with `data-verb` (`filter`, `highlight`, or
165
- * `brush` while one is being chosen).
320
+ * `brush` while one is being chosen). The highlight's is hatched.
166
321
  * @csspart axis - The values under the histogram.
167
322
  * @csspart ramp - The colour ramp under the histogram, while the map is coloured by the field.
168
- * @csspart brush - The box a dragged range opens.
323
+ * @csspart range - The Set range button, with `aria-expanded`, drawn over the start of the axis
324
+ * while it has the keyboard's focus.
325
+ * @csspart brush - The range box, which a chosen range or Set range opens over what is under the
326
+ * histogram.
327
+ * @csspart brush-from - The field for its lower end, with `aria-invalid` while what it holds is not
328
+ * applied.
329
+ * @csspart brush-to - The field for its upper end, with `aria-invalid` while what it holds is not
330
+ * applied.
169
331
  * @csspart brush-filter - Its Filter button.
170
332
  * @csspart brush-highlight - Its Highlight button.
171
333
  * @csspart colour-popover - The colour picker, while it is open.
@@ -188,35 +350,50 @@ let MosaicaFieldCard = (() => {
188
350
  let _compact_decorators;
189
351
  let _compact_initializers = [];
190
352
  let _compact_extraInitializers = [];
353
+ let _hideChips_decorators;
354
+ let _hideChips_initializers = [];
355
+ let _hideChips_extraInitializers = [];
191
356
  let _level_decorators;
192
357
  let _level_initializers = [];
193
358
  let _level_extraInitializers = [];
194
359
  let _expanded_decorators;
195
360
  let _expanded_initializers = [];
196
361
  let _expanded_extraInitializers = [];
197
- let _shownRow_decorators;
198
- let _shownRow_initializers = [];
199
- let _shownRow_extraInitializers = [];
362
+ let _position_decorators;
363
+ let _position_initializers = [];
364
+ let _position_extraInitializers = [];
365
+ let _said_decorators;
366
+ let _said_initializers = [];
367
+ let _said_extraInitializers = [];
200
368
  let _brush_decorators;
201
369
  let _brush_initializers = [];
202
370
  let _brush_extraInitializers = [];
371
+ let _entry_decorators;
372
+ let _entry_initializers = [];
373
+ let _entry_extraInitializers = [];
203
374
  return class MosaicaFieldCard extends _classSuper {
204
375
  static {
205
376
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
206
377
  _field_decorators = [property()];
207
378
  _folded_decorators = [property({ type: Boolean, reflect: true })];
208
379
  _compact_decorators = [property({ type: Boolean, reflect: true })];
380
+ _hideChips_decorators = [property({ type: Boolean, attribute: 'hide-chips' })];
209
381
  _level_decorators = [property({ type: Number, attribute: 'cluster-level' })];
210
382
  _expanded_decorators = [state()];
211
- _shownRow_decorators = [state()];
383
+ _position_decorators = [state()];
384
+ _said_decorators = [state()];
212
385
  _brush_decorators = [state()];
386
+ _entry_decorators = [state()];
213
387
  __esDecorate(this, null, _field_decorators, { kind: "accessor", name: "field", static: false, private: false, access: { has: obj => "field" in obj, get: obj => obj.field, set: (obj, value) => { obj.field = value; } }, metadata: _metadata }, _field_initializers, _field_extraInitializers);
214
388
  __esDecorate(this, null, _folded_decorators, { kind: "accessor", name: "folded", static: false, private: false, access: { has: obj => "folded" in obj, get: obj => obj.folded, set: (obj, value) => { obj.folded = value; } }, metadata: _metadata }, _folded_initializers, _folded_extraInitializers);
215
389
  __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);
390
+ __esDecorate(this, null, _hideChips_decorators, { kind: "accessor", name: "hideChips", static: false, private: false, access: { has: obj => "hideChips" in obj, get: obj => obj.hideChips, set: (obj, value) => { obj.hideChips = value; } }, metadata: _metadata }, _hideChips_initializers, _hideChips_extraInitializers);
216
391
  __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);
217
392
  __esDecorate(this, null, _expanded_decorators, { kind: "accessor", name: "expanded", static: false, private: false, access: { has: obj => "expanded" in obj, get: obj => obj.expanded, set: (obj, value) => { obj.expanded = value; } }, metadata: _metadata }, _expanded_initializers, _expanded_extraInitializers);
218
- __esDecorate(this, null, _shownRow_decorators, { kind: "accessor", name: "shownRow", static: false, private: false, access: { has: obj => "shownRow" in obj, get: obj => obj.shownRow, set: (obj, value) => { obj.shownRow = value; } }, metadata: _metadata }, _shownRow_initializers, _shownRow_extraInitializers);
393
+ __esDecorate(this, null, _position_decorators, { kind: "accessor", name: "position", static: false, private: false, access: { has: obj => "position" in obj, get: obj => obj.position, set: (obj, value) => { obj.position = value; } }, metadata: _metadata }, _position_initializers, _position_extraInitializers);
394
+ __esDecorate(this, null, _said_decorators, { kind: "accessor", name: "said", static: false, private: false, access: { has: obj => "said" in obj, get: obj => obj.said, set: (obj, value) => { obj.said = value; } }, metadata: _metadata }, _said_initializers, _said_extraInitializers);
219
395
  __esDecorate(this, null, _brush_decorators, { kind: "accessor", name: "brush", static: false, private: false, access: { has: obj => "brush" in obj, get: obj => obj.brush, set: (obj, value) => { obj.brush = value; } }, metadata: _metadata }, _brush_initializers, _brush_extraInitializers);
396
+ __esDecorate(this, null, _entry_decorators, { kind: "accessor", name: "entry", static: false, private: false, access: { has: obj => "entry" in obj, get: obj => obj.entry, set: (obj, value) => { obj.entry = value; } }, metadata: _metadata }, _entry_initializers, _entry_extraInitializers);
220
397
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
221
398
  }
222
399
  static styles = [
@@ -239,6 +416,11 @@ let MosaicaFieldCard = (() => {
239
416
  gap: 8px;
240
417
  min-height: 24px;
241
418
  }
419
+ /* The name, the mark after it and what the name notes stand in the heading's own row: the
420
+ group draws no box of its own while a long name is cut. */
421
+ .named {
422
+ display: contents;
423
+ }
242
424
  /* Not a section heading, as the shared rule for a title part draws one. */
243
425
  [part='title'] {
244
426
  display: block;
@@ -249,14 +431,16 @@ let MosaicaFieldCard = (() => {
249
431
  letter-spacing: 0;
250
432
  text-transform: none;
251
433
  color: var(--_mosaica-ink);
252
- white-space: nowrap;
434
+ white-space: var(--_mosaica-long-text, nowrap);
435
+ overflow: hidden;
436
+ text-overflow: ellipsis;
253
437
  }
254
438
  [part='sub'] {
255
439
  flex: 1 1 auto;
256
440
  min-width: 0;
257
441
  font-size: 12px;
258
442
  color: var(--_mosaica-ink-3);
259
- white-space: nowrap;
443
+ white-space: var(--_mosaica-long-text, nowrap);
260
444
  overflow: hidden;
261
445
  text-overflow: ellipsis;
262
446
  }
@@ -264,7 +448,10 @@ let MosaicaFieldCard = (() => {
264
448
  flex: 0 1 auto;
265
449
  margin-right: auto;
266
450
  }
451
+ /* 24 px to press, which the heading does not grow for. */
267
452
  [part='sub'] select {
453
+ padding-block: 4px;
454
+ margin-block: -4px;
268
455
  font-size: 12px;
269
456
  color: var(--_mosaica-ink-3);
270
457
  }
@@ -282,6 +469,7 @@ let MosaicaFieldCard = (() => {
282
469
  min-height: 1px;
283
470
  border-radius: 1px 1px 0 0;
284
471
  background: var(--_mosaica-bar-match);
472
+ forced-color-adjust: none;
285
473
  }
286
474
  :host([compact]) [part='spark'] {
287
475
  width: 72px;
@@ -301,8 +489,6 @@ let MosaicaFieldCard = (() => {
301
489
  :host([compact][folded]) [part='title'] {
302
490
  flex: 0 1 auto;
303
491
  min-width: 0;
304
- overflow: hidden;
305
- text-overflow: ellipsis;
306
492
  }
307
493
  .painted {
308
494
  display: inline-flex;
@@ -317,6 +503,7 @@ let MosaicaFieldCard = (() => {
317
503
  }
318
504
  [part='paint'],
319
505
  [part='fold'] {
506
+ position: relative;
320
507
  flex: none;
321
508
  width: 22px;
322
509
  height: 22px;
@@ -325,6 +512,21 @@ let MosaicaFieldCard = (() => {
325
512
  border-radius: 5px;
326
513
  color: var(--_mosaica-ink-2);
327
514
  }
515
+ /* Each small button takes a press from 24 px around its middle, drawn no larger for it. */
516
+ [part='paint']::after,
517
+ [part='fold']::after,
518
+ [part='verbs'] button::after,
519
+ [part='more']::after {
520
+ content: '';
521
+ position: absolute;
522
+ inset: -2px;
523
+ }
524
+ [part='fold']::after {
525
+ inset: -1px;
526
+ }
527
+ :host([compact][folded]) [part='fold']::after {
528
+ inset: -1px -4px;
529
+ }
328
530
  [part='paint'] {
329
531
  border: 1px solid var(--_mosaica-line);
330
532
  background: var(--_mosaica-surface);
@@ -339,6 +541,48 @@ let MosaicaFieldCard = (() => {
339
541
  [part='paint'][aria-pressed='false']:hover {
340
542
  background: var(--_mosaica-surface-2);
341
543
  }
544
+ /* Where a host has long names wrap, the name and what follows it are one group: what follows
545
+ goes under the name only where the two do not fit on a line, and the chart and the buttons
546
+ go under the group, at the right, where the name's longest word does not fit beside them. */
547
+ @container style(--_mosaica-long-text: normal) {
548
+ [part='head'] {
549
+ flex-wrap: wrap;
550
+ justify-content: flex-end;
551
+ gap: 2px 8px;
552
+ }
553
+ .named {
554
+ flex: 1000 1 min-content;
555
+ min-width: 0;
556
+ max-width: max-content;
557
+ margin-right: auto;
558
+ display: flex;
559
+ flex-wrap: wrap;
560
+ align-items: center;
561
+ gap: 0 8px;
562
+ }
563
+ [part='title'] {
564
+ flex: 0 1 auto;
565
+ min-width: 0;
566
+ overflow-wrap: break-word;
567
+ }
568
+ [part='sub'] {
569
+ overflow-wrap: anywhere;
570
+ }
571
+ /* Two thirds of its width where the name beside it needs the rest. */
572
+ [part='spark'] {
573
+ flex: 1 0 40px;
574
+ width: auto;
575
+ max-width: 60px;
576
+ }
577
+ :host([compact]) [part='spark'] {
578
+ flex-basis: 48px;
579
+ max-width: 72px;
580
+ }
581
+ :host([compact][folded]) [part='head'],
582
+ :host([compact][folded]) .named {
583
+ column-gap: 6px;
584
+ }
585
+ }
342
586
  .body {
343
587
  margin-top: 6px;
344
588
  display: flex;
@@ -362,11 +606,14 @@ let MosaicaFieldCard = (() => {
362
606
  cursor: pointer;
363
607
  }
364
608
  [part~='row']:hover,
365
- [part~='row']:focus-within,
366
- [part~='row'][data-shown] {
609
+ [part~='row']:focus-within {
367
610
  background: var(--_mosaica-surface-2);
368
611
  }
369
- [part~='row'][data-state='out'] {
612
+ /* A value the field's filter leaves out: its words faint, its colour dimmed. */
613
+ [part~='row'][data-state='out'] [part='name'] {
614
+ color: var(--_mosaica-ink-3);
615
+ }
616
+ [part~='row'][data-state='out'] [part='swatch'] {
370
617
  opacity: 0.55;
371
618
  }
372
619
  [part~='row'][data-state='in'] [part='name'] {
@@ -381,45 +628,42 @@ let MosaicaFieldCard = (() => {
381
628
  display: grid;
382
629
  place-items: center;
383
630
  }
631
+ /* A 24 px target around a 10 px square of the value's colour, reaching into the row's
632
+ padding and the gap beside it. */
384
633
  [part='swatch'] {
634
+ width: 24px;
635
+ height: 24px;
636
+ margin: -6px;
637
+ display: grid;
638
+ place-items: center;
639
+ border-radius: 5px;
640
+ }
641
+ [part='swatch']::before {
642
+ content: '';
385
643
  width: 10px;
386
644
  height: 10px;
387
645
  border-radius: 2px;
388
646
  background: var(--c);
647
+ forced-color-adjust: none;
389
648
  }
390
649
  /* A value the map has given no colour yet: an outline, not a grey that reads as a colour. */
391
- [part='swatch'][data-unmet] {
650
+ [part='swatch'][data-unmet]::before {
392
651
  background: none;
393
652
  box-shadow: inset 0 0 0 1px var(--_mosaica-line-control);
394
653
  }
395
- button[part='swatch'] {
396
- cursor: pointer;
397
- }
398
654
  .label {
399
655
  display: flex;
400
656
  flex-direction: column;
401
657
  gap: 3px;
402
658
  min-width: 0;
403
659
  }
660
+ /* The name on one line, cut at its end, or wrapped where long names wrap. */
404
661
  .names {
405
- display: flex;
406
- align-items: baseline;
407
- gap: 6px;
408
- min-width: 0;
409
- white-space: nowrap;
410
- }
411
- [part='name'] {
412
- flex: 0 1 auto;
413
- overflow: hidden;
414
- text-overflow: ellipsis;
415
- }
416
- [part='path'] {
417
- flex: 1 1 0;
418
662
  min-width: 0;
663
+ white-space: var(--_mosaica-long-text, nowrap);
419
664
  overflow: hidden;
420
665
  text-overflow: ellipsis;
421
- font-size: 12px;
422
- color: var(--_mosaica-ink-3);
666
+ overflow-wrap: anywhere;
423
667
  }
424
668
  .bars {
425
669
  position: relative;
@@ -434,9 +678,11 @@ let MosaicaFieldCard = (() => {
434
678
  }
435
679
  [part='bar-match'] {
436
680
  background: var(--_mosaica-bar-match);
681
+ forced-color-adjust: none;
437
682
  }
438
683
  [part='bar-subject'] {
439
684
  background: var(--_bar, var(--_mosaica-bar));
685
+ forced-color-adjust: none;
440
686
  }
441
687
  :host([highlighting]) {
442
688
  --_bar: var(--_mosaica-bar-highlight);
@@ -455,8 +701,20 @@ let MosaicaFieldCard = (() => {
455
701
  :host([highlighting]) [part='count'] .sub {
456
702
  color: var(--_mosaica-highlight);
457
703
  }
458
- /* The verbs share the counts' cell and take their place while the row is hovered or holds
459
- focus. Hidden, they stay in the tab order, so Tab reaches them and shows them. */
704
+ /* Before a row's counts, a tick where the filter keeps its value and a pen where it is
705
+ highlighted, so neither is told by a colour alone. */
706
+ .cue {
707
+ display: inline-flex;
708
+ margin-right: 4px;
709
+ vertical-align: -1.5px;
710
+ color: var(--_mosaica-ink);
711
+ }
712
+ .cue.lit {
713
+ color: var(--_mosaica-highlight);
714
+ }
715
+ /* The verbs share the counts' cell and take their place while the row is hovered or the
716
+ keyboard's focus is in it. Hidden, they stay in the tab order, so Tab reaches them and
717
+ shows them. */
460
718
  [part='count'],
461
719
  [part='verbs'] {
462
720
  grid-column: 3;
@@ -470,17 +728,16 @@ let MosaicaFieldCard = (() => {
470
728
  pointer-events: none;
471
729
  }
472
730
  [part~='row']:hover [part='verbs'],
473
- [part~='row']:focus-within [part='verbs'],
474
- [part~='row'][data-shown] [part='verbs'] {
731
+ [part~='row']:has(:focus-visible) [part='verbs'] {
475
732
  opacity: 1;
476
733
  pointer-events: auto;
477
734
  }
478
735
  [part~='row']:hover [part='count'],
479
- [part~='row']:focus-within [part='count'],
480
- [part~='row'][data-shown] [part='count'] {
736
+ [part~='row']:has(:focus-visible) [part='count'] {
481
737
  visibility: hidden;
482
738
  }
483
739
  [part='verbs'] button {
740
+ position: relative;
484
741
  width: 22px;
485
742
  height: 22px;
486
743
  display: grid;
@@ -490,6 +747,7 @@ let MosaicaFieldCard = (() => {
490
747
  background: var(--_mosaica-surface);
491
748
  color: color-mix(in srgb, var(--_mosaica-ink) 82%, var(--_mosaica-surface));
492
749
  }
750
+ /* On, a verb is filled and its icon solid. */
493
751
  [part='verbs'] button[aria-pressed='true'] {
494
752
  border-color: var(--_mosaica-accent);
495
753
  background: var(--_mosaica-accent);
@@ -499,15 +757,216 @@ let MosaicaFieldCard = (() => {
499
757
  border-color: var(--_mosaica-highlight);
500
758
  background: var(--_mosaica-highlight);
501
759
  }
760
+ [part='verbs'] button[aria-pressed='true'] svg {
761
+ fill: currentColor;
762
+ }
763
+ /* A screen with no pointer that hovers cannot show the verbs on hover: there they are
764
+ always drawn, beside the counts. */
765
+ @media (hover: none) {
766
+ [part~='row'] {
767
+ grid-template-columns: 12px minmax(0, 1fr) auto auto;
768
+ }
769
+ [part='verbs'] {
770
+ grid-column: 4;
771
+ opacity: 1;
772
+ pointer-events: auto;
773
+ }
774
+ [part~='row'].steps {
775
+ grid-template-columns: 12px minmax(0, 1fr) auto auto var(--_mosaica-step-w, 30px);
776
+ }
777
+ [part='remainder'] {
778
+ grid-template-columns: 12px minmax(0, 1fr) auto 48px var(--_mosaica-step-w, 30px);
779
+ }
780
+ .step {
781
+ grid-column: 5;
782
+ }
783
+ [part~='row']:hover [part='count'],
784
+ [part~='row']:has(:focus-visible) [part='count'] {
785
+ visibility: visible;
786
+ }
787
+ }
502
788
  [part='more'] {
789
+ position: relative;
503
790
  align-self: flex-start;
504
791
  margin: 4px 0 0 20px;
505
792
  }
793
+ [part='more']::after {
794
+ inset: -4px 0;
795
+ }
506
796
  .none {
507
797
  padding: 4px 0 0 20px;
508
798
  font-size: 12px;
509
799
  color: var(--_mosaica-ink-3);
510
800
  }
801
+ /* The breadcrumb: each crumb a button with its name, cut where long. */
802
+ [part='crumbs'] ol,
803
+ [part='chips'] {
804
+ list-style: none;
805
+ margin: 0;
806
+ padding: 0;
807
+ display: flex;
808
+ flex-wrap: wrap;
809
+ align-items: center;
810
+ gap: 2px 4px;
811
+ font-size: 12px;
812
+ }
813
+ /* The first crumb's text lines up with the rows; its button's padding reaches past it. */
814
+ [part='crumbs'] ol {
815
+ margin: 0 -4px;
816
+ }
817
+ [part='crumb'] {
818
+ display: inline-flex;
819
+ align-items: center;
820
+ gap: 2px;
821
+ min-width: 0;
822
+ max-width: 100%;
823
+ }
824
+ .sep {
825
+ margin-right: 2px;
826
+ color: var(--_mosaica-ink-3);
827
+ }
828
+ /* 24 px high, the name cut where long. */
829
+ [part='crumb-name'] {
830
+ display: inline-flex;
831
+ align-items: center;
832
+ min-width: 0;
833
+ max-width: 100%;
834
+ min-height: 24px;
835
+ padding: 0 4px;
836
+ border-radius: 4px;
837
+ color: var(--_mosaica-ink-2);
838
+ }
839
+ [part='crumb-name']:hover {
840
+ background: var(--_mosaica-surface-2);
841
+ }
842
+ [part='crumb-name'][aria-current] {
843
+ font-weight: 600;
844
+ color: var(--_mosaica-ink);
845
+ }
846
+ .crumb-text {
847
+ min-width: 0;
848
+ white-space: var(--_mosaica-long-text, nowrap);
849
+ overflow: hidden;
850
+ text-overflow: ellipsis;
851
+ overflow-wrap: anywhere;
852
+ }
853
+ /* The layer's clauses, each a chip with its ×, on a line apart from the breadcrumb. */
854
+ [part='chips'] {
855
+ gap: 4px;
856
+ }
857
+ [part='chip'] {
858
+ display: inline-flex;
859
+ align-items: center;
860
+ gap: 4px;
861
+ min-width: 0;
862
+ max-width: 100%;
863
+ height: 22px;
864
+ padding: 0 0 0 6px;
865
+ border: 1px solid var(--_mosaica-line);
866
+ border-radius: 5px;
867
+ background: var(--_mosaica-surface);
868
+ }
869
+ [part='chip'][data-verb='highlight'] {
870
+ border-color: color-mix(in srgb, var(--_mosaica-highlight) 18%, var(--_mosaica-highlight-soft));
871
+ background: var(--_mosaica-highlight-soft);
872
+ color: var(--_mosaica-highlight);
873
+ }
874
+ [part='chip'] .cue {
875
+ margin-right: 0;
876
+ color: inherit;
877
+ }
878
+ [part='chip-name'] {
879
+ min-width: 0;
880
+ white-space: var(--_mosaica-long-text, nowrap);
881
+ overflow: hidden;
882
+ text-overflow: ellipsis;
883
+ overflow-wrap: anywhere;
884
+ }
885
+ /* Drawn 20 px square, with a 24 px target around it. */
886
+ [part='chip-remove'] {
887
+ position: relative;
888
+ flex: none;
889
+ width: 20px;
890
+ height: 20px;
891
+ display: grid;
892
+ place-items: center;
893
+ border-radius: 4px;
894
+ color: inherit;
895
+ }
896
+ [part='chip-remove']::after {
897
+ content: '';
898
+ position: absolute;
899
+ inset: -2px;
900
+ }
901
+ [part='chip-remove']:hover {
902
+ background: var(--_mosaica-surface-2);
903
+ }
904
+ [part='level'] {
905
+ margin: 4px 0 0;
906
+ font-size: 12px;
907
+ font-weight: 600;
908
+ line-height: 1.45;
909
+ letter-spacing: 0;
910
+ text-transform: none;
911
+ color: var(--_mosaica-ink-2);
912
+ }
913
+ [part='also'] {
914
+ padding-left: 20px;
915
+ font-size: 12px;
916
+ color: var(--_mosaica-ink-3);
917
+ overflow-wrap: anywhere;
918
+ }
919
+ [part='also'] [data-key-name] {
920
+ color: inherit;
921
+ }
922
+ [part='remainder'] {
923
+ display: grid;
924
+ grid-template-columns: 12px minmax(0, 1fr) auto var(--_mosaica-step-w, 30px);
925
+ align-items: center;
926
+ column-gap: 8px;
927
+ padding: 4px 6px;
928
+ margin: 0 -6px;
929
+ font-size: 12px;
930
+ color: var(--_mosaica-ink-2);
931
+ }
932
+ /* A row that steps has a fourth column, kept on a row with nothing to step into so the
933
+ counts stand in line. */
934
+ [part~='row'].steps {
935
+ grid-template-columns: 12px minmax(0, 1fr) auto var(--_mosaica-step-w, 30px);
936
+ }
937
+ .step {
938
+ grid-column: 4;
939
+ grid-row: 1;
940
+ justify-self: end;
941
+ }
942
+ /* 22 px high, with a 24 px target around it. */
943
+ [part='step'] {
944
+ position: relative;
945
+ display: inline-flex;
946
+ align-items: center;
947
+ justify-content: flex-end;
948
+ gap: 2px;
949
+ min-width: 24px;
950
+ height: 22px;
951
+ box-sizing: border-box;
952
+ padding: 0 3px 0 6px;
953
+ border: 1px solid var(--_mosaica-line);
954
+ border-radius: 5px;
955
+ background: var(--_mosaica-surface);
956
+ font-size: 12px;
957
+ font-variant-numeric: tabular-nums;
958
+ color: var(--_mosaica-ink-2);
959
+ }
960
+ [part='step']::after {
961
+ content: '';
962
+ position: absolute;
963
+ inset: -1px 0;
964
+ }
965
+ [part='step']:hover {
966
+ border-color: var(--_mosaica-ink-3);
967
+ background: var(--_mosaica-surface-3);
968
+ color: var(--_mosaica-ink);
969
+ }
511
970
  /* The histogram. */
512
971
  .hist {
513
972
  position: relative;
@@ -520,7 +979,7 @@ let MosaicaFieldCard = (() => {
520
979
  align-items: flex-end;
521
980
  gap: 2px;
522
981
  cursor: crosshair;
523
- touch-action: none;
982
+ touch-action: pan-y;
524
983
  user-select: none;
525
984
  border-radius: 2px;
526
985
  }
@@ -554,14 +1013,16 @@ let MosaicaFieldCard = (() => {
554
1013
  [part~='band'][data-verb='brush'] {
555
1014
  border-style: dashed;
556
1015
  }
1016
+ /* Hatched, so it differs from the filter's range by more than its colour. */
557
1017
  [part~='band'][data-verb='highlight'] {
558
1018
  border-color: var(--_mosaica-highlight);
559
- background: color-mix(in srgb, var(--_mosaica-highlight) 8%, transparent);
1019
+ background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--_mosaica-highlight) 24%, transparent) 0 1.5px, color-mix(in srgb, var(--_mosaica-highlight) 6%, transparent) 1.5px 6px);
560
1020
  }
561
1021
  [part='ramp'] {
562
1022
  height: 6px;
563
1023
  margin-top: 4px;
564
1024
  border-radius: 2px;
1025
+ forced-color-adjust: none;
565
1026
  }
566
1027
  [part='axis'] {
567
1028
  display: flex;
@@ -571,6 +1032,21 @@ let MosaicaFieldCard = (() => {
571
1032
  color: var(--_mosaica-ink-2);
572
1033
  font-variant-numeric: tabular-nums;
573
1034
  }
1035
+ /* The button that opens the range box without a drag is drawn only while the keyboard has
1036
+ it, over the start of the axis, so the card is no taller for it. */
1037
+ [part='range'] {
1038
+ position: absolute;
1039
+ left: 0;
1040
+ top: ${PLOT_HEIGHT + 2}px;
1041
+ z-index: 1;
1042
+ }
1043
+ /* Off screen it has no box either, which the shared button's height and padding would give it. */
1044
+ [part='range']:not(:focus) {
1045
+ width: 1px;
1046
+ height: 1px;
1047
+ padding: 0;
1048
+ border: 0;
1049
+ }
574
1050
  [part='brush'] {
575
1051
  position: absolute;
576
1052
  top: ${PLOT_HEIGHT + 8}px;
@@ -591,6 +1067,43 @@ let MosaicaFieldCard = (() => {
591
1067
  color: var(--_mosaica-ink);
592
1068
  font-weight: 600;
593
1069
  }
1070
+ .chosen:empty {
1071
+ display: none;
1072
+ }
1073
+ /* The two fields share the width the range's words and the buttons give the box, each wide
1074
+ enough for a date. */
1075
+ .ends {
1076
+ display: flex;
1077
+ align-items: center;
1078
+ gap: 6px;
1079
+ }
1080
+ .ends input {
1081
+ flex: 1 1 0;
1082
+ width: 0;
1083
+ min-width: 84px;
1084
+ height: 26px;
1085
+ padding: 0 6px;
1086
+ font-size: 12px;
1087
+ }
1088
+ /* A field that is not applied: a heavier border, with the words under it. */
1089
+ .ends input[aria-invalid='true'] {
1090
+ padding: 0 5px;
1091
+ border: 2px solid var(--_mosaica-refuse);
1092
+ }
1093
+ /* The words wrap inside the box, and make it no wider. With nothing to say they take no room. */
1094
+ .mistakes {
1095
+ display: flex;
1096
+ flex-direction: column;
1097
+ margin-top: -8px;
1098
+ }
1099
+ .mistakes span:not(:empty) {
1100
+ width: 0;
1101
+ min-width: 100%;
1102
+ margin-top: 8px;
1103
+ white-space: normal;
1104
+ color: var(--_mosaica-refuse);
1105
+ font-weight: 500;
1106
+ }
594
1107
  [part='brush'] .acts {
595
1108
  display: flex;
596
1109
  gap: 6px;
@@ -616,14 +1129,19 @@ let MosaicaFieldCard = (() => {
616
1129
  .text {
617
1130
  margin-top: 6px;
618
1131
  }
619
- /* Read out, not drawn. */
620
- .hidden {
621
- position: absolute;
622
- width: 1px;
623
- height: 1px;
624
- overflow: hidden;
625
- clip-path: inset(50%);
626
- white-space: nowrap;
1132
+ /* Without a colouring, a bar is not content, and takes a system colour. */
1133
+ @media (forced-colors: active) {
1134
+ [part='bar-match'],
1135
+ [part='spark'] span {
1136
+ background: GrayText;
1137
+ }
1138
+ [part='bar-subject'],
1139
+ :host([compact]) [part='spark'] span {
1140
+ background: CanvasText;
1141
+ }
1142
+ :host([highlighting]) [part='bar-subject'] {
1143
+ background: Highlight;
1144
+ }
627
1145
  }
628
1146
  `
629
1147
  ];
@@ -639,41 +1157,86 @@ let MosaicaFieldCard = (() => {
639
1157
  /** Draws a folded card in the compact style the explorer uses in narrow containers. */
640
1158
  get compact() { return this.#compact_accessor_storage; }
641
1159
  set compact(value) { this.#compact_accessor_storage = value; }
642
- #level_accessor_storage = (__runInitializers(this, _compact_extraInitializers), __runInitializers(this, _level_initializers, null));
643
- /** The level a card for a layer with levels counts at. `null` is the coarsest level. */
1160
+ #hideChips_accessor_storage = (__runInitializers(this, _compact_extraInitializers), __runInitializers(this, _hideChips_initializers, false));
1161
+ /** Leaves out the layer's clauses as chips, for a card placed where they are shown already. */
1162
+ get hideChips() { return this.#hideChips_accessor_storage; }
1163
+ set hideChips(value) { this.#hideChips_accessor_storage = value; }
1164
+ #level_accessor_storage = (__runInitializers(this, _hideChips_extraInitializers), __runInitializers(this, _level_initializers, null));
1165
+ /**
1166
+ * The level a card for a layer with levels counts at. `null` is the level the map draws: where no
1167
+ * level declares a zoom range, the finest that is not crowded in view, else the coarsest served.
1168
+ */
644
1169
  get level() { return this.#level_accessor_storage; }
645
1170
  set level(value) { this.#level_accessor_storage = value; }
646
1171
  #expanded_accessor_storage = (__runInitializers(this, _level_extraInitializers), __runInitializers(this, _expanded_initializers, false));
647
1172
  /** Whether "N more" was pressed. @internal */
648
1173
  get expanded() { return this.#expanded_accessor_storage; }
649
1174
  set expanded(value) { this.#expanded_accessor_storage = value; }
650
- #shownRow_accessor_storage = (__runInitializers(this, _expanded_extraInitializers), __runInitializers(this, _shownRow_initializers, null));
651
- /** The row whose verbs a press showed, on a screen without hover. @internal */
652
- get shownRow() { return this.#shownRow_accessor_storage; }
653
- set shownRow(value) { this.#shownRow_accessor_storage = value; }
654
- #brush_accessor_storage = (__runInitializers(this, _shownRow_extraInitializers), __runInitializers(this, _brush_initializers, null));
1175
+ #position_accessor_storage = (__runInitializers(this, _expanded_extraInitializers), __runInitializers(this, _position_initializers, []));
1176
+ /**
1177
+ * Where a cluster card that steps stands in its layer: the clusters stepped into, the deepest
1178
+ * last, and none for the top level. @internal
1179
+ */
1180
+ get position() { return this.#position_accessor_storage; }
1181
+ set position(value) { this.#position_accessor_storage = value; }
1182
+ #said_accessor_storage = (__runInitializers(this, _position_extraInitializers), __runInitializers(this, _said_initializers, ''));
1183
+ /** What the card says loaded after the last step. @internal */
1184
+ get said() { return this.#said_accessor_storage; }
1185
+ set said(value) { this.#said_accessor_storage = value; }
1186
+ #brush_accessor_storage = (__runInitializers(this, _said_extraInitializers), __runInitializers(this, _brush_initializers, null));
655
1187
  /** A range being chosen on the histogram. @internal */
656
1188
  get brush() { return this.#brush_accessor_storage; }
657
1189
  set brush(value) { this.#brush_accessor_storage = value; }
658
- subjectCounts = (__runInitializers(this, _brush_extraInitializers), new HeldAggregate('field-subject'));
1190
+ #entry_accessor_storage = (__runInitializers(this, _brush_extraInitializers), __runInitializers(this, _entry_initializers, null));
1191
+ /** The range box's two fields, while it is open. @internal */
1192
+ get entry() { return this.#entry_accessor_storage; }
1193
+ set entry(value) { this.#entry_accessor_storage = value; }
1194
+ /** The part that opened the range box, which takes focus as it closes. */
1195
+ entryOpener = (__runInitializers(this, _entry_extraInitializers), 'plot');
1196
+ /** The ends of the last range the box could apply, as shares of the plot's width, which place the box. */
1197
+ boxAt = [0, 1];
1198
+ /** The control in the card that held focus as a render began, and the row it was in. */
1199
+ focusedBefore = null;
1200
+ /**
1201
+ * A step whose rows have not landed: the cluster stepped out of, whose row then takes focus, or
1202
+ * `null` for a step in, after which the first row does. `fresh` until the render after the step.
1203
+ */
1204
+ landing = null;
1205
+ subjectCounts = new HeldAggregate('field-subject');
659
1206
  matchCounts = new HeldAggregate('field-match');
660
1207
  figures = new HeldAggregate('field-summary');
661
1208
  /** The whole match's counts of the values in view its commonest do not list. */
662
1209
  outsideCounts = new HeldAggregate('field-outside');
663
1210
  unwatchChoices = null;
664
- /** The names of the clusters' parents. */
665
- paths = new ClusterPaths(() => this.requestUpdate());
1211
+ /** A linked layer's rows: the children of the position, or the roots, a page at a time. */
1212
+ lineage = new Lineage({
1213
+ store: () => this.resolvedStore,
1214
+ layer: () => {
1215
+ const layer = this.declaredLayer(this.resolvedStore?.get('meta') ?? null);
1216
+ return layer && drills(layer) ? layer : null;
1217
+ },
1218
+ limit: () => PAGE_ROWS,
1219
+ isOpen: () => false,
1220
+ changed: () => this.requestUpdate()
1221
+ });
1222
+ /** The items of the position in none of its children, once every child has loaded. */
1223
+ remainder = new HeldAggregate('field-remainder');
1224
+ remainderAsked = null;
1225
+ /** The counts in view asked last. */
1226
+ subjectAsked = null;
666
1227
  picker = new ColourPicker(this, () => this.getBoundingClientRect());
667
1228
  /** What the card last drew from, so a publish that changes none of it draws nothing. */
668
1229
  drawnFrom = [];
669
1230
  /**
670
- * Draw again only when what the card reads changed: its counts, the clauses, the colouring, the
671
- * view, or the palette and chosen colours a layer's clusters take. The store publishes as each
672
- * frame arrives, which changes none of these.
1231
+ * Draw again only when what the card reads changed: its counts, the clauses, the drawn region,
1232
+ * the colouring, the view, the palette and chosen colours a layer's clusters take, or the level a
1233
+ * levelled layer is drawn at. The store publishes as each frame arrives, which changes none of
1234
+ * these.
673
1235
  */
674
1236
  onStoreChange() {
675
1237
  const s = this.resolvedStore;
676
- const now = s ? [s.get('aggregates'), s.get('filters'), s.get('legend'), s.get('meta'), s.get('view').id, s.get('artifacts').palette, s.get('artifacts').overrides] : [];
1238
+ const layer = this.declaredLayer(s?.get('meta') ?? null);
1239
+ const now = s ? [s.get('aggregates'), s.get('filters'), s.get('region'), s.get('legend'), s.get('meta'), s.get('view').id, s.get('artifacts').palette, s.get('artifacts').overrides, layer ? this.levelOf(layer) : undefined] : [];
677
1240
  if (now.length === this.drawnFrom.length && now.every((v, i) => v === this.drawnFrom[i]))
678
1241
  return;
679
1242
  this.drawnFrom = now;
@@ -685,8 +1248,12 @@ let MosaicaFieldCard = (() => {
685
1248
  this.unwatchChoices = store ? watchChoices(store, () => this.requestUpdate()) : null;
686
1249
  }
687
1250
  resetServerData() {
688
- this.paths.reset();
1251
+ this.lineage.reset();
1252
+ this.position = [];
1253
+ this.landing = null;
1254
+ this.said = '';
689
1255
  this.brush = null;
1256
+ this.entry = null;
690
1257
  this.picker.close(false);
691
1258
  }
692
1259
  disconnectedCallback() {
@@ -694,6 +1261,7 @@ let MosaicaFieldCard = (() => {
694
1261
  this.matchCounts.set(null, null);
695
1262
  this.outsideCounts.set(null, null);
696
1263
  this.figures.set(null, null);
1264
+ this.remainder.set(null, null);
697
1265
  document.removeEventListener('pointerdown', this.onPressOutsideBrush, true);
698
1266
  super.disconnectedCallback();
699
1267
  }
@@ -714,7 +1282,9 @@ let MosaicaFieldCard = (() => {
714
1282
  const o = meta.filterOperands.find((x) => x.column === this.field);
715
1283
  if (!o)
716
1284
  return null;
717
- return o.family === 'category' ? 'category' : o.family === 'numeric' ? 'histogram' : 'search';
1285
+ if (o.family === 'numeric')
1286
+ return isBoolColumn(meta, this.field) ? 'bool' : 'histogram';
1287
+ return o.family === 'category' ? 'category' : 'search';
718
1288
  }
719
1289
  isTimestamp(meta) {
720
1290
  return meta?.declaredScalars.find((c) => c.name === this.field)?.arrowType === 'timestamp_us';
@@ -726,21 +1296,88 @@ let MosaicaFieldCard = (() => {
726
1296
  return colourLayers(meta.layers).some((l) => l.name === name);
727
1297
  return (kind === 'category' || kind === 'histogram') && meta.declaredScalars.some((c) => c.name === this.field && c.render);
728
1298
  }
729
- /** The level a levelled layer's card counts and browses at: the one chosen, else the top level. */
1299
+ /** The level a levelled layer's card counts and browses at: the one chosen, else the one the map draws. */
730
1300
  levelOf(layer) {
731
- return levelOf(layer, this.level, this.resolvedStore?.get('artifacts').served ?? []);
1301
+ return levelOfLayer(layer, this.level, this.resolvedStore?.get('artifacts').followedLevels);
1302
+ }
1303
+ /** How the card lists `layer`. */
1304
+ listing(layer) {
1305
+ if (layer.hierarchy.kind === 'flat')
1306
+ return 'ranked';
1307
+ return drills(layer) ? 'children' : 'overlap';
1308
+ }
1309
+ /** What the card calls a cluster no table names, from the label of its clause on this layer if it has one. */
1310
+ knownName(s, id) {
1311
+ return this.clauseCalled(s.get('filters').members.find((m) => m.artifact === id && m.layer === this.layerName)?.label, id);
1312
+ }
1313
+ /**
1314
+ * What the card calls a cluster with a clause labelled `label`: the label, which is the cluster's
1315
+ * key where a page served its row with a key and no name, else as a page served it, else Unnamed.
1316
+ */
1317
+ clauseCalled(label, id) {
1318
+ const met = this.lineage.knownName(id);
1319
+ if (label === undefined)
1320
+ return met ?? called(null, null);
1321
+ return met?.from === 'key' && met.text === label ? met : { text: label, from: 'name' };
1322
+ }
1323
+ /**
1324
+ * The level a ranked list counts at. The overlap list counts at the level after its position's,
1325
+ * and at the chosen or drawn level at the top.
1326
+ */
1327
+ listLevel(layer) {
1328
+ const deepest = this.listing(layer) === 'overlap' ? this.position.at(-1) : undefined;
1329
+ return deepest ? levelAfter(layer, deepest.rung) : this.levelOf(layer);
732
1330
  }
733
- /** The keys of the field's clause in `verb`: a category's values, or a layer's clusters by id. */
1331
+ /**
1332
+ * The rows a category or flat layer's card lists first, whatever their counts: those its clauses
1333
+ * name. A card that steps shows its layer's clauses apart from its rows.
1334
+ */
1335
+ pinned(store) {
1336
+ const layer = this.declaredLayer(store.get('meta'));
1337
+ return layer && this.listing(layer) !== 'ranked' ? [] : [...this.clauseKeys(store, 'filter'), ...this.clauseKeys(store, 'highlight')];
1338
+ }
1339
+ /** Point the browse pages at the position, with slots while the map is coloured by the layer, and fetch them where that moved. */
1340
+ browseIfStale() {
1341
+ const s = this.resolvedStore;
1342
+ const layer = s && this.isConnected ? this.declaredLayer(s.get('meta')) : null;
1343
+ if (!s || !layer || !drills(layer))
1344
+ return;
1345
+ this.lineage.top = this.position.at(-1)?.id ?? null;
1346
+ this.lineage.paletteSize = s.get('legend').colourBy === this.field ? paletteSize(s.get('artifacts').palette) : null;
1347
+ this.lineage.loadIfStale();
1348
+ }
1349
+ /**
1350
+ * The count of the position's items in none of its children, under every filter: a filter of its
1351
+ * own holds the position's `member_of` and one `none_of` over the children. Asked only once every
1352
+ * child has loaded.
1353
+ */
1354
+ remainderSpec(layer) {
1355
+ const deepest = this.position.at(-1);
1356
+ const { roots } = this.lineage;
1357
+ if (this.folded || this.listing(layer) !== 'children' || !deepest || !roots || roots.length === 0 || !this.lineage.complete)
1358
+ return null;
1359
+ const leaf = (artifact) => memberLeaf({ layer: layer.name, artifact, outside: false, verb: 'filter' });
1360
+ const ids = [...new Set(roots.map((n) => n.row.mosaicaId))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1361
+ return { groupings: [{ count: true }], filters: { all_of: [leaf(deepest.id), { none_of: ids.map(leaf) }] } };
1362
+ }
1363
+ /** The filter that keeps an overlap list inside its position, where it has one. */
1364
+ within(layer) {
1365
+ const deepest = this.position.at(-1);
1366
+ return deepest ? { filters: memberLeaf({ layer: layer.name, artifact: deepest.id, outside: false, verb: 'filter' }) } : {};
1367
+ }
1368
+ /** The keys of the field's clause in `verb`: a category's values, a bool's, or a layer's clusters by id. */
734
1369
  clauseKeys(store, verb) {
735
1370
  const name = this.layerName;
736
1371
  if (name !== null)
737
1372
  return new Set(store.get('filters').members.filter((m) => m.layer === name && m.verb === verb && !m.outside).map((m) => idString(m.artifact)));
738
1373
  const held = store.get('filters').draft[verb][this.field];
1374
+ if (isBoolColumn(store.get('meta'), this.field))
1375
+ return new Set(boolKeys(held));
739
1376
  return new Set(held?.family === 'category' ? held.keys : []);
740
1377
  }
741
1378
  /** The groupings a category or cluster card counts; the values its clauses name are always counted. */
742
1379
  rowGroupings(store, meta, kind, top) {
743
- const named = [...new Set([...this.clauseKeys(store, 'filter'), ...this.clauseKeys(store, 'highlight')])];
1380
+ const named = [...new Set(this.pinned(store))];
744
1381
  if (kind === 'category') {
745
1382
  const values = named.slice(0, meta.selection.maxAggregateNamed).sort();
746
1383
  return [{ count: true, by: { field: this.field, top } }, ...(values.length > 0 ? [{ count: true, by: { field: this.field, values } }] : [])];
@@ -750,14 +1387,14 @@ let MosaicaFieldCard = (() => {
750
1387
  return null;
751
1388
  return [this.ranked(layer, top), ...this.namedGroupings(layer, meta, named.map((id) => BigInt(id)))];
752
1389
  }
753
- /** The grouping ranking a layer's `top` clusters at the cut the map draws or the card's level; each row's slot colours its swatch. */
1390
+ /** The grouping ranking a layer's `top` clusters at the cut the map draws or at {@link listLevel}; each row's slot colours its swatch. */
754
1391
  ranked(layer, top) {
755
- return rankedGrouping(layer, top, this.levelOf(layer) ?? null);
1392
+ return rankedGrouping(layer, top, this.listLevel(layer) ?? null);
756
1393
  }
757
- /** The groupings counting `ids` of `layer` by name, leaving room for one more beside them. */
1394
+ /** The groupings counting `ids` of `layer`, each at the level the list counts at, by name, leaving room for one more beside them. */
758
1395
  namedGroupings(layer, meta, ids) {
759
- const level = this.levelOf(layer) ?? 0;
760
- const rows = [...new Set(ids)].map((id) => ({ mosaicaId: id, rung: this.paths.rungOf(id) ?? level }));
1396
+ const level = this.listLevel(layer) ?? 0;
1397
+ const rows = [...new Set(ids)].map((id) => ({ mosaicaId: id, rung: level }));
761
1398
  return rows.length === 0 ? [] : artifactGroupings(layer, rows, meta.selection, 'drawn').slice(0, meta.selection.maxAggregateGroupings - 1);
762
1399
  }
763
1400
  /**
@@ -780,60 +1417,177 @@ let MosaicaFieldCard = (() => {
780
1417
  .sort();
781
1418
  return values.length === 0 ? null : { groupings: [{ count: true, by: { field: this.field, values } }], without: this.field };
782
1419
  }
783
- /** The specs the card keeps registered: the subject's counts, the whole match's and a number's figures. */
1420
+ /**
1421
+ * The specs the card keeps registered: the subject's counts, the whole match's and a number's
1422
+ * figures. They are read in a render as well, so the overlap list reads only the answers to what
1423
+ * its position asks.
1424
+ */
784
1425
  specs(store, meta, kind) {
785
- const leaveOut = this.layerName !== null ? { withoutMembersOf: this.layerName } : { without: this.field };
786
- if (kind === 'search')
1426
+ const layer = this.declaredLayer(meta);
1427
+ const listing = layer ? this.listing(layer) : null;
1428
+ // A children list counts from its browse pages, and the overlap list under every clause.
1429
+ if (kind === 'search' || listing === 'children')
787
1430
  return { subject: null, match: null, figures: null };
788
- if (kind === 'histogram') {
789
- const bins = [{ count: true, by: { field: this.field, bins: BINS, sample: SAMPLE } }];
1431
+ const leaveOut = listing === 'overlap' ? this.within(layer) : this.layerName !== null ? { withoutMembersOf: this.layerName } : { without: this.field };
1432
+ if (kind === 'histogram' || kind === 'bool') {
1433
+ const bins = [{ count: true, by: { field: this.field, bins: kind === 'bool' ? BOOL_KEYS.length : BINS, sample: SAMPLE } }];
790
1434
  return {
791
1435
  subject: { groupings: bins, subject: 'view', highlighted: true, ...leaveOut },
792
1436
  match: this.folded ? null : { groupings: bins, ...leaveOut },
793
- figures: { groupings: [{ summary: [this.field] }], subject: 'visible' }
1437
+ figures: kind === 'bool' ? null : { groupings: [{ summary: [this.field] }], subject: 'visible' }
794
1438
  };
795
1439
  }
796
- const subject = this.rowGroupings(store, meta, kind, this.expanded ? MORE_ROWS : ROWS);
1440
+ const groupings = this.rowGroupings(store, meta, kind, this.expanded ? MORE_ROWS : ROWS);
1441
+ const subject = groupings ? { groupings, subject: 'view', highlighted: true, ...leaveOut } : null;
797
1442
  // A layer's whole match counts the clusters the subject lists and its clauses name, and ranks
798
1443
  // one more, whose table says how many there are; the search box names that number.
799
- const layer = this.declaredLayer(meta);
800
- const listed = [...(countsByKey(this.subjectCounts.entryFor(store))?.keys() ?? []), ...this.clauseKeys(store, 'filter'), ...this.clauseKeys(store, 'highlight')];
1444
+ const answered = listing === 'overlap' ? this.subjectCounts.entryOf(store, subject) : this.subjectCounts.entryFor(store);
1445
+ const listed = [...(countsByKey(answered)?.keys() ?? []), ...this.pinned(store)];
801
1446
  const match = kind === 'category'
802
1447
  ? this.rowGroupings(store, meta, kind, Math.min(MATCH_TOP, meta.selection.maxAggregateTop))
803
1448
  : layer
804
1449
  ? [...this.namedGroupings(layer, meta, listed.map((id) => BigInt(id))), this.ranked(layer, 1)]
805
1450
  : null;
806
1451
  return {
807
- subject: subject ? { groupings: subject, subject: 'view', highlighted: true, ...leaveOut } : null,
1452
+ subject,
808
1453
  match: match && !this.folded ? { groupings: match, ...leaveOut } : null,
809
1454
  figures: null
810
1455
  };
811
1456
  }
1457
+ willUpdate(changed) {
1458
+ super.willUpdate(changed);
1459
+ if (changed.has('field')) {
1460
+ this.position = [];
1461
+ this.landing = null;
1462
+ this.said = '';
1463
+ }
1464
+ // Before the render, so the rows of a position stepped out of are not drawn under the new one.
1465
+ this.browseIfStale();
1466
+ this.land();
1467
+ // The element of the card that holds focus, itself or in a search box's own shadow root.
1468
+ let el = this.isConnected ? document.activeElement : null;
1469
+ while (el && el.getRootNode() !== this.shadowRoot)
1470
+ el = el.shadowRoot?.activeElement ?? null;
1471
+ const row = el?.closest(HOLDERS) ?? null;
1472
+ const holder = row?.getAttribute('part') ?? '';
1473
+ this.focusedBefore = el ? { el: el, holder, row: row ? Array.from(this.renderRoot.querySelectorAll(`[part="${holder}"]`)).indexOf(row) : -1 } : null;
1474
+ }
1475
+ /** The deepest crumb's button. */
1476
+ deepestCrumb() {
1477
+ return this.renderRoot.querySelector('[part="crumb-name"][aria-current]');
1478
+ }
1479
+ /**
1480
+ * Give focus back where the render took it away. A row, crumb or chip that moved, as its value
1481
+ * joined or left a clause, keeps it; for one that went, the same button of the row, crumb or chip
1482
+ * now in its place takes it, else the one before's, else the search box, else the fold button of
1483
+ * a card with none. Focus in the search box stays there. Just after a step, focus in the card
1484
+ * goes to the deepest crumb until the rows land.
1485
+ */
1486
+ keepFocus() {
1487
+ const before = this.focusedBefore;
1488
+ this.focusedBefore = null;
1489
+ if (this.landing?.fresh) {
1490
+ this.landing.fresh = false;
1491
+ if (before)
1492
+ this.deepestCrumb()?.focus();
1493
+ return;
1494
+ }
1495
+ if (!before || !focusLost())
1496
+ return;
1497
+ if (before.el.isConnected)
1498
+ return before.el.focus();
1499
+ const search = this.renderRoot.querySelector('.search');
1500
+ if (before.row < 0) {
1501
+ if (before.el.classList.contains('search'))
1502
+ search?.focus();
1503
+ return;
1504
+ }
1505
+ const rows = Array.from(this.renderRoot.querySelectorAll(`[part="${before.holder}"]`));
1506
+ const same = (rows[before.row] ?? rows[before.row - 1])?.querySelector(`[part="${before.el.getAttribute('part')}"]`);
1507
+ (same ?? search ?? this.renderRoot.querySelector('[part="fold"]'))?.focus();
1508
+ }
1509
+ /**
1510
+ * The keys of the rows a card that steps lists for its position, once they have landed; `null`
1511
+ * while they are on their way.
1512
+ */
1513
+ landed(s, layer) {
1514
+ if (this.listing(layer) === 'children')
1515
+ return this.lineage.settled ? this.lineage.roots.map((n) => idString(n.row.mosaicaId)) : null;
1516
+ const entry = this.subjectCounts.entryOf(s, this.subjectAsked);
1517
+ if (!entry?.result && !entry?.refusal)
1518
+ return null;
1519
+ return this.rows(s, 'cluster', entry, undefined, false).map((r) => r.key);
1520
+ }
1521
+ /**
1522
+ * Once the rows of a step have landed, say what loaded, and once they are drawn move focus from
1523
+ * the deepest crumb to the row stepped out of or, after a step in, to the first row. Focus that
1524
+ * has gone elsewhere meanwhile stays there.
1525
+ */
1526
+ land() {
1527
+ const landing = this.landing;
1528
+ const s = this.resolvedStore;
1529
+ const layer = s ? this.declaredLayer(s.get('meta')) : null;
1530
+ // The render of the step itself still holds the answers to the position before.
1531
+ if (!landing || landing.fresh || !s || !layer)
1532
+ return;
1533
+ const keys = this.landed(s, layer);
1534
+ if (keys === null)
1535
+ return;
1536
+ this.landing = null;
1537
+ this.said = this.loadedText(layer, keys.length);
1538
+ const crumb = this.deepestCrumb();
1539
+ void this.updateComplete.then(() => {
1540
+ const active = this.shadowRoot?.activeElement ?? null;
1541
+ if (active !== crumb && !focusLost())
1542
+ return;
1543
+ const row = (key) => this.renderRoot.querySelector(`[part="row"][data-key="${key}"]`);
1544
+ const to = landing.out !== null ? row(idString(landing.out))?.querySelector('[part="step"]') : keys[0] === undefined ? null : row(keys[0])?.querySelector('[part="highlight"]');
1545
+ to?.focus();
1546
+ });
1547
+ }
1548
+ /** What a card that steps says has loaded: how many rows, of what. */
1549
+ loadedText(layer, n) {
1550
+ const deepest = this.position.at(-1);
1551
+ if (this.listing(layer) === 'overlap')
1552
+ return `${this.overlapTitle(layer) ?? layer.title ?? layer.name}: ${counted(n, 'cluster', 'clusters')}`;
1553
+ if (this.lineage.refusal)
1554
+ return '';
1555
+ if (!deepest)
1556
+ return counted(n, 'top-level cluster', 'top-level clusters');
1557
+ const total = deepest.children !== null && deepest.children > n ? ` of ${deepest.children.toLocaleString('en-GB')}` : '';
1558
+ return `${n.toLocaleString('en-GB')}${total} ${n === 1 && !total ? 'child' : 'children'} of ${deepest.name}`;
1559
+ }
812
1560
  updated(changed) {
813
1561
  super.updated(changed);
1562
+ this.keepFocus();
814
1563
  const s = this.resolvedStore;
815
1564
  const meta = s?.get('meta') ?? null;
816
1565
  const kind = this.kindOf(meta);
817
- this.toggleAttribute('highlighting', s ? this.highlighting(s) : false);
818
1566
  const live = this.isConnected && s !== null && meta !== null && kind !== null;
1567
+ const layer = live ? this.declaredLayer(meta) : null;
1568
+ // A children list's counts ignore the highlight, so they are not drawn as highlighted counts.
1569
+ this.toggleAttribute('highlighting', s !== null && this.highlighting(s) && !(layer && this.listing(layer) === 'children'));
819
1570
  const specs = live ? this.specs(s, meta, kind) : { subject: null, match: null, figures: null };
1571
+ this.subjectAsked = specs.subject;
1572
+ this.remainderAsked = layer ? this.remainderSpec(layer) : null;
820
1573
  this.subjectCounts.set(s, specs.subject);
1574
+ this.remainder.set(s, this.remainderAsked);
821
1575
  this.matchCounts.set(s, specs.match);
822
1576
  this.outsideCounts.set(s, live && kind === 'category' && !this.folded ? this.outsideSpec(s, meta) : null);
823
1577
  this.figures.set(s, specs.figures);
824
- if (changed.has('brush')) {
825
- if (this.brush?.open)
1578
+ if (changed.has('entry')) {
1579
+ if (this.entry)
826
1580
  document.addEventListener('pointerdown', this.onPressOutsideBrush, true);
827
1581
  else
828
1582
  document.removeEventListener('pointerdown', this.onPressOutsideBrush, true);
829
1583
  }
830
1584
  }
831
- /** A press outside an open range box closes it, as Escape does. */
1585
+ /** A press outside an open range box closes it, as Escape does; Set range closes it itself. */
832
1586
  onPressOutsideBrush = (e) => {
833
- const box = this.renderRoot.querySelector('[part="brush"]');
834
- if (box && e.composedPath().includes(box))
1587
+ const path = e.composedPath();
1588
+ if (Array.from(this.renderRoot.querySelectorAll('[part="brush"], [part="range"]')).some((el) => path.includes(el)))
835
1589
  return;
836
- this.brush = null;
1590
+ this.entry = null;
837
1591
  };
838
1592
  /** Whether a highlight is set, in which case the subject is the highlighted items. */
839
1593
  highlighting(store) {
@@ -852,6 +1606,14 @@ let MosaicaFieldCard = (() => {
852
1606
  const keys = was.includes(key) ? was.filter((k) => k !== key) : [...was, key];
853
1607
  this.setClause(verb, { family: 'category', keys });
854
1608
  }
1609
+ /** Put a bool's value in or out of the field's clause in `verb`. */
1610
+ toggleBool(key, verb) {
1611
+ const s = this.resolvedStore;
1612
+ if (!s)
1613
+ return;
1614
+ const was = boolKeys(s.get('filters').draft[verb][this.field]);
1615
+ this.setClause(verb, boolClause(was.includes(key) ? was.filter((k) => k !== key) : [...was, key]));
1616
+ }
855
1617
  /** Replace the field's clause in `verb`, and report what that position composes. */
856
1618
  setClause(verb, clause) {
857
1619
  const s = this.resolvedStore;
@@ -871,9 +1633,36 @@ let MosaicaFieldCard = (() => {
871
1633
  const artifact = BigInt(id);
872
1634
  const members = s.get('filters').members;
873
1635
  const on = members.some((m) => m.layer === layer && m.artifact === artifact && m.verb === verb && !m.outside);
874
- s.setMembers(on ? withoutMember(members, layer, artifact, verb) : withMember(members, { layer, artifact, outside: false, verb, ...(name === null ? {} : { label: name }) }));
1636
+ s.setMembers(on ? withoutMember(members, layer, artifact, verb) : withMember(members, { layer, artifact, outside: false, verb, ...labelOf(name) }));
875
1637
  emit(this, 'mosaica-clausechange', { id, layer, outside: false, verb, on: !on });
876
1638
  }
1639
+ /** Take one of this layer's clauses off. */
1640
+ removeClause(clause) {
1641
+ const s = this.resolvedStore;
1642
+ const layer = this.layerName;
1643
+ if (!s || layer === null)
1644
+ return;
1645
+ s.setMembers(withoutMember(s.get('filters').members, layer, clause.artifact, clause.verb));
1646
+ emit(this, 'mosaica-clausechange', { id: idString(clause.artifact), layer, outside: clause.outside, verb: clause.verb, on: false });
1647
+ }
1648
+ /** Step into a cluster, so the card lists what is inside it. The filter is left as it is. */
1649
+ stepIn(step) {
1650
+ this.position = [...this.position, step];
1651
+ this.stepped(null);
1652
+ }
1653
+ /** Step back to the crumb at `depth`, where 0 is All. */
1654
+ stepTo(depth) {
1655
+ if (depth >= this.position.length)
1656
+ return;
1657
+ const out = this.position[depth].id;
1658
+ this.position = this.position.slice(0, depth);
1659
+ this.stepped(out);
1660
+ }
1661
+ stepped(out) {
1662
+ this.landing = { out, fresh: true };
1663
+ this.said = '';
1664
+ this.expanded = false;
1665
+ }
877
1666
  /** Colour the map by this field, or by nothing where it already is. */
878
1667
  paint() {
879
1668
  const s = this.resolvedStore;
@@ -884,13 +1673,17 @@ let MosaicaFieldCard = (() => {
884
1673
  emit(this, 'mosaica-colourchange', { colourBy });
885
1674
  }
886
1675
  chooseLevel(value) {
1676
+ const layer = this.layerName;
1677
+ if (layer === null)
1678
+ return;
887
1679
  const level = value === '' ? null : Number(value);
888
1680
  this.level = level;
889
- emit(this, 'mosaica-levelchange', { level });
1681
+ emit(this, 'mosaica-levelchange', { layer, level });
890
1682
  }
891
1683
  fold() {
892
1684
  this.folded = !this.folded;
893
1685
  this.brush = null;
1686
+ this.entry = null;
894
1687
  emit(this, 'mosaica-fold', { field: this.field, folded: this.folded });
895
1688
  }
896
1689
  // ---- render ----------------------------------------------------------------------------------
@@ -907,23 +1700,30 @@ let MosaicaFieldCard = (() => {
907
1700
  ? html `<button part="paint" type="button" aria-pressed=${colouring ? 'true' : 'false'} aria-label=${`Colour by ${title}`} title=${colouring ? 'Stop colouring by this' : 'Colour by this'} @click=${() => this.paint()}>${icon('drop', 13, 2)}</button>`
908
1701
  : nothing;
909
1702
  const fold = html `<button part="fold" type="button" aria-expanded=${this.folded ? 'false' : 'true'} aria-label=${this.folded ? `Open ${title}` : `Fold ${title}`} @click=${() => this.fold()}>${icon(this.folded ? 'chev' : 'chevup', 12, 2)}</button>`;
910
- const head = (sub) => html `<div part="head"><span part="title">${title}</span>${this.folded && this.compact && colouring ? html `<span class="painted" role="img" aria-label="Colouring the map">${icon('drop', 11, 2)}</span>` : nothing}${sub}${this.folded ? this.spark(s, kind, colouring) : nothing}${paint}${fold}</div>`;
911
- if (kind === 'histogram')
912
- return this.histogram(s, meta, head, colouring);
913
- if (kind === 'search') {
914
- const body = this.folded ? nothing : html `<mosaica-filter class="text" exportparts=${exportparts('filter')} column=${this.field} placeholder=${`Search ${title.toLowerCase()}`} .store=${s}></mosaica-filter>`;
915
- return html `${head(html `<span part="sub"></span>`)}${body}`;
916
- }
917
- return this.valueCard(s, kind, layer, head, colouring);
1703
+ const head = (sub) => html `<div part="head"><div class="named"><h3 part="title" id="title">${title}</h3>${this.folded && this.compact && colouring ? html `<span class="painted" role="img" aria-label="Colouring the map">${icon('drop', 11, 2)}</span>` : nothing}${sub}</div>${this.folded ? this.spark(s, kind, colouring) : nothing}${paint}${fold}</div>`;
1704
+ const card = kind === 'histogram'
1705
+ ? this.histogram(s, meta, title, head, colouring)
1706
+ : kind === 'search'
1707
+ ? html `${head(html `<span part="sub"></span>`)}${this.folded ? nothing : html `<mosaica-filter class="text" exportparts=${exportparts('filter')} column=${this.field} placeholder=${`Search ${title.toLowerCase()}`} .store=${s}></mosaica-filter>`}`
1708
+ : this.valueCard(s, kind, layer, title, head, colouring);
1709
+ return html `<div role="group" aria-labelledby="title">${card}</div>`;
918
1710
  }
919
- /** A folded card's small chart: the subject's counts, largest first for values, in order for bins. */
1711
+ /**
1712
+ * A folded card's small chart: the subject's counts, largest first for values, in order for bins.
1713
+ * A children list charts its first rows as it lists them.
1714
+ */
920
1715
  spark(s, kind, colouring) {
1716
+ const layer = this.declaredLayer(s.get('meta'));
921
1717
  const entry = this.subjectCounts.entry();
922
1718
  const table = entry?.result?.tables[0];
923
- if (!table)
1719
+ const nodes = layer && this.listing(layer) === 'children' ? this.lineage.roots : null;
1720
+ if (!table && !nodes)
924
1721
  return html `<span part="spark" aria-hidden="true"></span>`;
925
1722
  let bars;
926
- if (kind === 'histogram') {
1723
+ if (layer && nodes) {
1724
+ bars = this.childRows(s, layer, nodes.slice(0, SPARK_BARS), colouring).map((r) => ({ n: r.sub ?? 0, colour: r.swatch }));
1725
+ }
1726
+ else if (kind === 'histogram' && table) {
927
1727
  const bins = binsOf(table, this.isTimestamp(s.get('meta')));
928
1728
  const per = Math.max(1, Math.ceil(bins.length / SPARK_BARS));
929
1729
  bars = [];
@@ -931,25 +1731,27 @@ let MosaicaFieldCard = (() => {
931
1731
  bars.push({ n: bins.slice(i, i + per).reduce((t, b) => t + b.count, 0), colour: null });
932
1732
  }
933
1733
  else {
934
- const counts = countsByKey(entry);
935
1734
  const rows = this.rows(s, kind, entry, undefined, colouring).slice(0, SPARK_BARS);
936
- bars = rows.map((r) => ({ n: counts?.get(r.key) ?? 0, colour: r.swatch }));
1735
+ bars = rows.map((r) => ({ n: r.sub ?? 0, colour: r.swatch }));
937
1736
  }
938
1737
  const top = Math.max(1, ...bars.map((b) => b.n));
939
1738
  return html `<span part="spark" aria-hidden="true">${bars.map((b) => html `<span style=${`height:${Math.max(1, Math.round((b.n / top) * 14))}px${b.colour ? `;background:${b.colour}` : ''}`}></span>`)}</span>`;
940
1739
  }
941
1740
  /**
942
- * The rows of a category or cluster card: the values its clauses name, then the rest by their
943
- * count in the subject, then in the whole match.
1741
+ * The rows of a bool card, yes then no; or of a category or cluster card: the values its
1742
+ * clauses name, then the rest by their count in the subject, then in the whole match.
944
1743
  */
945
1744
  rows(s, kind, subject, match, colouring) {
946
- const sub = countsByKey(subject);
947
- const all = countsByKey(match);
1745
+ const sub = this.counts(kind, subject);
1746
+ const all = this.counts(kind, match);
1747
+ if (kind === 'bool') {
1748
+ return BOOL_KEYS.map((key) => ({ key, name: BOOL_WORDS[key], from: 'name', sub: sub?.get(key) ?? null, all: all?.get(key) ?? null, swatch: null, own: null, unmet: false }));
1749
+ }
948
1750
  if (all && kind === 'category')
949
1751
  for (const [k, n] of countsByKey(this.outsideCounts.entryFor(s)) ?? [])
950
1752
  if (!all.has(k))
951
1753
  all.set(k, n);
952
- const named = [...this.clauseKeys(s, 'filter'), ...this.clauseKeys(s, 'highlight')];
1754
+ const named = this.pinned(s);
953
1755
  if (kind === 'category') {
954
1756
  const titles = new Map();
955
1757
  for (const t of [...(subject?.result?.tables ?? []), ...(match?.result?.tables ?? [])])
@@ -962,7 +1764,7 @@ let MosaicaFieldCard = (() => {
962
1764
  return keys.map((key) => ({
963
1765
  key,
964
1766
  name: titles.get(key) ?? keyTitle(s, this.field, key),
965
- path: '',
1767
+ from: 'name',
966
1768
  sub: sub?.get(key) ?? null,
967
1769
  all: all?.get(key) ?? null,
968
1770
  swatch: colouring ? valueColour(s, this.field, key) : null,
@@ -992,88 +1794,298 @@ let MosaicaFieldCard = (() => {
992
1794
  const held = slots.get(key);
993
1795
  const own = artifactColour(held?.palette ?? palette, held?.slot ?? null);
994
1796
  const swatch = colouring ? rgb(overrides.get(this.layerName)?.get(id) ?? own) : null;
995
- return { key, name: titles.get(key) ?? this.clauseName(s, id) ?? UNNAMED, path: this.paths.pathOf(id), sub: sub?.get(key) ?? null, all: all?.get(key) ?? null, swatch, own: colouring ? hexOf(own) : null, unmet: false };
1797
+ const title = titles.get(key);
1798
+ return { key, ...nameFrom(title ? called(title, null) : this.knownName(s, id)), sub: sub?.get(key) ?? null, all: all?.get(key) ?? null, swatch, own: colouring ? hexOf(own) : null, unmet: false };
996
1799
  });
997
1800
  }
998
- /** The name a cluster's clause carries, for a cluster no table lists. */
999
- clauseName(s, id) {
1000
- return s.get('filters').members.find((m) => m.artifact === id && m.layer === this.layerName)?.label ?? null;
1801
+ /** Each row's count in `entry`: a bool's from its bins for false and true, else by each group's key. */
1802
+ counts(kind, entry) {
1803
+ if (kind !== 'bool')
1804
+ return countsByKey(entry);
1805
+ const table = entry?.result?.tables[0];
1806
+ if (!table)
1807
+ return null;
1808
+ return new Map(BOOL_KEYS.map((k) => [k, binsOf(table, false).find((b) => b.lower === BOOL_NUMBERS[k])?.count ?? 0]));
1001
1809
  }
1002
- valueCard(s, kind, layer, head, colouring) {
1810
+ valueCard(s, kind, layer, title, head, colouring) {
1003
1811
  const levelled = layer ? this.levelOf(layer) : undefined;
1812
+ // Follow zoom is offered where no level declares a zoom range, and is chosen while no level is.
1813
+ const offered = layer !== null && levelFollowsZoom(layer);
1814
+ const following = layer !== null && follows(layer, this.level);
1004
1815
  const sub = layer && levelled !== undefined && layer.levels.length > 1
1005
- ? html `<span part="sub" class="choice"><select part="level-select" aria-label="Level" @change=${(e) => this.chooseLevel(e.target.value)}>
1006
- ${layer.levels.map((l) => html `<option value=${l.level} ?selected=${levelled === l.level}>${l.title || `Level ${l.level}`}</option>`)}
1816
+ ? html `<span part="sub" class="choice"><select part="level-select" aria-label=${`${title} level`} @change=${(e) => this.chooseLevel(e.target.value)}>
1817
+ ${offered ? html `<option value="" ?selected=${following}>Follow zoom</option>` : nothing}
1818
+ ${layer.levels.map((l) => html `<option value=${l.level} ?selected=${!following && levelled === l.level}>${l.title || `Level ${l.level}`}</option>`)}
1007
1819
  </select>${icon('chev', 12, 1.4)}</span>`
1008
1820
  : html `<span part="sub"></span>`;
1009
- if (this.folded)
1010
- return head(sub);
1011
- const subject = this.subjectCounts.entry();
1012
- const match = this.matchCounts.entry();
1821
+ // One template under the heading, whatever the card lists, so the heading, the search box and
1822
+ // the breadcrumb are the same elements as the list below them changes.
1823
+ const body = this.folded ? nothing : layer && this.listing(layer) !== 'ranked' ? this.clusterBody(s, layer, title, colouring) : this.valueBody(s, kind, layer, title, colouring);
1824
+ return html `${head(sub)}${body}${this.folded ? nothing : this.picker.render()}`;
1825
+ }
1826
+ /** The body of a category, bool or `flat` layer's card: the search box, the rows and More. */
1827
+ valueBody(s, kind, layer, title, colouring) {
1828
+ const { list, after, values } = this.rankedList(s, kind, layer, title, colouring);
1829
+ const search = kind === 'category'
1830
+ ? html `<mosaica-filter class="search" exportparts=${exportparts('filter')} column=${this.field} placeholder=${values === null ? 'Search values' : `Search ${values.toLocaleString('en-GB')} values`} .store=${s}></mosaica-filter>`
1831
+ : kind === 'bool'
1832
+ ? nothing
1833
+ : this.clusterSearch(s, layer, values);
1834
+ return html `<div class="body">${search}${list}${after}</div>`;
1835
+ }
1836
+ /**
1837
+ * The body of a card that steps: the search box, the breadcrumb, the layer's clauses, the one
1838
+ * status region, and the rows of the position with what follows them.
1839
+ */
1840
+ clusterBody(s, layer, title, colouring) {
1841
+ const children = this.listing(layer) === 'children';
1842
+ const { list, after } = children ? this.childList(s, layer, colouring) : this.rankedList(s, 'cluster', layer, title, colouring);
1843
+ const { roots, refusal, loading } = this.lineage;
1844
+ const note = !children
1845
+ ? nothing
1846
+ : refusal
1847
+ ? html `<span class="none" part="refusal" data-code=${refusal.code}>Clusters unavailable</span>`
1848
+ : roots === null
1849
+ ? loading
1850
+ ? html `<span class="sr-only">Loading</span>`
1851
+ : nothing
1852
+ : roots.length === 0
1853
+ ? html `<span class="none">No clusters</span>`
1854
+ : nothing;
1855
+ // One width for every row's step button, so the counts beside it line up down the card.
1856
+ const widest = Math.max(0, ...(roots ?? []).map((n) => (n.row.childCount > 0 ? countText(n.row.childCount).length : 0)));
1857
+ return html `<div class="body" style=${`--_mosaica-step-w: calc(${widest}ch + 27px)`}>${this.clusterSearch(s, layer, null)}${this.crumbsTemplate(s, title)}${this.hideChips ? nothing : this.chipsTemplate(s, title)}<div class="note" role="status">${note}<span class="sr-only">${this.said}</span></div>${list}${after}</div>`;
1858
+ }
1859
+ /** What an overlap list inside a position lists, which heads it; `null` at the top. */
1860
+ overlapTitle(layer) {
1861
+ const deepest = this.position.at(-1);
1862
+ const level = this.listLevel(layer);
1863
+ return deepest && level !== undefined ? `${levelTitle(layer, level)} overlapping ${deepest.name}` : null;
1864
+ }
1865
+ /**
1866
+ * A list ranked by count: a category's or a bool's values, a flat layer's clusters, or the
1867
+ * overlap; then what an empty answer says and the More that shows more rows. `values` is how
1868
+ * many values or clusters the whole match has, where its answer says.
1869
+ */
1870
+ rankedList(s, kind, layer, title, colouring) {
1871
+ // A filter on the overlap list joins the others with AND, so it leaves no row out.
1872
+ const anded = layer !== null && this.listing(layer) === 'overlap';
1873
+ // Just after a step, the answers held are to what the position before asked.
1874
+ const stale = anded && this.landing !== null;
1875
+ const subject = stale ? undefined : this.subjectCounts.entry();
1876
+ const match = stale ? undefined : this.matchCounts.entry();
1013
1877
  const all = this.rows(s, kind, subject, match, colouring);
1014
1878
  const filtered = this.clauseKeys(s, 'filter');
1015
1879
  const lit = this.clauseKeys(s, 'highlight');
1016
- const shown = all.slice(0, Math.max(this.expanded ? MORE_ROWS : ROWS, all.filter((r) => filtered.has(r.key) || lit.has(r.key)).length));
1017
- if (layer)
1018
- this.paths.ask(s, layer, shown.map((r) => BigInt(r.key)));
1880
+ const pinned = new Set(this.pinned(s));
1881
+ const shown = all.slice(0, Math.max(this.expanded ? MORE_ROWS : ROWS, all.filter((r) => pinned.has(r.key)).length));
1019
1882
  // The shares are of each set's total, or of what the field's own filter keeps where it has one.
1020
- const keptTotal = (counts, table) => filtered.size > 0 && counts ? [...filtered].reduce((t, k) => t + (counts.get(k) ?? 0), 0) : (table?.total ?? 0);
1021
- const subTotal = keptTotal(countsByKey(subject), subject?.result?.tables[0]);
1022
- const allTotal = keptTotal(countsByKey(match), match?.result?.tables[0]);
1883
+ const keptTotal = (counts, table) => !anded && filtered.size > 0 && counts ? [...filtered].reduce((t, k) => t + (counts.get(k) ?? 0), 0) : (table?.total ?? 0);
1884
+ const subTotal = keptTotal(this.counts(kind, subject), subject?.result?.tables[0]);
1885
+ const allTotal = keptTotal(this.counts(kind, match), match?.result?.tables[0]);
1023
1886
  const same = subTotal === allTotal;
1887
+ // A bool's counts are a histogram's, so they may come from a sample.
1888
+ const approx = approxMark(subject?.result?.tables[0], match?.result?.tables[0]);
1889
+ const highlighting = this.highlighting(s);
1890
+ const subjectWord = highlighting ? 'highlighted' : 'in view';
1024
1891
  const pct = (n, total) => (n === null || total <= 0 ? 0 : Math.min(100, (100 * n) / total));
1025
1892
  // A layer's clusters are often a few per cent each; a bar that is not empty stays visible.
1026
1893
  const width = (share) => (share > 0 && kind === 'cluster' ? `max(${MIN_BAR_PX}px, ${share.toFixed(1)}%)` : `${share.toFixed(1)}%`);
1027
1894
  const groups = kind === 'category' ? (subject?.result?.tables[0]?.groups ?? null) : all.length;
1028
1895
  const more = groups === null ? 0 : groups - shown.length;
1896
+ // On the overlap list, a row at a level with a finer one after it steps into it.
1897
+ const level = anded ? this.listLevel(layer) : undefined;
1898
+ const next = level === undefined ? undefined : levelAfter(layer, level);
1899
+ const stepper = (r) => next === undefined ? undefined : { label: `Open ${r.name}, ${levelTitle(layer, next)} inside it`, count: null, step: { id: BigInt(r.key), name: r.name, from: r.from, rung: level, children: null } };
1029
1900
  const row = (r) => {
1030
- const out = filtered.size > 0 && !filtered.has(r.key);
1031
1901
  const inFilter = filtered.has(r.key);
1902
+ const out = !anded && filtered.size > 0 && !inFilter;
1032
1903
  const onLit = lit.has(r.key);
1033
- const stateOf = out ? 'out' : inFilter ? 'in' : onLit ? 'lit' : '';
1034
- const mark = r.swatch
1035
- ? html `<button part="swatch" type="button" style=${`--c:${r.swatch}`} ?data-unmet=${r.unmet} aria-haspopup="dialog" aria-label=${`Colour of ${r.name}`} @click=${(e) => this.openPicker(e, r, kind)}></button>`
1036
- : inFilter
1037
- ? icon('check', 12, 2.6)
1038
- : nothing;
1039
- const verb = (v) => () => (kind === 'category' ? this.toggleValue(r.key, v) : this.toggleCluster(r.key, r.name === UNNAMED ? null : r.name, v));
1040
- const counts = out
1041
- ? html `<span part="count">${r.all === null ? '' : countText(r.all)}</span>`
1042
- : html `<span part="count"><span class="sub">${r.sub === null ? '' : countText(r.sub)}</span>${same || r.all === null ? '' : ` / ${countText(r.all)}`}</span>`;
1043
- // A press on a row filters to its value or cluster, as its Filter button does, and shows its
1044
- // buttons; the keyboard reaches the buttons.
1045
- return html `<div part="row" data-key=${r.key} data-state=${stateOf} ?data-shown=${this.shownRow === r.key}
1046
- @click=${(e) => {
1047
- if (e.target.closest('button'))
1048
- return;
1049
- this.shownRow = r.key;
1050
- verb('filter')();
1051
- }}>
1052
- <span class="mark">${mark}</span>
1053
- <span class="label">
1054
- <span class="names"><span part="name" title=${r.name} ?data-unnamed=${r.name === UNNAMED}>${r.name}</span>${r.path ? html `<span part="path" title=${r.path}>${r.path}</span>` : nothing}</span>
1055
- <span class="bars" aria-hidden="true"><span part="bar-match" style=${`width:${width(out ? 0 : pct(r.all, allTotal))}`}></span><span part="bar-subject" style=${`width:${width(out ? 0 : pct(r.sub, subTotal))}${r.swatch && !r.unmet ? `;background:${r.swatch}` : ''}`}></span></span>
1056
- </span>
1057
- ${counts}
1058
- <span part="verbs">
1059
- <button part="highlight" type="button" aria-pressed=${onLit ? 'true' : 'false'} aria-label=${onLit ? `Stop highlighting ${r.name}` : `Highlight ${r.name}`} title=${onLit ? 'Stop highlighting' : 'Highlight'} @click=${verb('highlight')}>${icon('highlight', 13, 1.5)}</button>
1060
- <button part="filter" type="button" aria-pressed=${inFilter ? 'true' : 'false'} aria-label=${inFilter ? `Stop filtering to ${r.name}` : `Filter to ${r.name}`} title=${inFilter ? 'Stop filtering' : 'Filter'} @click=${verb('filter')}>${icon('filter', 13, 1.6)}</button>
1061
- </span>
1062
- </div>`;
1904
+ const sub = out || r.sub === null ? null : approx + countText(r.sub);
1905
+ const all = r.all === null || (!out && same) ? null : approx + countText(r.all);
1906
+ return this.rowTemplate(r, kind, {
1907
+ state: out ? 'out' : inFilter ? 'in' : onLit ? 'lit' : '',
1908
+ inFilter,
1909
+ onLit,
1910
+ drawn: out ? (all ?? '') : null,
1911
+ sub,
1912
+ all,
1913
+ said: [sub === null ? '' : `${sub} ${subjectWord}`, all === null ? '' : `${all} matching`].filter(Boolean).join(', '),
1914
+ barMatch: width(out ? 0 : pct(r.all, allTotal)),
1915
+ barSubject: width(out ? 0 : pct(r.sub, subTotal)),
1916
+ step: stepper(r)
1917
+ });
1063
1918
  };
1064
1919
  const values = (kind === 'category' ? match?.result?.tables[0] : match?.result?.tables.at(-1))?.groups ?? null;
1065
- const search = kind === 'category'
1066
- ? html `<mosaica-filter class="search" exportparts=${exportparts('filter')} column=${this.field} placeholder=${values === null ? 'Search values' : `Search ${values.toLocaleString('en-GB')} values`} .store=${s}></mosaica-filter>`
1067
- : html `<mosaica-cluster-filter class="search" exportparts=${exportparts('cluster-filter')} layer=${layer.name} placeholder=${values === null ? 'Search clusters' : `Search ${values.toLocaleString('en-GB')} clusters`} .store=${s}></mosaica-cluster-filter>`;
1920
+ const noun = kind === 'bool' ? 'values' : layer ? `${title} clusters` : `${title} values`;
1921
+ const moreText = this.expanded ? 'Show fewer' : `${more.toLocaleString('en-GB')} more${highlighting ? '' : ' in view'}`;
1068
1922
  const moreButton = more > 0 || this.expanded
1069
- ? html `<button part="more" class="more-link" type="button" @click=${() => (this.expanded = !this.expanded)}>${this.expanded ? 'Show fewer' : `${more.toLocaleString('en-GB')} more${this.highlighting(s) ? '' : ' in view'}`}</button>`
1923
+ ? html `<button part="more" class="more-link" type="button" aria-label=${`${moreText}: ${noun}`} @click=${() => (this.expanded = !this.expanded)}>${moreText}</button>`
1070
1924
  : nothing;
1071
1925
  // An answer with nothing in it says so, where a list would otherwise stand empty.
1072
1926
  const empty = shown.length === 0 && subject?.result ? html `<span class="none">${subTotal === 0 ? 'None in view' : 'None counted'}</span>` : nothing;
1073
- return html `${head(sub)}<div class="body">${search}<div part="rows" role="list" aria-label=${`Commonest ${layer ? 'clusters' : 'values'}`}>${shown.map((r) => html `<div role="listitem">${row(r)}</div>`)}</div>${empty}${moreButton}</div>${this.picker.render()}`;
1927
+ const heading = anded ? this.overlapTitle(layer) : null;
1928
+ const items = repeat(shown, (r) => r.key, (r) => html `<div role="listitem">${row(r)}</div>`);
1929
+ const list = heading === null
1930
+ ? html `<div part="rows" role="list" aria-label=${`${title}: ${kind === 'bool' ? 'values' : layer ? 'commonest clusters' : 'commonest values'}`}>${items}</div>`
1931
+ : html `<h4 part="level" id="overlap">${heading}</h4><div part="rows" role="list" aria-labelledby="overlap">${items}</div>`;
1932
+ return { list, after: html `${empty}${moreButton}`, values };
1933
+ }
1934
+ /** The search box over a layer's clusters, which names how many there are where a count says. */
1935
+ clusterSearch(s, layer, values) {
1936
+ return html `<mosaica-cluster-filter class="search" exportparts=${exportparts('cluster-filter')} layer=${layer.name} placeholder=${values === null ? 'Search clusters' : `Search ${values.toLocaleString('en-GB')} clusters`} .store=${s}></mosaica-cluster-filter>`;
1937
+ }
1938
+ /**
1939
+ * One row of a category, bool or cluster card. `sub` and `all` are its two counts as drawn, or
1940
+ * `drawn` alone in their place, and `said` gives them to a screen reader with what each counts.
1941
+ * `step` is the button that steps into the row, `null` on a row of a card that steps with nothing
1942
+ * to step into. A press on the row filters to its value or cluster, as its Filter button does;
1943
+ * the keyboard reaches the buttons.
1944
+ */
1945
+ rowTemplate(r, kind, look) {
1946
+ const { inFilter, onLit, step } = look;
1947
+ const mark = r.swatch
1948
+ ? html `<button part="swatch" type="button" style=${`--c:${r.swatch}`} ?data-unmet=${r.unmet} aria-haspopup="dialog" aria-label=${`Colour of ${r.name}`} @click=${(e) => this.openPicker(e, r, kind)}></button>`
1949
+ : inFilter
1950
+ ? icon('check', 12, 2.6)
1951
+ : nothing;
1952
+ const verb = (v) => () => kind === 'category' ? this.toggleValue(r.key, v) : kind === 'bool' ? this.toggleBool(r.key, v) : this.toggleCluster(r.key, { text: r.name, from: r.from }, v);
1953
+ // A tick where the filter keeps the value and the swatch stands where the tick would, and a pen where it is highlighted.
1954
+ const cues = html `${inFilter && r.swatch ? html `<span class="cue">${icon('check', 11, 2.4)}</span>` : nothing}${onLit ? html `<span class="cue lit">${icon('highlight', 11, 1.6)}</span>` : nothing}`;
1955
+ // The figures as they are drawn, and then for a reader, each with what it counts.
1956
+ const counts = html `<span part="count" aria-hidden="true" title=${look.said}>${cues}${look.drawn ?? html `<span class="sub">${look.sub ?? ''}</span>${look.all === null ? '' : ` / ${look.all}`}`}</span
1957
+ ><span class="sr-only">${look.said}</span>`;
1958
+ const stepButton = step === undefined
1959
+ ? nothing
1960
+ : step === null
1961
+ ? html `<span class="step"></span>`
1962
+ : html `<span class="step"><button part="step" type="button" aria-label=${step.label} title="Open" @click=${() => this.stepIn(step.step)}>${step.count === null ? '' : countText(step.count)}${icon('chevr', 12, 2)}</button></span>`;
1963
+ return html `<div part="row" class=${step === undefined ? '' : 'steps'} data-key=${r.key} data-state=${look.state}
1964
+ @click=${(e) => {
1965
+ if (!e.target.closest('button'))
1966
+ verb('filter')();
1967
+ }}>
1968
+ <span class="mark">${mark}</span>
1969
+ <span class="label">
1970
+ <span class="names"><span part="name" title=${r.name} ?data-key-name=${r.from === 'key'} ?data-unnamed=${r.from === 'none'}>${r.name}</span></span>
1971
+ <span class="bars" aria-hidden="true"><span part="bar-match" style=${`width:${look.barMatch}`}></span><span part="bar-subject" style=${`width:${look.barSubject}${r.swatch && !r.unmet ? `;background:${r.swatch}` : ''}`}></span></span>
1972
+ </span>
1973
+ ${counts}
1974
+ <span part="verbs">
1975
+ <button part="highlight" type="button" aria-pressed=${onLit ? 'true' : 'false'} aria-label=${onLit ? `Stop highlighting ${r.name}` : `Highlight ${r.name}`} title=${onLit ? 'Stop highlighting' : 'Highlight'} @click=${verb('highlight')}>${icon('highlight', 13, 1.5)}</button>
1976
+ <button part="filter" type="button" aria-pressed=${inFilter ? 'true' : 'false'} aria-label=${inFilter ? `Stop filtering to ${r.name}` : `Filter to ${r.name}`} title=${inFilter ? 'Stop filtering' : 'Filter'} @click=${verb('filter')}>${icon('filter', 13, 1.6)}</button>
1977
+ </span>
1978
+ ${stepButton}
1979
+ </div>`;
1980
+ }
1981
+ /** The rows of a children list, in the server's order: each with its matched count over its masked one where a filter is set, else its masked count alone. */
1982
+ childRows(s, layer, nodes, colouring) {
1983
+ const { palette, overrides } = s.get('artifacts');
1984
+ return nodes.map(({ row }) => {
1985
+ const own = artifactColour(palette, row.slot);
1986
+ const masked = Number(row.maskedCount);
1987
+ return {
1988
+ key: idString(row.mosaicaId),
1989
+ ...nameFrom(called(row.name, row.key)),
1990
+ sub: row.matchedCount === null ? masked : Number(row.matchedCount),
1991
+ all: row.matchedCount === null ? null : masked,
1992
+ swatch: colouring ? rgb(overrides.get(layer.name)?.get(row.mosaicaId) ?? own) : null,
1993
+ own: colouring ? hexOf(own) : null,
1994
+ unmet: false
1995
+ };
1996
+ });
1997
+ }
1998
+ /**
1999
+ * The rows of a linked layer's position: its children, or the roots, grouped under their levels'
2000
+ * headings on a `tiered` layer, with the remainder as the last list's last row; then More.
2001
+ */
2002
+ childList(s, layer, colouring) {
2003
+ const nodes = this.lineage.roots ?? [];
2004
+ const deepest = this.position.at(-1);
2005
+ const filtered = this.clauseKeys(s, 'filter');
2006
+ const lit = this.clauseKeys(s, 'highlight');
2007
+ const rows = this.childRows(s, layer, nodes, colouring);
2008
+ // Each bar is a share of the largest row's masked count, the solid part what matches.
2009
+ const largest = Math.max(1, ...nodes.map((n) => Number(n.row.maskedCount)));
2010
+ const width = (n) => {
2011
+ const share = n === null ? 0 : Math.min(100, (100 * n) / largest);
2012
+ return share > 0 ? `max(${MIN_BAR_PX}px, ${share.toFixed(1)}%)` : '0%';
2013
+ };
2014
+ const row = (r, i) => {
2015
+ const node = nodes[i];
2016
+ const inFilter = filtered.has(r.key);
2017
+ const onLit = lit.has(r.key);
2018
+ const also = this.lineage.alsoUnder(node);
2019
+ const n = node.row.childCount;
2020
+ return html `<div role="listitem">${this.rowTemplate(r, 'cluster', {
2021
+ state: inFilter ? 'in' : onLit ? 'lit' : '',
2022
+ inFilter,
2023
+ onLit,
2024
+ drawn: null,
2025
+ sub: countText(r.sub ?? 0),
2026
+ all: r.all === null ? null : countText(r.all),
2027
+ said: spokenCounts(node.row),
2028
+ barMatch: width(r.all ?? r.sub),
2029
+ barSubject: width(r.sub),
2030
+ step: n > 0 ? { label: `Open ${r.name}, ${counted(n, 'child', 'children')}`, count: n, step: { id: node.row.mosaicaId, name: r.name, from: r.from, rung: node.row.rung, children: n } } : null
2031
+ })}${also.length > 0 ? html `<div part="also">also under ${calledList(also)}</div>` : nothing}</div>`;
2032
+ };
2033
+ const left = this.remainderAsked ? this.remainder.entryOf(s, this.remainderAsked)?.result?.tables[0]?.total : undefined;
2034
+ const rest = this.remainderAsked
2035
+ ? html `<div role="listitem"><div part="remainder"><span class="mark"></span><span class="rest">Not in any of these</span>${left === undefined ? nothing : html `<span part="count" aria-hidden="true"><span class="sub">${countText(left)}</span></span><span class="sr-only">${countText(left)} matching</span>`}</div></div>`
2036
+ : nothing;
2037
+ const scope = deepest ? `children of ${deepest.name}` : 'top level';
2038
+ // On a `tiered` layer one parent can have children at several levels, each listed under its title.
2039
+ const levels = layer.hierarchy.kind === 'tiered' ? [...new Set(nodes.map((n) => n.row.rung))].sort((a, b) => a - b) : [null];
2040
+ const list = nodes.length === 0
2041
+ ? nothing
2042
+ : html `${levels.map((rung, li) => {
2043
+ const at = nodes.flatMap((n, i) => (rung === null || n.row.rung === rung ? [i] : []));
2044
+ const items = html `${repeat(at, (i) => rows[i].key, (i) => row(rows[i], i))}${li === levels.length - 1 ? rest : nothing}`;
2045
+ return rung === null
2046
+ ? html `<div part="rows" role="list" aria-label=${`${layer.title || layer.name}: ${scope}`}>${items}</div>`
2047
+ : html `<h4 part="level" id=${`level-${rung}`}>${levelTitle(layer, rung)}</h4><div part="rows" role="list" aria-labelledby=${`level-${rung}`}>${items}</div>`;
2048
+ })}`;
2049
+ // How many children are still to come, where the position's row was listed with its count.
2050
+ const remaining = deepest?.children == null ? 0 : deepest.children - nodes.length;
2051
+ const moreText = remaining > 0 ? `${remaining.toLocaleString('en-GB')} more` : 'More';
2052
+ const after = this.lineage.hasMore
2053
+ ? html `<button part="more" class="more-link" type="button" aria-label=${`${moreText} ${deepest ? `children of ${deepest.name}` : 'top-level clusters'}`} @click=${(e) => void this.moreChildren(e.currentTarget)}>${moreText}</button>`
2054
+ : nothing;
2055
+ return { list, after };
2056
+ }
2057
+ /** The breadcrumb: All and then each step of the position, each a button that steps back to it. */
2058
+ crumbsTemplate(s, title) {
2059
+ const filtered = this.clauseKeys(s, 'filter');
2060
+ const steps = [{ id: null, name: 'All', from: 'name' }, ...this.position];
2061
+ return html `<nav part="crumbs" aria-label=${`${title} breadcrumb`}><ol>${repeat(steps, (step, i) => `${i}:${step.id ?? ''}`, (step, i) => {
2062
+ const on = step.id !== null && filtered.has(idString(step.id));
2063
+ return html `<li part="crumb" data-id=${step.id === null ? '' : idString(step.id)} ?data-filtered=${on}>${i > 0 ? html `<span class="sep" aria-hidden="true">›</span>` : nothing}<button
2064
+ part="crumb-name" type="button" title=${step.name} aria-current=${i === steps.length - 1 ? 'location' : nothing} @click=${() => this.stepTo(i)}>${on ? html `<span class="cue">${icon('check', 11, 2.4)}</span>` : nothing}<span class="crumb-text" ?data-key-name=${step.from === 'key'} ?data-unnamed=${step.from === 'none'}>${step.name}</span>${on ? html `<span class="sr-only">, filtered to</span>` : nothing}</button></li>`;
2065
+ })}</ol></nav>`;
2066
+ }
2067
+ /** The layer's clauses, in the order they were added, each with a × that removes it. */
2068
+ chipsTemplate(s, title) {
2069
+ const clauses = s.get('filters').members.filter((m) => m.layer === this.layerName);
2070
+ if (clauses.length === 0)
2071
+ return nothing;
2072
+ return html `<ul part="chips" aria-label=${`${title}: filters and highlights`}>${repeat(clauses, (c) => `${c.verb}:${c.artifact}`, (c) => {
2073
+ const name = this.clauseCalled(c.label, c.artifact);
2074
+ const lit = c.verb === 'highlight';
2075
+ const said = `${lit ? 'Highlighted' : 'Filtered to'}${c.outside ? ' outside' : ''}`;
2076
+ return html `<li part="chip" data-id=${idString(c.artifact)} data-verb=${c.verb}><span class="cue">${lit ? icon('highlight', 11, 1.6) : icon('check', 11, 2.4)}</span><span part="chip-name" title=${name.text}
2077
+ ><span class="sr-only">${said} </span>${c.outside ? 'not ' : ''}${calledText(name)}</span
2078
+ ><button part="chip-remove" type="button" aria-label=${`${lit ? 'Stop highlighting' : 'Stop filtering to'}${c.outside ? ' outside' : ''} ${name.text}`} title=${lit ? 'Stop highlighting' : 'Stop filtering'} @click=${() => this.removeClause(c)}>${icon('close', 12)}</button></li>`;
2079
+ })}</ul>`;
2080
+ }
2081
+ /** Load the next page of children, and move focus from More to the first row it added, unless focus has gone elsewhere meanwhile. */
2082
+ async moreChildren(button) {
2083
+ const first = (await this.lineage.loadMore())[0];
2084
+ if (first)
2085
+ await focusAdded(this, button, () => this.renderRoot.querySelector(`[part="row"][data-key="${idString(first.row.mosaicaId)}"] [part="highlight"]`));
1074
2086
  }
1075
2087
  // ---- histogram ---------------------------------------------------------------------------------
1076
- histogram(s, meta, head, colouring) {
2088
+ histogram(s, meta, title, head, colouring) {
1077
2089
  const timestamp = this.isTimestamp(meta);
1078
2090
  const column = meta.declaredScalars.find((c) => c.name === this.field);
1079
2091
  const whole = column ? column.arrowType !== 'f32' && column.arrowType !== 'f64' : false;
@@ -1094,55 +2106,133 @@ let MosaicaFieldCard = (() => {
1094
2106
  const share = (n, total) => (total > 0 ? n / total : 0);
1095
2107
  const tallest = Math.max(1e-9, ...bins.map((b) => Math.max(share(allOf(b), allTotal), share(subOf(b), subTotal))));
1096
2108
  const height = (n, total) => `${((share(n, total) / tallest) * PLOT_HEIGHT).toFixed(1)}px`;
1097
- const estimate = Boolean(subjectTable?.sample?.sampled || matchTable?.sample?.sampled);
1098
- const approx = estimate ? '≈' : '';
2109
+ const approx = approxMark(subjectTable, matchTable);
1099
2110
  const lo = bins[0]?.lower;
1100
2111
  const hi = bins.at(-1)?.upper;
2112
+ // A bin holds its lower edge and not its upper, so a whole number's or a date's last value is one below.
2113
+ const discrete = whole || timestamp;
1101
2114
  const at = (v) => (lo === undefined || hi === undefined || hi <= lo ? 0 : Math.min(1, Math.max(0, (v - lo) / (hi - lo))));
1102
2115
  const pct = (t) => `${(t * 100).toFixed(2)}%`;
1103
2116
  const band = (verb, from, to) => html `<span part="band" data-verb=${verb} style=${`left:calc(${pct(from)} - 1px);width:calc(${pct(to - from)} + 2px)`}></span>`;
2117
+ const between = (verb, gte, lte) => band(verb, gte === null ? 0 : at(gte), lte === null ? 1 : at(lte));
2118
+ const words = (gte, lte) => rangeWords(gte, lte, timestamp, whole);
1104
2119
  const draft = s.get('filters').draft;
2120
+ const held = (verb) => {
2121
+ const clause = draft[verb][this.field];
2122
+ return clause?.family === 'numeric' && isPopulated(clause) ? clause : null;
2123
+ };
1105
2124
  const clauseBand = (verb) => {
1106
- const held = draft[verb][this.field];
1107
- if (held?.family !== 'numeric' || !isPopulated(held) || lo === undefined)
1108
- return nothing;
1109
- return band(verb, held.gte === null ? 0 : at(held.gte), held.lte === null ? 1 : at(held.lte));
2125
+ const range = held(verb);
2126
+ return range && lo !== undefined ? between(verb, range.gte, range.lte) : nothing;
2127
+ };
2128
+ // The range each position holds, in words for a reader, since the bands are outlines alone.
2129
+ const applied = (verb) => {
2130
+ const range = held(verb);
2131
+ return range ? html `<span class="applied sr-only" data-verb=${verb}>${verb === 'filter' ? 'Filter' : 'Highlight'}: ${words(range.gte, range.lte)}. </span>` : nothing;
1110
2132
  };
1111
2133
  const brush = this.brush;
1112
2134
  const range = brush && bins.length > 0 ? this.brushRange(bins, brush, timestamp, whole) : null;
1113
- const brushBand = brush && bins.length > 0 ? band('brush', Math.min(brush.from, brush.to) / bins.length, (Math.max(brush.from, brush.to) + 1) / bins.length) : nothing;
2135
+ const entry = this.entry;
2136
+ // The range the box's fields stand for, while it can be applied.
2137
+ const typed = entry && entry.from.value !== undefined && entry.to.value !== undefined && !backwards(entry) ? { gte: entry.from.value, lte: entry.to.value } : null;
2138
+ const brushBand = brush && range
2139
+ ? band('brush', Math.min(brush.from, brush.to) / bins.length, (Math.max(brush.from, brush.to) + 1) / bins.length)
2140
+ : typed && lo !== undefined
2141
+ ? between('brush', typed.gte, typed.lte)
2142
+ : nothing;
1114
2143
  const ramp = colouring && bins.length > 0 ? this.ramp(s, bins) : nothing;
1115
2144
  const middle = bins[Math.floor(bins.length / 2)]?.lower;
1116
2145
  const axis = lo !== undefined && hi !== undefined
1117
2146
  ? html `<div part="axis"><span>${this.edgeText(lo, lo, hi, timestamp, whole)}</span><span>${middle === undefined ? '' : this.edgeText(middle, lo, hi, timestamp, whole)}</span><span>${this.edgeText(hi, lo, hi, timestamp, whole)}</span></div>`
1118
2147
  : html `<div part="axis"><span class="skel"></span></div>`;
1119
- const box = range && brush?.open
1120
- ? html `<div part="brush" role="dialog" aria-label="Chosen range" style=${this.brushPlace(brush, bins.length)} @keydown=${this.onBrushKey}>
1121
- <div><b>${range.text}</b> · ${approx}${countText(range.count)} items</div>
1122
- <div class="acts">
1123
- <button part="brush-filter" type="button" @click=${() => this.applyBrush('filter', range)}>${icon('filter', 12, 2)}Filter</button>
1124
- <button part="brush-highlight" type="button" @click=${() => this.applyBrush('highlight', range)}>${icon('highlight', 12, 1.8)}Highlight</button>
1125
- </div>
1126
- </div>`
2148
+ /** Open the range box on the range being chosen, else on the range the field holds, else on none. */
2149
+ const open = (opener) => {
2150
+ const chosen = this.brush && bins.length > 0 ? this.brushRange(bins, this.brush, timestamp, whole) : null;
2151
+ const { gte, lte } = chosen ?? held('filter') ?? held('highlight') ?? { gte: null, lte: null };
2152
+ const end = (v) => ({ text: v === null ? '' : endText(v, timestamp), value: v, checked: false });
2153
+ this.entryOpener = opener;
2154
+ this.brush = null;
2155
+ this.entry = { from: end(gte), to: end(lte) };
2156
+ // A range that was dragged is ready to apply; one that was not is typed, over what the field holds.
2157
+ void this.updateComplete.then(() => {
2158
+ const to = this.renderRoot.querySelector(chosen ? '[part="brush-filter"]' : '[part="brush-from"]');
2159
+ to?.focus();
2160
+ if (to instanceof HTMLInputElement)
2161
+ to.select();
2162
+ });
2163
+ };
2164
+ const type = (which, e, checked) => {
2165
+ const text = e.target.value;
2166
+ if (this.entry)
2167
+ this.entry = { ...this.entry, [which]: { text, value: endValue(text, timestamp, which === 'to'), checked } };
2168
+ };
2169
+ const mistake = (end, edge) => {
2170
+ if (!end.checked || end.value !== undefined)
2171
+ return '';
2172
+ const said = `“${end.text.trim()}”`;
2173
+ if (!timestamp)
2174
+ return `${said} is not a number. Type a number such as ${edge ?? 0}${end.text.trim().search(GROUP_MARKS) < 0 ? '' : '; a comma or a space only groups thousands, as in 1,250'}.`;
2175
+ const day = shortDateText(edge ?? 0);
2176
+ return `${said} is not a date. Type a day such as ${day}, a month such as ${day.replace(/^\d+ /, '')}, or a year.`;
2177
+ };
2178
+ const last = hi === undefined ? undefined : discrete ? hi - 1 : hi;
2179
+ const fromMistake = entry ? mistake(entry.from, lo) : '';
2180
+ const toMistake = entry ? mistake(entry.to, last) : '';
2181
+ const order = !entry || !backwards(entry) || !(entry.from.checked || entry.to.checked) ? '' : timestamp ? 'From is later than To. Type an earlier From or a later To.' : 'From is above To. Type a lower From or a higher To.';
2182
+ const described = (own, id) => [own ? id : '', order ? 'order-error' : ''].filter(Boolean).join(' ') || nothing;
2183
+ const count = typed ? countWithin(bins, typed.gte, typed.lte, discrete) : null;
2184
+ // The box stands under the range it holds, and stays where it is while a field holds no range.
2185
+ if (typed && lo !== undefined)
2186
+ this.boxAt = [typed.gte === null ? 0 : at(typed.gte), typed.lte === null ? 1 : at(typed.lte)];
2187
+ const box = entry
2188
+ ? html `<div part="brush" role="dialog" aria-label=${`${title}: range`} style=${this.brushPlace()} @keydown=${this.onBrushKey} @focusout=${this.onBrushLeft}>
2189
+ <div class="chosen">${typed ? html `<b>${words(typed.gte, typed.lte)}</b>${count === null ? nothing : html ` · ${approx}${countText(count)} items`}` : nothing}</div>
2190
+ <div class="ends">
2191
+ <input part="brush-from" type="text" autocomplete="off" aria-label=${`${title}: from`} title="From" placeholder=${lo === undefined ? nothing : endText(lo, timestamp)}
2192
+ aria-invalid=${fromMistake || order ? 'true' : nothing} aria-describedby=${described(fromMistake, 'from-error')} .value=${entry.from.text}
2193
+ @input=${(e) => type('from', e, false)} @change=${(e) => type('from', e, true)} />
2194
+ <span aria-hidden="true">–</span>
2195
+ <input part="brush-to" type="text" autocomplete="off" aria-label=${`${title}: to`} title="To" placeholder=${last === undefined ? nothing : endText(last, timestamp)}
2196
+ aria-invalid=${toMistake || order ? 'true' : nothing} aria-describedby=${described(toMistake, 'to-error')} .value=${entry.to.text}
2197
+ @input=${(e) => type('to', e, false)} @change=${(e) => type('to', e, true)} />
2198
+ </div>
2199
+ <div class="mistakes" role="status"><span id="from-error">${fromMistake}</span><span id="to-error">${toMistake}</span><span id="order-error">${order}</span></div>
2200
+ <div class="acts">
2201
+ <button part="brush-filter" type="button" @click=${() => this.applyRange('filter')}>${icon('filter', 12, 2)}Filter</button>
2202
+ <button part="brush-highlight" type="button" @click=${() => this.applyRange('highlight')}>${icon('highlight', 12, 1.8)}Highlight</button>
2203
+ </div>
2204
+ </div>`
1127
2205
  : nothing;
1128
- const title = (b) => `${value(b.lower)} – ${value(b.upper)}: ${approx}${countText(subOf(b))} in view, ${approx}${countText(allOf(b))} matching`;
2206
+ const subjectName = this.highlighting(s) ? 'Highlighted' : 'In view';
2207
+ const span = (b) => `${value(b.lower)} – ${value(b.upper)}`;
2208
+ const binTitle = (b) => `${span(b)}: ${approx}${countText(subOf(b))} ${subjectName.toLowerCase()}, ${approx}${countText(allOf(b))} matching`;
2209
+ // The bins' counts for a reader, who has no pointer to hover a bar with.
2210
+ const table = bins.length === 0
2211
+ ? nothing
2212
+ : html `<div class="sr-only"><table>
2213
+ <caption>${title} by range</caption>
2214
+ <thead><tr><th scope="col">Range</th>${subjectTable ? html `<th scope="col">${subjectName}</th>` : nothing}${matchTable ? html `<th scope="col">All matching</th>` : nothing}</tr></thead>
2215
+ <tbody>${bins.map((b) => html `<tr><th scope="row">${span(b)}</th>${subjectTable ? html `<td>${approx}${countText(subOf(b))}</td>` : nothing}${matchTable ? html `<td>${approx}${countText(allOf(b))}</td>` : nothing}</tr>`)}</tbody>
2216
+ </table></div>`;
1129
2217
  // While a range is being chosen, a reader hears it and its count as it moves.
1130
- const said = range && !brush?.open ? `${range.text}, ${approx}${countText(range.count)} items` : '';
2218
+ const said = range ? `${range.text}, ${approx}${countText(range.count)} items` : '';
1131
2219
  return html `${head(sub)}<div class="hist">
1132
- <div part="plot" role="group" tabindex="0" aria-label=${`${columnCaption(this.field)}: drag or use Shift and the arrow keys to choose a range`}
2220
+ <div part="plot" role="group" tabindex="0" aria-label=${`${title}: the arrow keys choose a range, Shift widens it and Enter opens it; Set range takes one typed`}
1133
2221
  @pointerdown=${(e) => this.brushStart(e, bins.length)} @pointermove=${(e) => this.brushMove(e, bins.length)}
1134
- @pointerup=${(e) => this.brushEnd(e, bins.length)} @pointercancel=${() => (this.brush = null)} @keydown=${(e) => this.brushKey(e, bins.length)}>
1135
- ${bins.map((b, i) => html `<div part="bin" data-index=${i} title=${title(b)}><span part="bar-match" style=${`height:${height(allOf(b), allTotal)}`}></span><span part="bar-subject" style=${`height:${height(subOf(b), subTotal)}`}></span></div>`)}
2222
+ @pointerup=${(e) => this.brushEnd(e, bins.length, open)} @pointercancel=${() => (this.brush = null)} @keydown=${(e) => this.brushKey(e, bins.length, open)}>
2223
+ ${bins.map((b, i) => html `<div part="bin" data-index=${i} title=${binTitle(b)} aria-hidden="true"><span part="bar-match" style=${`height:${height(allOf(b), allTotal)}`}></span><span part="bar-subject" style=${`height:${height(subOf(b), subTotal)}`}></span></div>`)}
1136
2224
  ${clauseBand('filter')}${clauseBand('highlight')}${brushBand}
1137
2225
  </div>
1138
- ${ramp}${axis}${box}
1139
- <span class="hidden" role="status" aria-live="polite">${said}</span>
2226
+ ${ramp}${axis}
2227
+ <button part="range" class="btn small sr-only" type="button" aria-haspopup="dialog" aria-expanded=${entry ? 'true' : 'false'} aria-label=${`Set range of ${title}`}
2228
+ @click=${() => (this.entry ? (this.entry = null) : open('range'))}>Set range</button>
2229
+ ${applied('filter')}${applied('highlight')}${box}${table}
2230
+ <span class="sr-only" role="status" aria-live="polite">${said}</span>
1140
2231
  </div>`;
1141
2232
  }
1142
- /** Where the range's box stands: under the range, from its left end or, on the right half, its right, and never past the plot. */
1143
- brushPlace(brush, n) {
1144
- const from = Math.min(brush.from, brush.to) / n;
1145
- const to = (Math.max(brush.from, brush.to) + 1) / n;
2233
+ /** Where the range box stands: under its range, from the range's left end or, on the right half, its right, and never past the plot. */
2234
+ brushPlace() {
2235
+ const [from, to] = this.boxAt;
1146
2236
  const room = 'calc(100% - 210px)';
1147
2237
  return from + to < 1 ? `left:clamp(0px, ${(from * 100).toFixed(2)}%, ${room})` : `right:clamp(0px, ${((1 - to) * 100).toFixed(2)}%, ${room})`;
1148
2238
  }
@@ -1181,20 +2271,35 @@ let MosaicaFieldCard = (() => {
1181
2271
  const first = Math.max(0, Math.min(brush.from, brush.to));
1182
2272
  const last = Math.min(bins.length - 1, Math.max(brush.from, brush.to));
1183
2273
  const from = bins[first].lower;
1184
- // A bin holds its lower edge and not its upper, so a whole-number or date range ends just
1185
- // below the next bin.
1186
2274
  const end = bins[last].upper;
1187
2275
  const to = whole || timestamp ? end - 1 : end;
1188
- const table = this.matchCounts.entry()?.result?.tables[0] ?? this.subjectCounts.entry()?.result?.tables[0];
1189
- const counted = binsOf(table, timestamp);
1190
- const count = counted.slice(first, last + 1).reduce((t, b) => t + b.count, 0);
1191
- const text = timestamp ? dateRangeText(from, to) : `${formatNumber(from, whole)} – ${formatNumber(to, whole)}`;
1192
- return { gte: first === 0 ? null : from, lte: last === bins.length - 1 ? null : to, text, count };
2276
+ const count = bins.slice(first, last + 1).reduce((t, b) => t + b.count, 0);
2277
+ return { gte: first === 0 ? null : from, lte: last === bins.length - 1 ? null : to, text: rangeWords(from, to, timestamp, whole), count };
1193
2278
  }
1194
- applyBrush(verb, range) {
1195
- this.brush = null;
1196
- this.setClause(verb, { family: 'numeric', gte: range.gte, lte: range.lte });
1197
- void this.updateComplete.then(() => this.renderRoot.querySelector('[part="plot"]')?.focus());
2279
+ /**
2280
+ * Set the range box's range as the field's clause in `verb`. A field holding text that is no
2281
+ * value, or a lower end above the upper, sets nothing: both fields show what is wrong with them,
2282
+ * and the first that is wrong takes focus.
2283
+ */
2284
+ applyRange(verb) {
2285
+ const entry = this.entry;
2286
+ if (!entry)
2287
+ return;
2288
+ if (entry.from.value === undefined || entry.to.value === undefined || backwards(entry)) {
2289
+ this.entry = { from: { ...entry.from, checked: true }, to: { ...entry.to, checked: true } };
2290
+ void this.updateComplete.then(() => this.renderRoot.querySelector('[part="brush"] [aria-invalid="true"]')?.focus());
2291
+ return;
2292
+ }
2293
+ this.entry = null;
2294
+ this.setClause(verb, { family: 'numeric', gte: entry.from.value, lte: entry.to.value });
2295
+ this.focusOpener();
2296
+ }
2297
+ /** Give focus back to the plot or to Set range, whichever opened the range box, unless the box is open again by then. */
2298
+ focusOpener() {
2299
+ void this.updateComplete.then(() => {
2300
+ if (!this.entry)
2301
+ this.renderRoot.querySelector(`[part="${this.entryOpener}"]`)?.focus();
2302
+ });
1198
2303
  }
1199
2304
  /** Which bin a pointer is over. */
1200
2305
  binAt(e, n) {
@@ -1207,52 +2312,67 @@ let MosaicaFieldCard = (() => {
1207
2312
  if (e.button !== 0 || n === 0)
1208
2313
  return;
1209
2314
  const i = this.binAt(e, n);
1210
- this.brush = { from: i, to: i, open: false };
2315
+ this.brush = { from: i, to: i };
1211
2316
  e.currentTarget.setPointerCapture?.(e.pointerId);
1212
2317
  e.preventDefault();
1213
2318
  }
1214
2319
  brushMove(e, n) {
1215
- if (!this.brush || this.brush.open)
2320
+ if (!this.brush)
1216
2321
  return;
1217
2322
  const to = this.binAt(e, n);
1218
2323
  if (to !== this.brush.to)
1219
2324
  this.brush = { ...this.brush, to };
1220
2325
  }
1221
- brushEnd(e, n) {
1222
- if (!this.brush || this.brush.open)
2326
+ brushEnd(e, n, open) {
2327
+ if (!this.brush)
1223
2328
  return;
1224
- this.brush = { ...this.brush, to: this.binAt(e, n), open: true };
1225
- void this.updateComplete.then(() => this.renderRoot.querySelector('[part="brush-filter"]')?.focus());
2329
+ this.brush = { ...this.brush, to: this.binAt(e, n) };
2330
+ open('plot');
1226
2331
  }
1227
- /** The arrow keys move a one-bin range, Shift widens it, Enter opens its box and Escape drops it. */
1228
- brushKey(e, n) {
2332
+ /**
2333
+ * The arrow keys move a one-bin range, Shift widens it and Escape drops it. Enter opens the range
2334
+ * box, on that range or, with none chosen, on the range the field holds.
2335
+ */
2336
+ brushKey(e, n, open) {
1229
2337
  if (n === 0)
1230
2338
  return;
1231
- if (e.key === 'Escape' && this.brush) {
2339
+ if (e.key === 'Escape' && (this.brush || this.entry)) {
1232
2340
  e.stopPropagation();
1233
2341
  this.brush = null;
2342
+ this.entry = null;
1234
2343
  return;
1235
2344
  }
1236
- if (e.key === 'Enter' && this.brush) {
2345
+ if (e.key === 'Enter') {
1237
2346
  e.preventDefault();
1238
- this.brush = { ...this.brush, open: true };
1239
- void this.updateComplete.then(() => this.renderRoot.querySelector('[part="brush-filter"]')?.focus());
2347
+ if (!this.entry)
2348
+ open('plot');
1240
2349
  return;
1241
2350
  }
1242
2351
  const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
1243
2352
  if (step === 0)
1244
2353
  return;
1245
2354
  e.preventDefault();
1246
- const b = this.brush ?? { from: 0, to: 0, open: false };
2355
+ this.entry = null;
2356
+ const b = this.brush ?? { from: 0, to: 0 };
1247
2357
  const to = Math.min(n - 1, Math.max(0, b.to + (this.brush ? step : 0)));
1248
- this.brush = e.shiftKey ? { from: b.from, to, open: false } : { from: to, to, open: false };
2358
+ this.brush = e.shiftKey ? { from: b.from, to } : { from: to, to };
1249
2359
  }
2360
+ /**
2361
+ * The box lies over what is under the plot, so it closes as focus goes on to that. Focus that
2362
+ * goes to nothing has not left, and a box already taken off the page has nothing to close.
2363
+ */
2364
+ onBrushLeft = (e) => {
2365
+ const box = e.currentTarget;
2366
+ const to = e.relatedTarget;
2367
+ if (box.isConnected && to && !box.contains(to))
2368
+ this.entry = null;
2369
+ };
1250
2370
  onBrushKey = (e) => {
1251
2371
  if (e.key !== 'Escape')
1252
2372
  return;
1253
2373
  e.stopPropagation();
1254
- this.brush = null;
1255
- void this.updateComplete.then(() => this.renderRoot.querySelector('[part="plot"]')?.focus());
2374
+ this.entry = null;
2375
+ this.focusOpener();
1256
2376
  };
1257
2377
  // ---- colours ---------------------------------------------------------------------------------
1258
2378
  /** Open the colour picker on a row's swatch: a category value's, or a cluster's. */