Ajouter un volet Dock
Le dock inférieur est une région partagée, et non la propriété privée de la table de données : une ligne de grille, une hauteur, un pli, quel que soit le nombre de volets qu'il contient. addDockTab() est la façon dont vous mettez autre chose là-dedans - la même porte par laquelle passe la table intégrée, donc un volet que vous enregistrez est son égal plutôt que son invité.
Cette carte ajoute un volet Résumé qui regroupe le graphique par propriétaire ou par type. La bande du quai indique Table │ Summary ; cliquez dessus et le corps * et * les contrôles d'en-tête changent avec lui, car Select all / CSV / Columns appartiennent à la table et ne signifient rien sur un roll-up.
Un onglet est un volet
Le volet Résumé affiche le graphique de deux manières — Par propriétaire et Par type — et ce sont vues d'un volet, et non de deux volets. Il enregistre donc un onglet de dock single et dessine son propre commutateur, appelant refresh() pour changer de corps.
Cette distinction est toute la conception. Enregistrer les deux vues sous forme de deux onglets du dock les répertorierait dans la bande du dock à côté de Table, affirmant qu'une vue du résumé et l'ensemble du tableau de données sont le même genre de chose. Le tableau intégré fait exactement ce que fait ce volet : Nodes et Edges sont ses onglets, dessinés sur sa propre barre.
Les deux niveaux sont dessinés différemment afin qu'ils puissent être placés l'un à côté de l'autre tout en restant lisibles comme un extérieur et un intérieur :
| Regardez | Classe | |
|---|---|---|
Table │ Summary | onglets pleine hauteur, soulignés lorsqu'ils sont actifs, fermés par une règle | pvt-dock-tabs / pvt-dock-tab |
Nodes │ Edges, By owner │ By kind | un petit groupe de pilules | pvt-dock-views / pvt-dock-view |
La paire interne est publique, c'est pourquoi le commutateur ci-dessous est constitué de trois noms de classe plutôt que d'un bloc de style en ligne : votre volet obtient le même contrôle que la table intégrée, et un consommateur qui conserve --pvt-theme-primary ou remplace l'une ou l'autre classe conserve les deux niveaux à la fois.
refresh() n'est pas une politesse facultative
Le dock conserve l'élément renvoyé par votre render et le refixe lorsque votre vitre revient vers l'avant. Un volet qui échangeait son propre DOM quitterait le dock contenant un nœud obsolète à restituer plus tard. Ainsi, le changement de corps passe par refresh(), qui appelle à nouveau render.
Savoir quand personne ne regarde
onActivate / onDeactivate sont le seul signal qu'un volet reçoit indiquant qu'il est hors écran, et ce que vous en faites est à vous de choisir :
- Le contenu qui est une fonction de l'état actuel du graphique peut cesser de fonctionner lorsqu'il est masqué et redérivé au retour. C'est ce que fait ce volet et ce que fait le tableau de données.
- Un contenu qui ferait manquer quelque chose doit continuer à fonctionner et simplement cesser de peindre – un panneau surveillant un bus en direct, par exemple, puisqu'un événement disparaît une fois qu'il s'est déclenché.
Rien à propos des crochets ne préfère non plus.
Il amène le quai avec lui
Les plugins s'installent après l'interface utilisateur est construite, donc un onglet dock arrive toujours après l'exécution de la propre porte de mode de la région. En enregistrer un donc construit le dock — votre plugin fonctionne avec UI.table: false et n'a besoin que du mode full. Avec la table retirée et une vitre à vous, aucune bande n'est dessinée : le quai est simplement votre vitre.
UI.dock configure la région elle-même (open, collapsed, height) — atteignez-la plutôt que UI.table lorsque la table est éteinte, puisque c'est alors la seule porte.
Voir Plugins pour le contrat et Data table pour les propres options du dock.
// The bottom dock is a shared region: one row, one height, one fold, however many panes
// are in it. `addDockTab` is the door — the same one the built-in data table comes
// through, so a pane you register is its equal rather than its guest.
//
// **One tab is one pane, not one view of one.** This pane rolls the graph up two ways,
// by owner and by kind. Those are two views of *this* pane, so it registers a single
// dock tab and draws its own switch, calling `refresh()` to change body. Registering
// them as two dock tabs would put them in the dock's strip beside `Table`, claiming a
// view of the summary and the whole data table are the same kind of thing.
//
// That is also why the two levels look different: the dock draws panes as full-height
// underlined tabs, and a pane's own views a lighter pill group. That inner look is public
// — `pvt-dock-views` on the strip, `pvt-dock-view` on each button, `active` on the current
// one — so your pane matches the built-in table for free, and follows the theme when a
// consumer overrides `--pvt-theme-primary` or either class.
const VIEWS = [
{ key: 'owner', label: 'By owner' },
{ key: 'kind', label: 'By kind' },
]
/** Count the nodes under each value of `key`, biggest group first. */
function rollUp(graph, key) {
const counts = new Map()
for (const node of graph.getNodes()) {
const value = node.getData()?.[key] ?? '—'
counts.set(value, (counts.get(value) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])
}
/**
* The pane's body. `render` is called once per pane, lazily, the first time it is
* opened — and again on every `refresh()`, which is how the view switch works.
*/
function renderSummary(graph, key) {
const rows = rollUp(graph, key)
const biggest = Math.max(...rows.map(([, count]) => count), 1)
const body = document.createElement('div')
body.style.cssText = 'padding: 8px 12px; font: 12px/1.9 sans-serif'
for (const [value, count] of rows) {
const row = document.createElement('div')
row.style.cssText = 'display: grid; grid-template-columns: 130px 1fr 34px; gap: 10px; align-items: center'
const name = document.createElement('span')
name.textContent = value
// A bar rather than a number alone: the shape of the split is the point.
const track = document.createElement('span')
track.style.cssText = 'height: 7px; border-radius: 4px; background: var(--pvt-bg-color-4)'
const fill = document.createElement('span')
fill.style.cssText = `display: block; height: 100%; border-radius: 4px; background: var(--pvt-vibrant-blue); width: ${(count / biggest) * 100}%`
track.appendChild(fill)
const tally = document.createElement('span')
tally.textContent = String(count)
tally.style.cssText = 'text-align: right; color: var(--pvt-text-color-3)'
row.append(name, track, tally)
body.appendChild(row)
}
return body
}
/**
* The pane's own controls, in the header slot the dock hands it. `toolbar` is re-invoked
* on every activation, so these always read the pane's current state.
*/
function renderControls(current, pick) {
const strip = document.createElement('div')
strip.className = 'pvt-dock-views'
for (const view of VIEWS) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'pvt-dock-view'
button.textContent = view.label
button.classList.toggle('active', view.key === current)
button.setAttribute('aria-pressed', String(view.key === current))
button.addEventListener('click', () => pick(view.key))
strip.appendChild(button)
}
return strip
}
/**
* Ships as a plugin, so it installs itself and the core never needs to know it exists.
* A plugin's tab also *builds* the dock if there isn't one — plugins install after the
* UI, so it always arrives after the region's own gate has run.
*/
function summaryPane() {
return {
name: 'summaryPane',
install(ctx) {
let view = 'owner'
// Whether the pane is the one on show, and whether the graph moved while it
// wasn't. `onActivate` / `onDeactivate` are the only signal you get, and this
// is the shape most panes want: don't work while hidden, catch up on return.
let visible = false
let stale = false
ctx.addDockTab({
id: 'summary',
label: 'Summary',
render: () => renderSummary(ctx.graph, view),
toolbar: (pane) => renderControls(view, (key) => {
view = key
// The dock keeps the element `render` gave it, so ask it to rebuild
// rather than swapping the DOM behind its back.
pane.refresh()
}),
onActivate: (pane) => {
visible = true
if (stale) { stale = false; pane.refresh() }
},
onDeactivate: () => { visible = false },
})
// The graph outlives nothing here, but a real plugin should keep the
// unsubscribe — see the Extend with a plugin card.
ctx.graph.on('dataBatchChanged', () => {
if (visible) ctx.refreshDockTab('summary')
else stale = true
})
},
}
}
const options = {
UI: {
mode: 'full',
// The dock is the subject, so it starts expanded; the table is the pane our own
// one sits beside, and is what makes the strip appear at all.
dock: { open: true, height: 0.4 },
table: { columns: [
{ key: 'label', label: 'Service', type: 'text' },
{ key: 'kind', label: 'Kind', type: 'select' },
{ key: 'owner', label: 'Owner', type: 'select' },
] },
// Full mode's minimap isn't what this card is about.
minimap: false,
},
plugins: [summaryPane()],
}const data = {
nodes: [
{ id: 'api', data: { label: 'api-gateway', kind: 'service', owner: 'Platform' } },
{ id: 'auth', data: { label: 'auth-service', kind: 'service', owner: 'Identity' } },
{ id: 'billing', data: { label: 'billing-service', kind: 'service', owner: 'Payments' } },
{ id: 'search', data: { label: 'search-service', kind: 'service', owner: 'Discovery' } },
{ id: 'users-db', data: { label: 'users-db', kind: 'datastore', owner: 'Identity' } },
{ id: 'orders-db', data: { label: 'orders-db', kind: 'datastore', owner: 'Payments' } },
{ id: 'index', data: { label: 'search-index', kind: 'datastore', owner: 'Discovery' } },
{ id: 'cache', data: { label: 'edge-cache', kind: 'datastore', owner: 'Platform' } },
{ id: 'mailer', data: { label: 'mailer', kind: 'worker', owner: 'Payments' } },
{ id: 'reindexer', data: { label: 'reindexer', kind: 'worker', owner: 'Discovery' } },
],
edges: [
{ from: 'api', to: 'auth', data: { label: 'authenticates' } },
{ from: 'api', to: 'search', data: { label: 'queries' } },
{ from: 'api', to: 'billing', data: { label: 'charges' } },
{ from: 'api', to: 'cache', data: { label: 'reads' } },
{ from: 'auth', to: 'users-db', data: { label: 'reads' } },
{ from: 'billing', to: 'orders-db', data: { label: 'writes' } },
{ from: 'billing', to: 'mailer', data: { label: 'enqueues' } },
{ from: 'search', to: 'index', data: { label: 'reads' } },
{ from: 'reindexer', to: 'index', data: { label: 'writes' } },
{ from: 'reindexer', to: 'orders-db', data: { label: 'reads' } },
],
}