Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions static/css/redis-workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
191 changes: 190 additions & 1 deletion static/js/redis-workbench.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
paoloredis marked this conversation as resolved.
commands: commands
});
self.end();
Expand All @@ -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;
};
Comment thread
cursor[bot] marked this conversation as resolved.

/* 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;
Comment thread
cursor[bot] marked this conversation as resolved.
}

dock.renderVectorElement = function (key, element, detail) {
var self = this;
var pane = this.valuePane;
Expand Down Expand Up @@ -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);
});
Comment thread
cursor[bot] marked this conversation as resolved.
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. */
Expand Down Expand Up @@ -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
Expand All @@ -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. */
Expand Down
Loading