@mosaicajs/components 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/aggregate.d.ts +4 -10
- package/dist/aggregate.js +7 -13
- package/dist/artifact-card.d.ts +21 -7
- package/dist/artifact-card.js +136 -79
- package/dist/base.d.ts +44 -5
- package/dist/base.js +54 -5
- package/dist/cluster-filter.d.ts +29 -25
- package/dist/cluster-filter.js +123 -106
- package/dist/colour-editor.d.ts +15 -4
- package/dist/colour-editor.js +107 -34
- package/dist/colour-picker.d.ts +12 -5
- package/dist/colour-picker.js +119 -32
- package/dist/display.js +77 -9
- package/dist/events.d.ts +21 -7
- package/dist/explorer.d.ts +116 -37
- package/dist/explorer.js +476 -210
- package/dist/field-card.d.ts +304 -48
- package/dist/field-card.js +1364 -244
- package/dist/filter-panel.d.ts +54 -10
- package/dist/filter-panel.js +242 -42
- package/dist/filter.d.ts +22 -4
- package/dist/filter.js +113 -34
- package/dist/hierarchy.d.ts +68 -45
- package/dist/hierarchy.js +320 -243
- package/dist/item-card.d.ts +47 -16
- package/dist/item-card.js +241 -108
- package/dist/key-picker.d.ts +2 -1
- package/dist/key-picker.js +4 -3
- package/dist/layer-picker.d.ts +4 -2
- package/dist/layer-picker.js +16 -7
- package/dist/lineage.d.ts +132 -0
- package/dist/lineage.js +307 -0
- package/dist/map.d.ts +135 -24
- package/dist/map.js +387 -117
- package/dist/mosaica-components.js +1837 -833
- package/dist/mosaica-components.js.sri +1 -1
- package/dist/mosaica-components.json +2 -2
- package/dist/parts.d.ts +4 -4
- package/dist/parts.js +18 -4
- package/dist/paths.d.ts +34 -12
- package/dist/paths.js +84 -46
- package/dist/selection.d.ts +22 -3
- package/dist/selection.js +125 -59
- package/dist/states.d.ts +72 -0
- package/dist/states.js +135 -26
- package/dist/status.d.ts +63 -5
- package/dist/status.js +215 -36
- package/dist/tokens.d.ts +8 -4
- package/dist/tokens.js +144 -14
- package/dist/view-picker.js +5 -0
- package/package.json +4 -3
package/dist/field-card.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
41
|
-
import {
|
|
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
|
-
*
|
|
108
|
-
* there is a highlight. The pale bars count every item the
|
|
109
|
-
* card's own filter, so a value the viewer has filtered
|
|
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
|
|
114
|
-
* Filter buttons
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
|
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
|
|
151
|
-
* colour
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* @csspart
|
|
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
|
-
*
|
|
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
|
|
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
|
|
198
|
-
let
|
|
199
|
-
let
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
459
|
-
|
|
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-
|
|
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-
|
|
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:
|
|
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)
|
|
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
|
-
/*
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
#
|
|
643
|
-
/**
|
|
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
|
-
#
|
|
651
|
-
/**
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
665
|
-
|
|
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
|
|
671
|
-
* view,
|
|
672
|
-
* frame arrives, which changes none of
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
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.
|
|
1392
|
+
return rankedGrouping(layer, top, this.listLevel(layer) ?? null);
|
|
756
1393
|
}
|
|
757
|
-
/** The groupings counting `ids` of `layer
|
|
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.
|
|
760
|
-
const rows = [...new Set(ids)].map((id) => ({ mosaicaId: id, rung:
|
|
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
|
-
/**
|
|
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
|
|
786
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
|
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
|
|
800
|
-
const listed = [...(countsByKey(
|
|
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
|
|
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('
|
|
825
|
-
if (this.
|
|
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
|
|
834
|
-
if (
|
|
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.
|
|
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
|
|
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"><
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
|
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 =
|
|
947
|
-
const all =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
|
1006
|
-
${
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
const
|
|
1012
|
-
|
|
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
|
|
1017
|
-
|
|
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(
|
|
1022
|
-
const allTotal = keptTotal(
|
|
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
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
: inFilter
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
:
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
|
1066
|
-
|
|
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)}>${
|
|
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
|
-
|
|
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
|
|
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
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
|
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
|
|
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=${`${
|
|
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=${
|
|
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}
|
|
1139
|
-
<
|
|
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
|
|
1143
|
-
brushPlace(
|
|
1144
|
-
const from
|
|
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
|
|
1189
|
-
|
|
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
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
|
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
|
|
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
|
|
2326
|
+
brushEnd(e, n, open) {
|
|
2327
|
+
if (!this.brush)
|
|
1223
2328
|
return;
|
|
1224
|
-
this.brush = { ...this.brush, to: this.binAt(e, n)
|
|
1225
|
-
|
|
2329
|
+
this.brush = { ...this.brush, to: this.binAt(e, n) };
|
|
2330
|
+
open('plot');
|
|
1226
2331
|
}
|
|
1227
|
-
/**
|
|
1228
|
-
|
|
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'
|
|
2345
|
+
if (e.key === 'Enter') {
|
|
1237
2346
|
e.preventDefault();
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1255
|
-
|
|
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. */
|