diff --git a/static/css/redis-workbench.css b/static/css/redis-workbench.css index 206aec4ec3..a4a58762e9 100644 --- a/static/css/redis-workbench.css +++ b/static/css/redis-workbench.css @@ -1196,6 +1196,158 @@ body.rwb-dragging-y * { comes later in this file and, at equal specificity, was winning. */ .rwb-text.rwb-failed { color: #fca5a5; } /* red-300 */ +/* ---- bars ---- + VSIM's neighbours: one row per result in the order the server returned them, + with the score printed beside the bar. A score runs 1 (identical) to 0 + (opposite), so it is already the share of the track and needs no axis. Named + for what it is rather than for VSIM — the same row suits anything the server + ranks. */ +.rwb-vsim-controls { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.25rem 0.5rem 0.5rem; +} + +.rwb-vsim-counts { + display: flex; + flex: none; + gap: 0.25rem; +} + +/* .rwb-chip is laid out for the KEYS heading, where it is pushed to the end and + carries a word. Here it carries a number, so it is a circle of one size for + every count — a square box with the padding dropped, and the digits centred on + both axes rather than sitting where a label's padding put them. */ +.rwb-vsim-counts .rwb-chip { + margin-left: 0; + max-width: none; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + gap: 0; + font-size: 11px; + line-height: 1; + border-radius: 50%; +} + +.rwb-chip-on { + color: var(--rwb-ink); + border-color: var(--rwb-muted); + background: var(--rwb-bg-2); +} + +.rwb-vsim-filter { + flex: 1 1 auto; + min-width: 0; + font-family: ui-monospace, "Cascadia Code", Menlo, monospace; + font-size: 12px; + line-height: 1.5; + color: var(--rwb-ink); + background: var(--rwb-bg-2); + border: 1px solid var(--rwb-line); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; +} + +.rwb-vsim-filter::placeholder { color: var(--rwb-muted); } + +/* The × that drops the filter. As tall as Search beside it — .rwb-btn's vertical + padding kept, its horizontal padding dropped, and the glyph set larger inside + that same box rather than growing it. Square by taking its width from that + height, so it reads as one button rather than a short wide one. */ +.rwb-vsim-clear { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 25px; /* the height this box comes to: 17px + 2 × 0.25rem */ + padding: 0.25rem 0; + font-size: 15px; + line-height: 17px; +} + +.rwb-vsim-filter:focus-visible { + outline: 2px solid var(--rwb-focus); + outline-offset: 1px; +} + +.rwb-bars { + display: flex; + flex-direction: column; + margin: 0 0.5rem; +} + +.rwb-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.25rem 0.25rem; + font: inherit; + font-size: 12px; + color: var(--rwb-ink-2); + text-align: left; + background: none; + border: 0; + border-bottom: 1px solid var(--rwb-line-soft); + cursor: pointer; +} + +.rwb-bar-row:hover { color: var(--rwb-ink); background: var(--rwb-bg-2); } + +/* The row everything else is measured from: no walking to where you already + are. */ +.rwb-bar-marked { cursor: default; } +.rwb-bar-marked:hover { background: none; } + +.rwb-bar-name { + flex: 0 0 35%; + display: flex; + align-items: baseline; + gap: 0.375rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, "Cascadia Code", Menlo, monospace; +} + +.rwb-bar-tag { + flex: none; + font-family: inherit; + font-size: 10px; + color: var(--rwb-muted); +} + +.rwb-bar-track { + flex: 1 1 auto; + height: 6px; + min-width: 0; + background: var(--rwb-bg-3); + border-radius: 3px; + overflow: hidden; +} + +.rwb-bar-fill { + display: block; + height: 100%; + background: #0284c7; + border-radius: 3px; +} + +/* Wide enough that a column of scores lines up, free to grow for a value that + does not fit one — "0.9993" and "8955.05 km" share this column. */ +.rwb-bar-value { + flex: none; + min-width: 6ch; + white-space: nowrap; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--rwb-muted); +} + .rwb-value-head { display: flex; align-items: center; diff --git a/static/js/redis-workbench.js b/static/js/redis-workbench.js index ce536a4d15..713e756e90 100644 --- a/static/js/redis-workbench.js +++ b/static/js/redis-workbench.js @@ -2295,15 +2295,19 @@ re-renders whatever is selected — which used to mean the element view was replaced by the key's own the moment the reader typed anything. */ this.openElement = { name: key.name, element: element }; + var search = this.vsimOptions(key.name); var commands = [ 'VEMB ' + quote(key.name) + ' ' + quote(element), - 'VGETATTR ' + quote(key.name) + ' ' + quote(element) + 'VGETATTR ' + quote(key.name) + ' ' + quote(element), + vsimCommand(key.name, element, search) ]; this.begin('reading ' + element + '…'); return run(commands).then(function (replies) { self.renderVectorElement(key, element, { vector: ok(replies[0]), attributes: ok(replies[1]), + neighbours: replies[2], + search: search, commands: commands }); self.end(); @@ -2313,6 +2317,34 @@ }); }; + /* How many neighbours to ask for, and the filter to ask with. Kept on the dock + so it survives the re-render a sweep does, and so walking from element to + element keeps the search the reader set up. */ + var VSIM_COUNTS = [5, 10, 25]; + + dock.vsimOptions = function (name) { + if (!this.vsim) this.vsim = { name: name, count: VSIM_COUNTS[0], filter: '' }; + /* A filter is written against one set's attributes: `.year > 2000` means + nothing to a set whose elements carry `.side`, and carried over it hid + every neighbour on a set the reader had only just opened. How many to + return is a preference about the list rather than about the data, so that + one follows the reader from set to set. */ + if (this.vsim.name !== name) { + this.vsim.name = name; + this.vsim.filter = ''; + } + return this.vsim; + }; + + /* The element itself comes back first with a score of 1, so ask for one more + than the reader wanted and the list still holds that many neighbours. */ + function vsimCommand(name, element, search) { + var command = 'VSIM ' + quote(name) + ' ELE ' + quote(element) + + ' WITHSCORES COUNT ' + (search.count + 1); + if (search.filter) command += ' FILTER ' + quote(search.filter); + return command; + } + dock.renderVectorElement = function (key, element, detail) { var self = this; var pane = this.valuePane; @@ -2350,9 +2382,92 @@ 'None set. VSETATTR attaches JSON metadata to an element.')); } + /* The nearest elements to this one, which is the question a vector set + exists to answer. Clicking one walks to it and asks again — the way a + reader actually explores an embedding space. */ + pane.appendChild(el('div', 'rwb-group', 'Nearest')); + pane.appendChild(this.vsimControls(key, element, detail.search)); + pane.appendChild(this.renderNeighbours(key, element, detail.neighbours, + detail.search)); + pane.appendChild(ranNote(detail.commands)); }; + /* How many to return, and an attribute filter to return them through. The + filter is the one part of a vector set a reader cannot see the effect of + from a reply, so it gets a box rather than a paragraph. */ + dock.vsimControls = function (key, element, search) { + var self = this; + var row = el('form', 'rwb-vsim-controls'); + + /* Built before the chips, which read it: a chip is another way to run this + search, so it takes the filter as typed rather than as last submitted. */ + var filter = el('input', 'rwb-vsim-filter'); + filter.type = 'text'; + filter.value = search.filter; + filter.setAttribute('spellcheck', 'false'); + filter.placeholder = 'FILTER, e.g. .year > 2000'; + filter.title = 'An expression over element attributes; see Filtered search'; + filter.setAttribute('aria-label', 'Filter expression'); + + var counts = el('div', 'rwb-vsim-counts'); + counts.setAttribute('role', 'group'); + counts.setAttribute('aria-label', 'How many neighbours'); + VSIM_COUNTS.forEach(function (count) { + var choice = el('button', 'rwb-chip' + (count === search.count ? ' rwb-chip-on' : ''), + String(count)); + choice.type = 'button'; + choice.title = 'Return the ' + count + ' nearest elements'; + choice.setAttribute('aria-pressed', count === search.count ? 'true' : 'false'); + choice.addEventListener('click', function () { + /* The reply rebuilds this row from what was searched with, so a filter + the reader had typed but not run would be wiped by the new box. */ + self.vsimOptions(key.name).filter = filter.value.trim(); + self.vsimOptions(key.name).count = count; + self.openVectorElement(key, element); + }); + counts.appendChild(choice); + }); + row.appendChild(counts); + row.appendChild(filter); + + /* One click back to the unfiltered neighbours. Only while a filter is in + force: clearing an empty box is a control that does nothing. Emptying the + box by hand and pressing Enter does the same thing — this saves the two + steps. */ + if (search.filter) { + var clear = el('button', 'rwb-btn rwb-vsim-clear', '\u00d7'); + clear.type = 'button'; + clear.title = 'Clear the filter and show every neighbour'; + clear.setAttribute('aria-label', 'Clear the filter'); + clear.addEventListener('click', function () { + self.vsimOptions(key.name).filter = ''; + self.openVectorElement(key, element).then(function () { + self.focusVsimFilter(0); + }); + }); + row.appendChild(clear); + } + + var go = el('button', 'rwb-btn', 'Search'); + go.type = 'submit'; + go.title = 'Run VSIM with this filter'; + row.appendChild(go); + + row.addEventListener('submit', function (event) { + event.preventDefault(); + /* Where the caret was, to put it back in the box that replaces this one: + the reply re-renders the whole pane, so the box the reader typed into is + gone by the time the neighbours are up. */ + var caret = filter.selectionStart; + self.vsimOptions(key.name).filter = filter.value.trim(); + self.openVectorElement(key, element).then(function () { + self.focusVsimFilter(caret); + }); + }); + return row; + }; + /* "Path: $" over a JSON document, and what it matched underneath. Enter runs it; the root is what openKey already showed, so an untouched box changes nothing. */ @@ -2414,6 +2529,16 @@ return row; }; + dock.focusVsimFilter = function (caret) { + if (!this.valuePane) return; + var box = this.valuePane.querySelector('.rwb-vsim-filter'); + if (!box) return; + box.focus({ preventScroll: true }); + var at = typeof caret === 'number' ? Math.min(caret, box.value.length) + : box.value.length; + box.setSelectionRange(at, at); + }; + /* Back in the Path box after a read, caret where the reader left it. Reading redraws the whole value pane, so the box they pressed Enter in is @@ -2434,6 +2559,70 @@ box.setSelectionRange(at, at); }; + /* One bar per row, in the order given — both callers ask the server to sort, so + the bars only have to be read, not sorted. No axis: the numbers are printed + beside them and the heading says the unit. + + rows: [{name, share (0..1), label, mark, pick}] — mark names this row as the + one the others are measured against, pick makes it the next one. */ + function renderBars(rows) { + var list = el('div', 'rwb-bars'); + rows.forEach(function (row) { + var line = el(row.pick ? 'button' : 'div', + 'rwb-bar-row' + (row.mark ? ' rwb-bar-marked' : '')); + if (row.pick) { + line.type = 'button'; + line.title = row.title || row.name; + line.addEventListener('click', row.pick); + } + + var name = el('span', 'rwb-bar-name', row.name); + if (row.mark) name.appendChild(el('span', 'rwb-bar-tag', row.mark)); + line.appendChild(name); + + var track = el('span', 'rwb-bar-track'); + var fill = el('span', 'rwb-bar-fill'); + fill.style.width = (Math.max(0, Math.min(1, row.share)) * 100).toFixed(1) + '%'; + track.appendChild(fill); + line.appendChild(track); + + line.appendChild(el('span', 'rwb-bar-value', row.label)); + list.appendChild(line); + }); + return list; + } + + /* VSIM's answer. Scores run 1 (identical) to 0 (opposite), so a score is + already the share of the bar. */ + dock.renderNeighbours = function (key, element, reply, search) { + var self = this; + if (reply && reply.error) { + return el('p', 'rwb-text rwb-failed', '(error) ' + cellText(reply.value)); + } + var rows = pairs(ok(reply)).map(function (pair) { + return { name: cellText(pair[0]), score: Number(cellText(pair[1])) }; + }); + /* One more than the chip was asked for, because the element itself normally + comes back first. A filter it fails — no attributes at all is enough — + leaves it out, and then that spare row is a neighbour nobody asked for. */ + var mine = rows.some(function (row) { return row.name === element; }); + if (!mine && search) rows = rows.slice(0, search.count); + if (!rows.length) { + return el('p', 'rwb-empty', 'Nothing came back — no element passes this filter.'); + } + return renderBars(rows.map(function (row) { + var here = row.name === element; + return { + name: row.name, + share: row.score, + label: isNaN(row.score) ? '—' : row.score.toFixed(4), + mark: here ? 'this one' : null, + title: 'Look at ' + row.name, + pick: here ? null : function () { self.openVectorElement(key, row.name); } + }; + })); + }; + /* JSON.GET at a path. A path that matches nothing answers with an empty array and a path that does not parse answers with an error, and both are worth seeing: getting them wrong is how the syntax is learned. */