Picking and querying
How a reader names an entity in the viewer, walks from one answer to the next, searches by name, and sees what is already known about a selection.
A reader names an entity in the viewer in three ways: by clicking the drawing, by clicking a cell in
a query result, or by searching for a name. All three produce the same Selection value and all
three leave an editable datalog query in the box. This page covers those paths, and what the viewer
can then say about what is selected. The render and highlight contracts underneath them are on
Web app and presenter.
Picking: the drawing is the entry point ¶
Clicking an entity is how a reader asks about it, and the rule is that each renderer owns its own
selection data. The SVG backend keys every element it draws (data-kind, data-ref, data-pin,
data-net, data-net-id, data-bus); the WebGL backend has PrimitiveKey, whose proto comment has
always said it exists for picking. Neither reads the other’s representation, so either can be retired
without stranding the other, and a keyed SVG stays self-describing wherever it is embedded.
What IS shared is the contract, not the index: a Selection (the same shape as a finding’s subject),
the priority order, and the intent. That is what lets a canvas click, a search result and a finding
row produce one value.
- Priority, not topmost. A symbol is drawn over its own pins, so topmost-wins would make a pin unclickable. Resolution goes pin, component, bus, net, most specific first.
- A click is not a pan. The press/release pair counts as a click only if the cursor moved less than a few pixels, or a pan that ends over a wire selects it.
- Two pick aids, both opt-in (
render.WithPickTargets, which only the served viewer asks for). A pin has no drawn element in a faithful render, so it gets an invisible circle. A wire is a 0.8px stroke andfill="none"hit-tests only ON the stroke. Measured in a browser, a probe at a wire’s own midpoint rounded to whole pixels hits the page rect, so it gets an invisible wide companion withpointer-events="stroke". Both are absent from a render destined for a file or a report, which should not carry the viewer’s interaction model. - Entity keys are design data, so they are escaped (
svg.AEsc). Attribute values are written verbatim otherwise, and the viewer mounts this document withinnerHTML.
A click generates a query and runs it, rather than opening a bespoke panel. The query is left in the box, editable, so using the viewer teaches the language instead of routing around it.
Walking: an answer becomes the next question ¶
A result cell is the second way a reader names an entity. The server types each answer column with an
entity kind (RunQueryResponse.column_kinds, derived from the relation catalog’s arg labels), so a
component or net cell reads back as the same Selection a canvas click produces
(selectionFromCell). Clicking one locates it, as it always did, and now also selects it: the query
panel names what is selected and offers the served preset for that kind as a plain-language question.
Taking it fills the box and runs, which lands the reader on a fresh set of cells to walk from.
Two things are deliberate here. A cell click does not itself re-run the query, because scanning a
result set and highlighting each row in turn is what the locate affordance was for, and a click that
replaced the table would take that away. And the question’s wording lives on the client (askLabel,
beside the locate-reason copy) while the query it runs comes from the server, the same split every
other served preset uses.
A pin cell takes its other half from its row. A pin’s identity is two fields, so the cell holding
5 names nothing until you know it means U7’s pin 5, which is why a pin column typed as a scalar for
so long. QueryRow.cell_refs carries the component per row, and selectionFromCell(kind, cell, ref)
reads both.
Where the ref comes from, what counts as a pin column, and why a pin cannot be searched by name
cell_refs is a sibling of cell_kinds rather than the same mechanism, and the distinction is worth
keeping straight. A pin column’s KIND is fixed, since every row of it is a pin; what varies per row
is the REF. The ref may come from a sibling column (pin.net(?ref, ?pin, ?net)), from a constant
(pin.net("U1", ?pin, ?net)), or from a variable the projection dropped, so it resolves against the
row’s bindings rather than against the visible columns.
The rule for what counts as a pin column is the pairing again: a pin position is a DESIGN pin only
when the same atom also carries a ref_des. param.pin(mpn, pin, name, function) and
param.pin_range pair pin with an mpn instead, and theirs is a pin of a part TYPE off a
datasheet, with nothing on any canvas to highlight.
Downstream, a pin resolves through its component: its sheets are the component’s placements, and so
is its locate reason, so a pin of a virtual #PWR symbol reports VIRTUAL_SYMBOL rather than a
missing render. Asking either question about the designator would be asking about a thing called
5. A pin whose component does not resolve stays plain text, since half a pin is not a less precise
pin.
Searching for a pin by name is still not possible. entity(name, kind) deliberately does not
enumerate pins, because a pin cannot be one name without inventing a composite string nothing else
in the fact base joins against, and pin(?ref, ?pin) already enumerates them for anyone writing the
query by hand.
The table marks where the reader is standing, because a click sends the canvas somewhere and a
forty-row result otherwise says nothing about which answer it came from. A cell is marked when
sameSelection matches it against the current selection, and a sheet badge when its sheet is also
the one on screen (QueryView.setCurrentSheet, pushed from showSheet on EVERY navigation).
Both marks are DERIVED rather than remembered. A remembered mark is wrong the moment the reader picks something on the drawing or opens a finding, and wrong silently, which is the worst way for a you-are-here marker to fail. Two consequences follow and both are deliberate. An entity answering in several rows marks all of them, which is true, since they all name the thing being shown. And a marked badge past the strip’s three-chip cap forces the cut to grow, because a mark nobody can see is worse than no mark: the reader would read the unmarked strip as being somewhere else.
Searching: the same lesson from the other end ¶
A click says where to look and gets back what is there. A search says what a thing is called and gets back where it is. Both write datalog into the box and run it, which is why search is a MODE on the query panel rather than a widget of its own: what the reader keeps is an editable query either way.
The panel’s “Find by name” mode takes a term, fills the served template
(ListRelationsResponse.search_query, from query.Search()), runs it, and hands the panel back to
query mode so the reader ends up looking at the sentence that answered them. The template ranges over
entity(?name, ?kind) because every other relation ranges over an ASSOCIATION, so a search built on
one silently cannot find a part with no connections or a net with nothing on it. It matches with
match and (?i) rather than contains, so case does not have to be guessed at and a reader who
wants ^U can write it. The client regex-escapes the typed term (searchPattern, mirroring Go’s
regexp.QuoteMeta), because VDD+ and DATA[7:0] are ordinary names here.
A search result is the one answer set whose rows are not all the same shape, and that is what
made this more than a text box. Kind is normally a COLUMN property, since a variable binds at the
same relation position in every row. Under entity(?name, ?kind) one answer set holds a component, a
net and a bus, so column_kinds has nothing true to say about the name column and types it scalar,
which is to say unclickable. QueryRow.cell_kinds is the per-row override: empty for every ordinary
query, and read through cellKind(result, row, i) so the fallback to column_kinds lives in one
place. It types the sheet-badge lookup and the locate reason as well as the click, since a hit
nobody can navigate from is only a third of an answer.
A bus is the case that would have broken quietly. It is neither a placement nor a named wire, so
neither drawn-entity set can speak for it, and the honest test is whether it resolved to a sheet at
all, the same rule AnnotateSheets applies to a bus finding.
Inspecting: what is already known about the selection ¶
A selection now also answers “what has been checked about this” (agni issue 259). The query panel’s selection bar carries a findings count for the picked entity, and the results footer carries one for every entity the query returned.
It is a projection of one evaluation and never a scoped re-run. Every Finding carries exactly
one Subject, so grouping by subject partitions the findings, and the client already holds the whole
list. findingsFor(findings, selections) in findings.ts is the filter. The presenter fans ONE
FindingsState to both the checks panel and the query panel from a single pushFindings, so the two
cannot disagree.
Why a scoped re-run was ruled out, and why the filter takes a set
The alternative fails in three ways at once. A scoped run resolves config independently and can contradict the report beside it, the seam C25 exists to protect. It redoes net solving and reach walks once per click. And it makes “the union of what I clicked equals the full pass” a hope rather than a property.
findingsFor takes a SET rather than one subject, because a set is the primitive and one click is
its degenerate case. The results footer is the set case in the UI: every locatable cell, deduped by
sameSelection, which is also how a finding matching several of the given subjects is still counted
once. selectionFromFinding is the third producer of a Selection, after a keyed element and a
result cell, so the canvas, the query table and the checks panel share one identity rule.
A count needs its state or it lies. A zero has four meanings and only one of them is “nothing is
wrong here”: no rules are selected, nobody has run them, the ruleset is half-evaluated, or it ran
clean. checkedState names which, and the panel prints the name rather than a bare number, because
the reassuring reading is the one a reviewer acts on. A half-run ruleset reports its count as a floor.
An unresolved result is not a defect and not a pass. Finding.inconclusive marks a result the
rule could not DECIDE: it ran, it had what it needed, it examined this subject, and it could not
conclude (agni issue 74). The client dropped that field for its whole life, so every surface counted
“could not decide” as “found a problem”. It now rides on FindingItem, and tallySeverities counts
it apart and excludes it from the defect total, including from its own severity bucket, since an
inconclusive error is not an error.
It is also the per-SUBJECT axis, which is worth holding against the skip vocabulary. The needs-* outcomes are PRECONDITIONS, decided around the rule and always design-wide; this sits on the other side of the rule and is about one subject. So an entity view already answers “what could not be decided here” without inventing anything, and the remedy lives in the message the way it does for a defect.
A gated rule reports nothing anywhere, which is the largest thing a per-entity count leaves out.
FindingsState.skipped is design-wide, so the count states how many selected rules could not run at
all and gives the engine’s own reasons on hover. A clean entity under a half-gated ruleset is a much
weaker statement than a clean entity under a full one, and nothing on the entity itself can say so.
And the count says what it is not. An entity view enumerates attention; a review pass enumerates the design. A design-global rule has no subject and can never appear beside an entity. The caveat is rendered beside every count rather than parked in a hover, since a caveat nobody sees is a caveat nobody has.
A rule about two entities used to name one of them and lose the other. Finding carries exactly
one Subject, so a rule whose sentence involves two entities could highlight only one, and it was
not always the one the sentence named. crystal-load-caps reads “crystal terminal net XOUT1 has no
load capacitor” with the crystal as its subject, so clicking it sent the reader to a part while the
message talked about a net. No client-side filter reached that: the net was bound in the rule’s query
and destroyed when the Finding was built, so the panel had nothing to render.
Finding.context now carries those entities as structured data, each with a role naming the part it
plays (agni issue 349). The panel renders them as their own clickable chips beside the message, so the
net in the sentence is reachable. They sit after the message rather than in the subject column,
because the subject column answers “what failed” and these answer “what else the sentence mentions”.
A context entity is deliberately NOT counted as a finding about itself. Grouping by subject partitions the findings, which is what makes “the union of what I clicked equals the full pass” a fact rather than a hope, so counting one finding under two entities would break the property the per-entity view rests on. Context makes a finding REACHABLE from another entity without making it ABOUT that entity.
Table cells wrap, then scroll, and never bleed ¶
The results table is fixed-layout, which is what keeps the columns equal by default and makes a dragged width stick. Fixed layout does not clip: a cell whose content will not wrap draws straight over the columns to its right, and the column never widens to fit it. That happened on a 21-sheet design, where the sheet-badge strip on a ground net was one nowrap line and painted over the two columns beside it, its last badge’s right edge sitting 1927px past its own cell.
So a cell has three behaviours in order. overflow-wrap: anywhere breaks a long unbroken value, which
handles nearly everything (a 66-character net name in a 120px column wraps to four lines and stays
inside). overflow-x: auto catches whatever still cannot wrap, so the cell scrolls within its own
column. Nothing is allowed to paint across a neighbour. A td honours overflow-x directly, checked
in Chromium, so no inner wrapper is needed.
The badge strip itself is SheetBadges in web/src/sheetbadges.tsx, shared by the query, findings and
diff-changes panels, and it shows the first three with a +N chip for the rest. Past a handful,
“which sheet” is a menu to open rather than a label to read. The cap is what makes the common row one
line tall; it is not what keeps the table honest, since a reader can expand the strip.