@yuneta/gobj-ui
v7.23.45
Published
Yuneta UI Library — the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed by wattyzer and the yunetas JS yunos (gui_agent, gui_treedb). The frozen legacy GClass GUI stack (C_YUI_MAIN/
Readme
gobj-ui — Yuneta UI Library
Reusable GUI components for Yuneta GClass front-ends: a declarative shell
(C_YUI_SHELL/NAV/PAGER/WIZARD), floating windows
(C_YUI_WINDOW/WINDOW_MANAGER), TreeDB editors, charts and maps. The
legacy GClass GUI stack (C_YUI_MAIN/TABS/ROUTING) was removed from
this line in 3.0.0 — the frozen v1 npm line still ships it.
Published as @yuneta/gobj-ui. Built on top of @yuneta/gobj-js.
Routing & navigation: every navigable state is a URL. Before adding any view or navigable element, read
ROUTING.md— the shell's routing contract (URL = source of truth, push/replace history, the position/preference/transient litmus).BREAKING (7.0.0): a dependency-only major — no component API moved. The
maplibre-glpeer floor rises to^6.4.1, which is whereDOM.sanitizestops leaving dangerous attributes behind when several sit next to each other. Raise the range in every consumer that declares maplibre.BREAKING (5.0.0): a dependency-only major — no component API moved. The peer floors moved to
maplibre-gl^6.0.0(see 7.0.0 above),@yuneta/gobj-js^7.8.7,i18next^26.3.6,tom-select^2.6.2,vanilla-jsoneditor^3.13.0. maplibre v6 is ESM-only with no default export, so a consumer imports it asimport * as maplibregl; bundling the map with Vite 8 also means emitting maplibre's worker + shared chunk yourself and pointingsetWorkerUrl()at them (Vite cannot statically follow v6's dynamic worker URL, and a.mjsworker is refused when the host serves it asapplication/octet-stream). The test-app'svite.config.js(maplibre_worker_assets) +src/main.jsare the reference wiring.BREAKING (4.0.0):
yui_shell_navigate(shell, route)now pushes a history entry by default; pass{replace:true}for a redirect / normalization / F5-restore — anything code decided rather than the user. It used to replace unless given{push:true}(still accepted, now redundant). A call left unmigrated only leaves a spurious Back entry; the default is the failure-tolerant direction, since a forgotten{push}silently broke Back. See ROUTING.md §7/§9.1.BREAKING (4.0.0): the legacy
__yui_main__theme/resize service is gone from v2. Components are self-contained: the theme lives in<html data-theme>and gclasses follow it throughsrc/yui_theme.js(yui_theme_now()/yui_is_dark()/yui_watch_theme(gobj), all barrel-exported — the watcher translates the DOM mutation and the OSprefers-color-schemeflip intoEV_THEME); reflow uses each component's ownResizeObserver. An app that registered a__yui_main__service for gobj-ui's benefit can delete it; do not re-add one.BREAKING (4.0.0): a window/modal
titleis now an i18n KEY, rendered withdata-i18nso it re-translates on language change. Pass the key, nevert(key), and never compose data into it — the DATA half (a topic/service/marker name) travels in the newtitle_prefixattr/opt, shown before the title and never translated (C_YUI_WINDOW,yui_shell_show_modal, the dock chip). The oldtitle_fn/retitle_modalhooks are removed.BREAKING (4.0.0): minimize requires a window manager.
C_YUI_WINDOWpaints its minimize button only when the window has amanager(C_YUI_WINDOW_MANAGER): minimize means "send to the dock", and without a manager there is nowhere to send it — soshowMinis now ignored when there is no manager, and a manager-less window shows only maximize/restore + close. The self-contained "shade" fallback (roll up to the title bar in place) and itsis-shadedCSS are removed; an app that relied on shading needs to register a manager.
Two maintained lines
This repository carries two parallel lines with different layouts and consumers. They are independent snapshots (no shared git ancestry):
| Line | Branch | Tag | Layout | Consumed by | How | Status |
|------|--------|-----|--------|-------------|-----|--------|
| v2 | main | 2.0.0+ | src/ subdir | gui_agent, gui_treedb | local file: dep on the yunetas submodule | active development |
| v2 | main | 2.0.0+ | src/ subdir | wattyzer | published npm @yuneta/gobj-ui@^5.0.0 (dist-tag latest) | active development |
| v1 | v1 | 1.0.1 | src/ subdir | estadodelaire, hidraulia | published npm @yuneta/gobj-ui@^1.0.1 (dist-tag legacy) | frozen, maintenance-only |
- v2 /
mainis the active development line: the declarative shell (legacy-stack-free since3.0.0). It is embedded as a git submodule in yunetas atkernel/js/gobj-ui, and the in-repo JS yunos (yunos/js/gui_agent,yunos/js/gui_treedb) consume that checkout as afile:dependency (@yuneta/gobj-ui→../../../kernel/js/gobj-ui), importing by package specifier (@yuneta/gobj-ui/src/*.js, exports map"./src/*"; theindex.jsbarrel and the vite plugin stay at the package root). wattyzer takes the same line from the registry (since 2026-07-25): the published tarball shipssrc/,index.jsand the vite plugin, so the import specifiers are identical — but library work only reaches it after anpm publishand a range bump on its side. Two consequences worth knowing: a fix cannot be validated in wattyzer before it is released, and wattyzer is the consumer that proves the tarball is complete, not just the checkout. - v1 /
v1is the frozen legacy-only stack (the declarative shell is not on this line). It is published to npm; estadodelaire and hidraulia depend on@yuneta/gobj-ui@^1.0.0from the registry. Land only maintenance fixes here, thennpm publisha new1.x.
All new feature work lands on main/v2.
Usage
# v2 (active): clone yunetas with submodules; the in-repo yunos pick it up via file:
git clone --recurse-submodules <yunetas>
git submodule update --init kernel/js/gobj-ui # yunetas tracks main/v2
# v2 from the registry (wattyzer, and any out-of-tree consumer)
npm install @yuneta/gobj-ui@^5.0.0
# v1 (frozen): consumers just install the published package
npm install @yuneta/gobj-ui@^1.0.0Edit v2 from the yunetas kernel/js/gobj-ui checkout, commit on main in this
repo, then bump that submodule pointer in yunetas. For v1, work from a v1
checkout and publish.
Build & test
npm install
npm run build # vite -> dist/ (ES/CJS/UMD/IIFE, min + non-min)
npm test # vitest (v2/main only; v1 has no test target)dist/ is gitignored. v1 consumers get dist/ from the published npm
tarball; v2 consumers import source files by specifier, whether they resolve
them from the checkout or from the tarball's src/. Rebuild dist/ to
validate and before publishing a release.
Components
Site map — yui_shell_show_route_map
Every declarative-shell app can render its WHOLE navigation surface —
toolbar + account menu + every declared menu + live dynamic tabs + each
view's contributed sub-routes + the routes declared only in the route table —
as a printable, filterable, clickable tree (a floating C_YUI_WINDOW, modal
fallback) that doubles as the app's basic documentation. The current route is
marked "you are here". Wire it from an account-menu entry
(type:"event" → EV_OPEN_SITEMAP, or a deep-linkable /sitemap action
route with redirect:"back") and call
yui_shell_show_route_map(shell, {t}) from the handler; a second call
toggles it closed. The tree model is yui_shell_nav_map() /
route_map_model.js (pure, unit-tested). Semantics and the contributor
protocols (yui_shell_set_sub_routes, yui_shell_register_event_handler)
live in ROUTING.md.
C_YUI_NODE — navigation as a tree of gobjs (prototype)
C_YUI_SHELL's menu tree is two levels (a primary item and its
submenu.items); a submenu item cannot declare a submenu of its own, so a
section with sub-sections has to flatten everything into one tab strip.
C_YUI_NODE is the prototype of the other model: the gobj tree IS the
navigation tree. A node is a gobj, the URL is the path of node ids under a
single declared base_route, and a parent holds how it wants its children
seen:
{
"gclass": "C_YUI_NODE",
"kw": {
"node_id": "cards", "base_route": "/cards",
"projection": {
"index": {"layout": "cards"},
"chrome": [{"layout": "tabs", "show_on": ">=tablet"},
{"layout": "backbar", "show_on": "<tablet"}]
},
"content": {"gclass": "C_MY_LANDING", "kw": {}},
"children": [
{"id": "energy", "label": "Energy", "icon": "yi-bolt",
"projection": {
"index": {"layout": "cards"},
"chrome": [{"layout": "tabs", "show_on": ">=tablet"},
{"layout": "backbar", "show_on": "<tablet"}]
},
"children": [ /* … any depth … */ ]}
]
}
}projectionis aC_YUI_NAVrender config (so cards/tabs/vertical/ icon-bar/backbar andshow_onall work unchanged) in two modes:indexwhen the node is the tip of the path — the projection IS the page — andchromewhen a child is showing — the projection is the strip around it.Chrome belongs to the node that declares it — so every branch declares its own. A node's
chromestrip lists that node's children, and its backbar goes back to that node's route (back_route = my_route). A branch that declares nochrometherefore contributes no strip, and the only ← the user can reach is the nearest ancestor that did declare one. Declaring the pair only at the root is the mistake this rule exists to name: the whole subtree then shows one "← root" that says the same thing at every depth, instead of one ← per level going up exactly one level. Repeat the samechromeon every branch that can have a tip below it — that is whattest-app's/cardsdoes, and what makes its ← hierarchical.contentandchildrenare not exclusive: a section with its own page and sub-pages is one node.The route table does not grow. The host declares ONE route; everything below arrives as the shell's
subpath(ROUTING.md §4), and the tree contributes its full shape to the site map viayui_shell_set_sub_routes.Two ways to show depth. Stacked chrome — one strip per ancestor — reads well at three levels and eats the screen at five. The other way is
projection.path: the trail down to the user as ONE line ({"layout": "breadcrumb"}), drawn from the tree root whichever node declares it, each crumb a link to that level. Declared per branch, so a deep corner can trade its strips for a breadcrumb (chrome_depth: 0+projection.path) while the rest of the tree keeps its tabs. Note the asymmetry that makes it a third mode and not a layout:indexandchromeproject a node's CHILDREN;pathprojects the way in.remember_position— an item points at where you LEFT that child. Without it a nav item points at the canonical route of its child, so a strip of children behaves like a row of tabs that forgets: open a topic inside one, move to a sibling, come back, and the tab is at its landing — browser Back the only way to what was open. With it, the item carries the tail that was last active under that child.It stays a real position, which is the reason it is done here and not by the viewer restoring itself: clicking is a navigation like any other, nothing redirects and nothing argues with the url. A bare navigation to a child records "its home", so choosing the landing sticks too.
Off by default: a tree whose children are pages wants the item to BE the destination. On for a tree whose children are workspaces with a position inside them — the agent console's strip of treedbs, each with its open topic. Since 6.2.0.
nav_mode— the three shapes as one runtime knob. The two bullets above describe what a tree declares;nav_modeis how a user chooses between the shapes without the app rewriting anything:| mode | what it shows | equivalent declaration | |---|---|---| |
"stack"(default) | one strip per ancestor | whatever each branch declares | |"back"| only the tip's parent, as a← parent|chrome: {"layout":"backbar"}everywhere +chrome_depth: 1| |"path"| the trail as ONE line |path: {"layout":"breadcrumb"}on the root +chrome_depth: 0|It belongs to the root (
yui_node_set_nav_mode(root, "path"), or"nav_mode"in its declaration) and the whole tree reads it from there — ancestors stacking strips while a descendant drew a breadcrumb would be saying the same thing twice in two languages. Set on a middle node it is refused, loudly, rather than accepted and ignored.A mode filters the renders as they are asked for; it never rewrites what the app declared. That is what makes
"stack"an exact restore: a branch that declaredverticalchrome comes back asvertical, not as the tabs a canonical "stacked" shape would have imposed. Theindexprojection is never touched by a mode — how a node shows its own children when it IS the page is not a statement about depth. Modes are per tree, so an app can run/adminas a breadcrumb and/alarmsas a backbar;test-app's node lab cycles all three on the live tree.chrome_depthcaps the stacked chrome: with every ancestor painting its own strip, depth N shows N-1 of them. A node declares how many its corner of the tree deserves (0= none, omit = all), the **deepest declaration on the path wins, and an intermediate node whose only job is to hold that number is a legitimate node.Declarative and dynamic are the same code. The declared
childrenattr is fed to the sameEV_ADD_NODEthe runtime API uses:yui_node_add(node, spec, index) yui_node_remove(node, node_id) yui_node_set_projection(node, proj) yui_node_set_content(node, content) yui_node_set_chrome_depth(node, n) yui_node_tree_version(node) yui_node_set_nav_mode(root, mode) yui_node_nav_mode(node) yui_node_find(node, "energy/north") yui_node_route(node)A node added at runtime is deep-linkable like one declared at boot. Removing the branch the user is standing on moves them to the nearest living ancestor (
replace, logged) — with a live tree the ground can disappear under a bookmark.
Every move goes through the URL: a projection click publishes
EV_NAV_CLICKED, the node turns it into a push navigation, and the shell's
EV_ROUTE_CHANGED walks back down the tree as EV_ACTIVATE. Back, Forward,
F5 and deep links are therefore correct by construction.
The root can be a node too — config.shell.tree. Declared there, the
shell stops owning the menu and keeps only the space (zones, layers,
stages, toolbar, overlays, theme, breakpoints): the root node's children are
the app's primary options, and it projects them into zones instead of into its
own body.
"shell": {
"zones": {"top": {"host": "toolbar"}, "left": {"show_on": ">=desktop"},
"bottom": {"show_on": "<desktop"}, "center": {"host": "stage.main"}},
"stages": {"main": {"zone": "center", "default_route": "/"}},
"tree": {
"base_route": "/", "stage": "main",
"projection": {
"index": [{"zone": "left", "layout": "vertical"},
{"zone": "bottom", "layout": "icon-bar"}],
"chrome": [{"zone": "left", "layout": "vertical"},
{"zone": "bottom", "layout": "icon-bar"}]
},
"children": [ /* the primary options, and everything under them */ ]
}
}Note what is NOT there: no zone declares host: "menu.<id>", and there is no
menu block at all. A render config with a zone mounts through
yui_shell_zone() and persists — the rail is standing chrome, so it is
built once and told where the user is, not rebuilt per navigation.
menu.primary.render always was a per-zone projection; this just gives it an
owner that can hold it.
shell.tree synthesizes exactly ONE route entry, flagged owns_subtree, which
is the only case where root / may match as an ancestor (route_resolver.js).
The unknown-route diagnostic is not lost by that: it moves to the node that
actually knows the names of its children. Runnable reference:
test-app/tree.html (_qa_root.mjs), served beside index.html so the two
navigation models can be compared in one browser.
Where the tree ends. One gobj per structural node is right; one gobj per
meter reading is not. A node marks the boundary with link — a pointer into a
data space (a timeranger: millions of raw records, series/time, key/value) plus
the viewer suited to that shape:
{"id": "m1", "label": "Meter 1",
"link": {"kind": "tranger", "gclass": "C_MY_TRANGER_VIEW",
"kw": {"topic": "meters^north^m1"}}}A link node is always the tip of the structure: the url keeps going, but
its tail is handed to the viewer as EV_ROUTE_CHANGED {base, subpath} — the
same contract the shell gives a view (ROUTING.md §5), so a viewer cannot tell
whether the shell mounted it at a declared route or a node did, deep in a tree.
base is the node's canonical route, which is what the viewer builds its own
deep links from. An empty subpath means the viewer's home, which is what makes
Back out of a deep data position land on it. Below a link there are no nodes:
link + children (or link + content) is a config error, because a silent
winner in "who owns the subpath" would be the worst outcome.
The tree is a contract, not runtime state. Once published, a node's path
is a url a client may have bookmarked, scripted, or been sold as another door
into the system. So there is deliberately no reparent/move API: the shape
is versioned (tree_version on the root, yui_node_tree_version()), and a
rename migrates through aliases — the former id keeps resolving and the URL
is rewritten (replace) to the canonical spelling, the same shape as an HTTP
301. Anything a version bump cannot cover is a new tree, declared as such.
Runnable reference: the Cards chapter of test-app (four levels plus a
panel that mutates the live tree), driven by test-app/_qa_nodetree.mjs and
test-app/_qa_extra.mjs.
C_YUI_SERVICE_VIEW — mounting a view that talks to a backend
A view asks the backend for data with gobj_command(remote, …, src = itself),
and C_IEVENT_CLI routes the answer back with
gobj_find_service(gobj_name(src)) — which only finds registered services.
Neither host creates one, so a backend-talking view mounted directly at a route
never receives a single answer: it sits empty while the ievent logs "service
not found" once per answer. And a route's target.kw is static JSON, so it
cannot carry the live transport pointer either.
Two shapes, because the callers are not alike:
/* A route with NO extras: declare the host, name the view it hosts. */
{ gclass: "C_YUI_SERVICE_VIEW", kw: {
view_gclass: "C_MY_VIEW",
service_name: "#my-view", // UNIQUE per mount — see below
view_kw: { title: "…" }
}}
/* A wrapper that keeps its own extras (url segments into the hosted view,
* rebinding it when a connection drops): drop only the boilerplate. */
let view = yui_mount_service_view(gobj, {
gclass: "C_YUI_TREEDB_TOPICS",
name: service_name(gobj),
kw: {...},
transport: remote // already resolved (e.g. per connection);
}); // omit it for "__remote_service__"The hosted gclass must declare the attr the transport is injected under
(gobj_remote_yuno by default), build its $container in mt_create, and flag
EVF_PUBLIC_EVENT on whatever arrives from the backend — the ievent drops
events that are not public.
The service name must be unique per mount, and a duplicate is dangerous precisely because it is not fatal: gobj-js logs "service ALREADY REGISTERED. Will be UPDATED" and rebinds the name, so two mounts of one route would cross their answers. Derive it from the route (or from whatever else makes the mount unique — a connection id, a workspace), never from the gclass alone.
Why the hosts do not just create services. It would make every routed view an inter-yuno endpoint by default, against the framework's rule that only named services are; most views never talk to a backend; and the collision above would become the default failure mode. Opt-in per route instead.
Known asymmetry, deliberately left alone:
C_YUI_SHELLmounts a view withgobj_create()(a plain child) andC_YUI_NODEwithgobj_create_pure_child(). The flag decides whether a gclass that consultsgobj_is_pure_child()sends its output event straight to the parent or publishes it. Today nothing consults it on a view (onlyc_ievent_cliandc_timerdo, and always about themselves), so the difference has no observed consequence — but the same view gclass does get a different flag depending on who mounted it. Align it the day it bites, with the case that bit.
C_YUI_JSON — lazy JSON tree viewer
Indentation follows the house rule: four characters per level, plus a guide
line per ancestor. The rows are siblings with growing padding rather than
nested boxes, so the guides are painted as a repeating gradient bounded to
each row's own indentation (background-size set per row) — which is why the
hover state must set background-color, never the background shorthand, or
the guides vanish under the cursor.
A container-agnostic viewer (like C_YUI_PAGER): it owns only a toolbar +
scrollable tree body and exposes a $container the parent mounts wherever it
wants (a C_YUI_WINDOW body, a yui_shell_show_modal card, or inline). It is
built to show arbitrarily large JSON, so it never assumes the whole
document fits in memory or the DOM.
Server-driven lazy expansion. The C kernel's kw_collapse() (kwid.c,
used by the print-tranger command) truncates over-limit dicts/arrays into a
sentinel — { "__collapsed__": { "path": …, "size": N } } (dict) or
[ { "__collapsed__": … } ] (array). C_YUI_JSON renders each sentinel as an
expandable stub and, when the user opens it, does not fetch anything itself:
it publishes EV_EXPAND_PATH {path, size} to its subscriber. The subscriber is
the only party that knows the backend (it re-issues print-tranger path=<path>
with limits, or any equivalent), and hands the subtree back via
EV_SUBTREE_LOADED {path, json}. Only expanded containers are materialised in
the DOM, so the tree stays bounded regardless of document size. With no
sentinels present it degrades to a plain client-side collapsible tree.
Three views, one document. The toolbar switch (and the view_mode attr,
"tree" | "text" | "graph") picks which. They answer three different
questions:
The switch reads text · tree · graph, flattest reading first — which is not
the order of arrival.
Which view it opens on is decided in three steps: what the HOST asked for
(the view_mode attr), then what the READER chose last time, then the tree.
That is why the attr's default is the empty string and not "tree": as a
default and as a host's explicit choice "tree" was the same string, so
nothing could tell "show me the tree" from "I have no opinion", and a
memory that cannot see the difference has to lose to both. Pass view_mode
only to PIN a view.
The reader's choice is kept in localStorage under one key for the whole
library — which of the three views somebody reads JSON in is a habit of the
person, not a property of the document — and only a view they picked is
remembered: a mode the host pinned, or the tree we fell back to, would
otherwise be written back as if somebody had chosen it.
| view | question | notes |
|---|---|---|
| text | what does this document say, verbatim | JSON.stringify(…, 4), four characters per level |
| tree | where is this value, and what is around it | the lazy view; the only one that can drill; the default |
| graph | what shape is this | a hosted C_YUI_JSON_GRAPH child (AntV/G6) |
What the graph draws is the document, not a rearrangement of it. Every key
is a ROW of its card, containers included — cols is one key of a topic dict
exactly like pkey is, and a drawing that leaves it out does not say what the
document says. What a container row does NOT get is its contents repeated
underneath it: the row says [14] and the fourteen cards say the rest, which
is the part that has to scale.
A container with no scalars of its own gets no card at all: it is not a thing, it is a LIST of things, described by the row that names it plus the edges to what it holds, and its children hang from its parent. The root always keeps a card.
Each container key opens a G6 port on the line of its own row, and its edge
leaves from there; the edge carries an arrowhead and arrives at an in port
centred above the target card's title. Ports on an html node need care: the
HTML is a DOM layer over the canvas, so a port fully inside the box is painted
under the card — they sit ON the border.
Cards are JSON_CARD and carry data-json-path (GOBJ_CARD /
data-gobj-name in the gobj tree, TREEDB_CARD in the treedb graph): a gclass
that builds DOM owes the Inspector a readable tree, and the anchor's mark needs
something to find its card by after a rebuild — the node id does not survive
one, the path does.
Since 7.23.2 the schema and cell-JSON popups of C_YUI_TREEDB_TOPIC_WITH_FORM
present that viewer as a floating C_YUI_WINDOW on a laptop — movable,
resizable, maximisable, position remembered — and keep the modal sheet on a
phone. A JSON document is read while looking at the table it came from, which
a modal cannot allow; a phone has no room to arrange anything, so the sheet
stays. Note the close contract: C_YUI_WINDOW.close_window() calls on_close
and THEN destroys itself, so a host must drop its reference rather than destroy
the window too.
Its camera comes from yui_graph_camera.js (7.23.4), the one place every
DOM-toolbar graph asks for one — this gclass and the gobj tree both, so a
cloned toolbar cannot drift apart again. The vocabulary is the treedb graph's
(c_g6_nodes_tree.js, which draws its toolbar as a G6 plugin over an SVG
sprite and so cannot share the code, only the decisions): zoom in / zoom out, a
live zoom readout, yi-fit for fit — the same corner brackets the sprite
draws for g6-icon-fit — and the WRITTEN 1:1 for actual size, which is
written in every editor that offers it and never drawn. Fold is the chevron
pair, from the lazy tree viewer's own toolbar. The readout hangs off G6's
aftertransform, the one hook that also covers the wheel.
Both graph toolbars are arranged the same way (7.23.7): the global
expand/collapse leftmost, ahead of the find box; camera and refresh in the
centre; the layout picker on the right. The per-node fold is a different
control and lives elsewhere — on the right of each card's own header, last,
where the gobj tree puts its +N / −.
The graph carries its own toolbar, a layout picker
(EV_CHANGE_LAYOUT {layout}: vertical tree, dagre top-down,
dagre left-right — the layout and the edge type move together), and since
7.22.0 the same two facilities the tree has: a find box (rate-limited, EV_FIND_NODES)
that highlights matching rows and outlines the cards they are in and says how
many matched, and expand-all / collapse-all (EV_EXPAND_ALL /
EV_COLLAPSE_ALL) that fold every card but the root, marking each cut with
▸ N so the shape stays legible and you can see where the rest went. Since
7.23.0 each card that HAS a branch also carries its own handle in its header
(EV_TOGGLE_FOLD {path}); a leaf gets a spacer of the same width instead,
because a handle that does nothing is worse than none and the labels should
stay on one axis.
That handle is the same filled chip the gobj tree draws (+N folded, −
open — see render_toggle_html in c_yui_gobj_tree_js.js), and it is a chip
for a reason: 7.23.0 shipped it as a bare ▾ and on a phone it was
invisible. At the zoom that fits a document on screen a glyph is a couple of
pixels of ink; a filled chip is still a visible blob and reads as something you
press. +N also carries the count of what is hidden.
The handle is delegated from the canvas mount in the capture phase, over
pointerdown/pointerup/mousedown/click: the card is an innerHTML
string so nothing can be bound to it directly, G6 binds on the node element
below, and G6 builds its click from the POINTER sequence — swallowing click
alone leaves the fold working and the card also reporting an item click. A find
does not move the camera — a viewport that jumps on every keystroke is
unusable — which is why the count is there.
Note where the highlight lives: baked into the card's own markup, never set
as a G6 node state. The key shape of an html node is a DOM element and G6
paints no state style on it, so setting active selects correctly and shows
nothing.
- Neither text nor graph is lazy. Both show what the client currently
holds,
__collapsed__sentinels included, because that is honestly what it has. Drill in the tree and they grow with it. - The tree-only controls (search, expand-loaded, collapse-all) hide with the tree; copy stays. A control that can answer nothing is worse than an absent one. The graph brings its own zoom/centre toolbar.
- The graph child is built on first entry into graph mode, never in
build_ui: G6 sizes itself from its container, so a graph created behindis-hiddencomes up 0×0.register_c_yui_json()auto-registersC_YUI_JSON_GRAPH(and that register is idempotent, so an app may also register it itself, in either order).
Two layout facts the browser taught this component, both worth keeping:
- In the text view long lines scroll sideways inside the viewer
(
white-space: preon amax-content-wide<pre>), never on the page body: in a raw dump the indentation is the structure, and a wrapped line restarts at column 0 and lies about the depth of everything under it. The<pre>must bemax-contentwide or the container reports no overflow and the tail of every long line is unreachable. - The graph body carries a definite
height(24rem, withflex: 1 1 autoso a constrained host still wins). Notmin-height: a percentage height does not resolve against a box sized by a minimum, and the tree and the text push their own height while a canvas pushes none — so in an unconstrained host the graph came up as a 2px hairline.
Contract:
- Attributes:
subscriber,title(i18n key, optional — hidden on mobile, where the toolbar cannot hold it as well as the buttons),json_data(initial JSON, optional),view_mode("tree"default |"text"|"graph"),$container(mounted by the parent). - Input events:
EV_SET_JSON {json}(replace the whole document;ST_EMPTY→ST_READY),EV_SUBTREE_LOADED {path, json}(splice a fetched subtree),EV_SUBTREE_ERROR {path, error},EV_SET_VIEW_MODE {mode}("tree"/"text"/"graph"; no mode advances to the next view, which is what the two-view toggle did when the list was two long), plusEV_REFRESH/EV_SHOW/EV_HIDE/EV_LANGUAGE_CHANGED. - Output event:
EV_EXPAND_PATH {path, size}(EVF_OUTPUT_EVENT) — the parent must declare it in its own FSM (CHILD subscription model). That is the ONLY one, on purpose: this viewer is a child of its host and subscribes it to everything it publishes, so every output event is a mandatory declaration in every host's FSM. The graph child'sEV_JSON_ITEM_CLICKEDstops here (7.21.0forwarded it and broke exactly that way); a host that wants node clicks mountsC_YUI_JSON_GRAPHitself. - Internal (DOM → FSM):
EV_TOGGLE_NODE,EV_EXPAND_COLLAPSED,EV_SEARCH,EV_EXPAND_ALL,EV_COLLAPSE_ALL,EV_COPY_ALL. Every kw carries only apathstring — never a DOM node or gobj. - i18n keys the switch needs:
tree view,text view,graph view,text truncated; collapse some branches. All spelled out insidet()inview_label()— nevert(VIEWS[i].key), which novalidate-localescan see (that shipped once, in7.20.0). - Paths use the kernel delimiter (backtick) and index arrays numerically, so a
path emitted by the viewer round-trips through
kw_find_pathon the backend.
Backend note. The Raw JSON feed is print-tranger, which serves the tranger
with both dict- and array-drill (via kw_collapse()): c_tranger.c for a
C_TRANGER service, and C_NODE (it holds priv->tranger) for a treedb.
A document that arrives with no __collapsed__ sentinels is simply rendered
client-side (no lazy drill).
Logical DOM classes: JSON_VIEWER, JSON_TOOLBAR, JSON_SEARCH, JSON_TREE,
JSON_ROW, JSON_KEY, JSON_VALUE, JSON_SUMMARY, JSON_COLLAPSED,
JSON_TIME. The gclass imports its own c_yui_json.css.
What a node in the graph is CALLED
C_G6_NODES_TREE (the record graph inside C_YUI_TREEDB_GRAPH) labels a card
by what NAMES the record, which is not always what KEYS it. A topic whose id
column is flagged rowid, uuid or qualified keys its records by something
that is not the plain name — a counter, a random string, or the name with
every ancestor in front of it — and the name lives in the secondary key the
topic declares (pkey2s). treedb_system_schema is the case that forced it:
its topics and cols records are named in value, so the graph drew cards
reading 181, 225, 193 while they were keyed by rowid, and would read the
whole path now that they are qualified.
The rule is in treedb_node_label.js (pure, unit-tested): read the pkey
column's flags from the desc; if the key is not the plain name, take the first
pkey2s field the record actually carries; otherwise keep the id. The pkey is never
lost — it is the card's tooltip, on the chip and on the entity card alike.
It needs the descriptor to carry pkey2s, which tranger2_topic_desc() only
clones from SDK > 7.13.0. Against an older node the desc has no pkey2s, the
label falls back to the id, and nothing else changes.
Which backend a view browses: source_url
C_YUI_TREEDB_TOPICS takes an optional source_url string and prints it in
its toolbar, between the left buttons and the raw json one. A host passes the
url of the connection the view reads through (wss://host:port), and an empty
value renders nothing.
The tab that hosts the view is labelled with the treedb name, and a treedb
name is not unique across backends: two tabs reading treedb_yuneta_agent are
two different machines, and a wrong assumption there is a write on the wrong
node. The url is what tells them apart, and it does not fit in a tab label —
a tab wide enough for wss://artgins.yunetacontrol.com:1996 is a tab bar with
room for one tab. So the view carries it, where there is a whole row for it.
The buttons of that toolbar never shrink. When the row runs out of room, the
url is what gives way, cut with an ellipsis, and the whole value stays in the
title and the aria-label.
Reading a topic a page at a time
C_YUI_TREEDB_TOPICS takes with_remote_paging (off by default) and
forwards it to every topic table: the table pulls the page it is showing
instead of the host pushing the whole topic down. It needs the SDK's nodes
with from / limit (see YUNO_TREEDB.md §5.3).
The page size is generous on purpose (page_size, 200). A treedb that
fits in one page behaves exactly as it did — paginator hidden, every filter
seeing every row — so nothing that exists today changes. Only a topic that
does NOT fit pays for paging, and for that one loading it whole was never an
option.
Safe against a backend that cannot page: it answers the whole list, which
nodes_answer() reads as one page. That is the truth, and it is why the table
can ask without knowing what it is talking to.
filterMode: "local" says the plain truth: the header filters and the search
box work on the page that is loaded. Same as the tranger browser's Rows card,
and for the same reason — the alternative is pushing every filter to the
backend and changing what "search" means.
Who does what: the transport belongs to the HOST, so the table asks with
EV_REQUEST_PAGE and the answer comes back as EV_PAGE_LOADED, correlated by
an id echoed in __md_command__. Read that id flat off the command stack
(kw_command.req_id): C_IEVENT_CLI EXTRACTS __md_command__ and pushes it
AS the stack's kw, so one level deeper is a level too far — and the symptom
is every request timing out with its answer sitting right there.
The promise Tabulator wants is parked in the table (ajaxRequestFunc must
RETURN a promise — it is a data source, not an event), with a watchdog,
because the link can stay up and an answer still never land. A refresh
re-pulls the page the reader is on rather than throwing them back to the
first.
What a delete takes with it
A treedb delete is not one thing, and these views delete with force.
force on a node does not only remove it: its children are UNLINKED —
they survive, loose — and it is cleaned off its parents. So "delete this row"
can mean "detach eleven records from their only parent", and the question that
used to be asked, are you sure, said none of it.
The confirmation names what is going (the record's key, or how many) and adds a line per thing at stake, each only when there is something at stake — a loose record must not be dressed up as a dangerous one:
- N children will be UNLINKED, not deleted
- It will be detached from M parents
Counted off the record the table already has (list_dict fills the hook and
fkey columns), so asking costs no round trip. The counting is
delete_impact.js, pure and tested, because the shapes are the fiddly part: a
hook or fkey value arrives as a list of refs, a dict keyed by id, or a single
ref string — and a column can be BOTH hook and fkey, which counts on both
sides, because the delete does both things.
In the graph the node-delete popover carries the same two lines, and the unlink popover carries the reassurance that is its whole point: neither record is deleted. Next to a delete button painted the same red, that is not obvious.
Three things this cost, worth knowing before composing any message from keys:
yui_shell_confirm_*renders its message as an i18n key, so a composed sentence can never be one. Pass DOM instead — the helper takes it.createElement2trims text nodes, so a["span", {}, " "]separator vanishes and the question reads "BorrarDeveloper". Space with CSS.- a counted word carries no
i18nattribute:yui_shell_show_modalcallsrefresh_language()on the dialog's content, which re-translates from the key alone — without the count — and puts the plural back over the singular. Nothing is lost, because a dialog with a backdrop never sees a language change.
Editing a topic table in place
A writable scalar is editable in the table, in edition mode
(with_inline_edit, default on). Changing one field used to mean opening the
record form, changing it, saving and closing.
Which cells, and why not the rest. The schema decides first: only a column
flagged writable, and never the pkey — renaming what a record is KEYED by is
not a field edit. Then the type: a hook holds children and an fkey IS a link,
so both are edited by linking; a dict or a list is a document the form has an
editor for; a date cell shows a formatted string over an epoch, so typing into
it would write the string. Those stay with the form, one click away on the
same row. boolean gets a tick, enum the list of its own values, numbers a
number editor.
The write is a partial update with no autolink, and that is the whole
safety of it. treedb_update_node() merges (json_object_update), so the
fields it does not carry are left alone; autolink is the option that wipes a
node's links and rebuilds them from the fkeys the record carries, and on a
partial record it reads that as "no parents", detaches the node and answers
success. So a cell edit travels as its own event, EV_UPDATE_FIELD, and
not as EV_UPDATE_RECORD — that one does send autolink, and may, because the
form hands it the whole record with its fkeys in it. See
schema_write_options.js for the rule and why each word of it is there.
editable is a function on the column, not a flag: edition mode is
toggled on a table that is already built, so the answer has to be asked for at
the moment of the click.
A refused write puts the topic back to what the treedb has. Leaving the typed value on screen is tolerable for a form, which stays open on the values it failed with; a cell edited in place would just look saved.
Reading a topic table: filters, columns, CSV
C_YUI_TREEDB_TOPIC_WITH_FORM had one global search box over the loaded rows.
Three tools join it, each behind its own flag, all on by default:
| attr | what it adds |
|---|---|
| with_header_filters | a filter box in the header of each column a match means something on |
| with_columns_button | a dialog that ticks which columns the table shows |
| with_export_button | downloads what the table holds as CSV |
Not every column gets a filter box, on purpose. A hook holds children, a
dict holds a subtree, and a date cell shows a formatted string over an epoch
number — a text match against the raw value there answers a question nobody
asked, so those columns get no box rather than a box that lies. A boolean
gets a tristate tick, an enum gets a list of its own values, and an fkey
gets a box whose match stringifies the value first, because which rows point
at X is the question fkey columns exist to answer and a fkey arrives as a ref
string, a list of them or a dict.
The search box and the header filters are separate layers: clearing the search does not silently drop the column filters. The CSV carries what the table HOLDS — the loaded rows, the visible columns, both filters applied, which is what the reader is looking at. It is not the topic: a server-side dump of every node is not something this view can stream, and the button's title says so.
Searching is a user action, so it crosses the FSM (EV_SEARCH) like the rest;
it used to call tabulator.setFilter straight from the DOM handler, where the
machine trace could not see it.
Read-only treedbs: readonly
C_YUI_TREEDB_TOPICS and C_YUI_TREEDB_GRAPH take a readonly attr; the
topics view propagates it to every topic it builds. It is not one more button
flag: it is the STATE of the treedb and it beats each with_* flag at once,
because a treedb whose tranger the yuno does not master answers every write
with
ERROR -1: <yuno>: treedb '<name>' is READ-ONLY, this yuno is not the master of its tranger(the yuno refuses since SDK 7.13.0), so offering the buttons anyway turns a
fact into an error message per click. Ask the yuno which it is with
command-yuno id=<yuno> service=<treedb> command=treedb-info, which answers
{treedb_name, master, schema_version, topics} — and remember the flag is per
TREEDB and is runtime state: a yuno is routinely the master of its
treedb_system_schema and a replica of a data treedb it shares.
What readonly takes away: the edition mode, the new / delete / paste
buttons, the in-row edit icons, and the write half of the record form's toolbar
(copy stays — reading a record includes taking it with you) with the cells not
editable. The record form still OPENS: looking is the point of a replica.
In the graph it takes away the edition operation mode, which is the only
one that draws the create / delete / link affordances — the mode select stops
offering it, and a graph left in edition on a master comes back in reading on
a replica (the mode is a persisted preference). The other modes are untouched:
panning, zooming and opening a node are reading.
Two implementation notes worth keeping:
- the decision lives in
treedb_write_plan.js(pure, tested), not in five!readonly && with_xexpressions — five places to forget the sixth; - and the write events are refused as well, in every gclass, with a
log_error. Hiding a button is not the same as refusing a write: an event can still arrive from a keyboard path or a form that outlived the flag, and an ignored write is exactly the behaviour this whole change exists to stop.
The graph's viewport toolbar
C_G6_NODES_TREE floats a vertical toolbar over the canvas:
| control | what it does |
|---|---|
| zoom in / zoom out | one step of scale |
| the zoom level | a readout, not a button — 85% |
| ── | |
| fit | fitView(): the whole graph in the viewport |
| 1:1 | zoomTo(1): actual size |
| anchor (crosshairs) | pick one element; every zoom then leaves it in the middle |
| ── | |
| fit to selection | fit, for the part that is selected (edition only, disabled while nothing is) |
| ── | |
| full screen | the container, not the camera |
Two of those rows are the answer to a real complaint, and the reasoning generalises:
1:1used to be a house, and the house was the thing people reached for when they wanted the graph back. It never gave it to them: the action iszoomTo(1), which sets the scale and leaves the camera where it was, so from a corner of a large graph it answered with the same corner at 100%. A house means the initial extent in a map and the starting view in an editor — never a scale — and this one sat directly underfit, so the pair read as two ways to do one thing. Actual size is written in every editor that offers it, because there is no glyph anybody recognises for it.- the zoom level is shown because
1:1is a jump to a number, and a jump to a number is only meaningful next to the number you are on. - fit to selection wears the
fiticon with a marked object inside it, drawn rather than borrowed: the two sit next to each other and are the same action at two scopes, so they have to read as a family. It appears only in edition — a button that can never be enabled is furniture, not a control — and is disabled while nothing is selected.fitView()has no subset form, so the bounds are measured off the elements and the zoom is clamped to the graph's ownzoomRange, the only limit that is not invented here. One card filling the view is not a bug: that is what zooming to it means.
The separators are gaps, not lines: every item already carries a hairline against its neighbour, so one more line would not group anything. Full screen is behind the second one because it is a window control that happens to live in a camera toolbar.
Both toolbars (this one and the edit one) follow the theme. They used to be pinned to a light background in both themes, with the icon colour pinned dark so it survived that — two light islands over a dark canvas.
New keys for consumers: actual size, zoom level, zoom to selection
(all tooltips, so a host that has not defined them shows the key on hover and
nothing else breaks).
The anchor: one element the camera holds
The same crosshairs button is in all three graphs' toolbars, drawn once in
yui_graph_camera.js. Why it exists: a graph that FITS on screen is unreadable
at the zoom that makes it fit — one topic's schema fits at 37%, where every
card is grey texture — and the zoom that makes it readable does not fit. So the
useful view is always a fraction of the document, and WHICH fraction was
nobody's decision: 1:1 translated to the layout's origin, a corner with
nothing in it, and the reader then hunted for the node they had been looking at.
Three states, because two could not say what a press does: off, arming
(the attention colour, and the pointer over the canvas turns to a crosshair)
and on (pressed). The anchored card carries a dashed amber outline —
dashed because in the treedb graph a solid amber ring already means a find
match and blue means the selection, so a third solid ring would be a third
thing to learn in one channel.
Two things a host does not have to think about but a maintainer does:
- While it is arming,
drag-elementis swapped OUT withsetBehaviors(). With it in, a click that drifts two pixels — which is every click a hand makes — becomes a drag, G6 fires nonode:click, and the pick silently does not happen. - The camera move is POSTED to the gclass, not made in the handler: a translate
issued inside G6's click dispatch or its
aftertransformis swallowed. And the translate itself is a closed form,T = (canvasCentre - nodeWorld) * zoom, read off G6's owngetTranslateOptions()—focusElement()andtranslateBy()compute the right offset, resolve, and do not move the camera at all.
New keys for consumers: anchor view, click the element to centre on,
centred: click to release.
Moving the cards, and folding without losing your place
The JSON graph and the gobj tree take drag-element: a card can be dragged.
The position is deliberately NOT kept — both are rebuilt from their source on
every refresh, fold and layout change, and neither is a document of its own to
save it to — and it is worth having anyway, because pulling two cards apart to
read the lines between them is most of what a reader wants from a graph.
Folding no longer moves anything. It used to re-pack the tree: five surviving cards would all slide 240px sideways for a fold that removed nothing they could see, and the card under the finger slid out from under it. Two halves:
- the card you clicked is read before the rebuild and put back afterwards
(
yui_graph_place_at(), the anchor's arithmetic with the target point left free); - and the others stay because a fold leaves a phantom child — an invisible node of exactly the width its children had, in the place they had in the order. Reserving the width as a NUMBER does not work: the layout centres a parent over its children, so a lump appended at the end moves every sibling by half of it.
collapse all reserves nothing on purpose: it is asking for a COMPACT drawing.
The gobj tree remembers how you left it — layout, zoom, camera, folds and
anchor — in localStorage, keyed by the gobj's name. Not in a persistent attr
because only a SERVICE can save those and this gclass is hosted as a child, and
because how somebody left THEIR tree is a fact about that browser. The layout
and the folds are restored BEFORE the first build, since they decide what is
built; the camera after, and once only, or every fold would drag the reader
back.
The graphs on a touch screen
The three G6 graphs (C_G6_NODES_TREE under the treedb view,
C_YUI_JSON_GRAPH, C_YUI_GOBJ_TREE_JS) have always DRAWN correctly on a
phone. What they could not be was operated on one, for reasons that were
structural rather than cosmetic — and none of them is visible in the CSS:
- There is no wheel on a phone, and
zoom-canvasbinds the wheel and nothing else, so the only zoom was the toolbar's+/−. G6 ships a pinch recogniser, buttrigger: ['pinch']REPLACES the wheel (itsbindEventsis anif/else), and itsPinchHandlerkeeps its instance and callbacks in statics — with two graphs on a page the second registers against the first one's emitter, so pinching one zooms both and pinching the other does nothing.g6_touch_gestures.jsrecognises the gesture per graph and registers azoom-canvasthat keeps the wheel, the same patterng6_drag_canvas_touch.jsalready uses fordrag-canvas. Every graph gets it with no change to itsbehaviorslist. - G6 does not read the DOM's
contextmenuevent. ItsBehaviorControllersynthesises the event frompointerdownwithbutton === 2, so the context menu was a right click and only a right click, whatever the browser does with a long press. The same module gives it a long press (500ms, 10px of slop) that re-emits G6's own forwarded event under the name the plugin listens for — sogetItems(e)sees exactly what a right click gives it, including the port under the finger. - One finger has to serve three commands, so the press is arbitrated — at
the RELEASE (
press_arbiter.js, pure and tested): moved → drag; still and let go quickly → the element's own action; still and held past 500ms → the context menu — with a 15ms haptic tick at the 500ms mark, which is a NOTICE and not the decision (a finger that buzzes and then carries the node away still gets its drag). Firing the menu on a TIMER instead cannot arbitrate anything, because at the moment it fires the gesture is not over: it opened whiledrag-elementwas already carrying the node, so one press meant both things and the menu sat over a card running away underneath it. Two more things the same press was doing, both fixed with it:clickis not taken from the DOM either (@antv/g'sonPointerUpsynthesises one), so the press that opened the menu went on to click the node it opened the menu on — the click handlers askconsume_long_press_click()first; and the BROWSER's own menu, which opens while the finger is still down, is refused for as long as there is a finger on the glass. - The browser was taking the gesture outright. G6 puts
touch-action: noneon its canvas and nothing on its HTML nodes, which are ordinary DIVs over it — so a drag that started on a card was a page scroll: twopointermoves through, thenpointercancel, and the node stopped dead about 20px in while the page slid..graph-containerrefuses those gestures whole now; the panels and the context menu keeptouch-action: auto, so a finger still scrolls what is meant to scroll.
Everything a finger has to LAND on is sized off (pointer: coarse), which
means a mouse sees no change at all: node resize handles become a 14px
mark in a 44px box and drop to the four corners (eight fingertip-sized boxes
around a 90px node overlap into one blob, and a corner resizes both axes);
node properties and delete node go from two 28px circles 4px apart —
one fingertip covered both, with the destructive one underneath — to 44px
and 12px apart; a port's hit area stops being a flat +4 in world units
(a different target at every zoom, and 5 screen px at the 50% a phone lands
on after fit) and becomes a screen measurement converted to world; popover
controls and context-menu rows get a 44px floor at 16px, under which iOS
Safari zooms the page on focus and never zooms back.
And the two floating toolbars fold. They are drawn inside the canvas, one
on each edge: on a 356px-wide phone canvas they took a third of the drawing
area and stood on top of the nodes. Under 480px of container — measured on
the container, not the window, because the same graph is a full page in one
app and a card in a column in another — they collapse behind a single ⋮,
and the edit strip is removed rather than emptied (an empty toolbar is still
a card with a border sitting on the graph). Above that width nothing changes.
New keys for consumers: show toolbar, hide toolbar.
Multi-selection reaches a finger through a MODE, not through a gesture
(7.23.11). Both halves of it — shift+click to add a card, shift+drag for the
rubber band — hang off a key a phone does not have, and there is no spare
gesture to give them: G6 binds panning and the band to the same plain drag, so
one of the two has to stand aside. The edit toolbar carries a selection
mode toggle (the dashed marquee, next to +): while it is on, a tap picks a
card and a drag on the background draws the band, and panning is what stands
aside. It looks pressed while it is on (7.23.12, pressed_state, an
inverted neutral chip) rather than taking one of the palette's colours: each of
those names a KIND of action, and 7.23.11 borrowed undo/redo's violet for it —
which put the same colour on two neighbouring buttons for two different
reasons, and carried the whole state change in the hairline of an outline
glyph. It is deliberately a button and not a heuristic — the toolbar says which
of the two the graph is listening for, and turning it off gives the camera
back. It is not device-specific: the same button spares a desktop reader the
key. Leaving edition turns it off, and it is not persisted.
New key for consumers: selection mode.
The two selects of the toolbar speak the app's language (7.23.13). They
rendered their raw names — reading, edition, dagre, manual — in every
language, because neither went through t() at all: not a missing key, a
missing call. The label is translated and carries its own data-i18n, so it
follows a language change; the option's value is now set EXPLICITLY, because
an <option> with no value answers with its own text and a translated label
would have sent "Edición" to the FSM as the mode to enter.
New keys for consumers: reading, operation, writing, edition,
manual, dagre, antv-dagre, d3-force, force-atlas2. A host that adds
a layout of its own gets its name untranslated, which is what all of them got
until now.
Tabs opened at runtime, and the two decisions their url costs
yui_tab_routes.js. A workspace whose tabs are opened by the operator —
/<ws>/<home>/<id>, with whatever the tab is showing below it — pays for that
url twice, and both apps in this family learned the same two lessons, one of
them the hard way.
yui_tab_split_subpath(subpath) → {id, tail}. On a cold load the tab's
route does not exist yet: it is registered when the tab is opened, so a reload
on /<ws>/<home>/<id>/<tail> resolves only as far as the workspace home and the
shell hands the WHOLE rest over as the subpath — <id>/<tail>, not <id>.
Reading all of it as the id matches nothing, and an app that then falls back to
its first tab answers a reload with somebody else's default. It hides well:
a bare tab route survives, because there the subpath IS the id, so only the deep
case breaks and only for whoever reloads on one.
Only the id segment is decoded. These ids are composite (<node>+0x1F+
<yuno>, <conn>+0x1F+<treedb>) and reach the url percent-encoded, so
decoding the whole tail first would turn an encoded slash inside an id into a
separator and cut it in two.
yui_tab_position_plan(prev_base, base, subpath, remembered) →
{record, replay}. A tab's nav item is a FIXED route —
yui_shell_set_submenu() registers it, and that route is where the view is
mounted — so the position inside a tab cannot travel in the item and has to be
replayed when the tab is entered again. "Entered again" is the whole subtlety:
arriving at the root of the tab you were ALREADY in is the way OUT of whatever
was open, and replaying the position there would make that button do nothing.
What is NOT here: the wiring. One host restores on its transport's
EV_ON_OPEN, another normalizes the route as it arrives, and both are right for
what they know about when their tabs become real. These are the decisions, not
the plumbing — which is also why they are pure and tested rather than three
lines inside an action.
Selecting several nodes, and moving them together
In edition mode the graph has a real selection, not just "the node you clicked":
| gesture | what it does | |---|---| | click a node | selects it and opens it: resize handles, ports, popovers | | shift + click | adds that node to the selection, or takes it out | | shift + drag on the canvas | rubber band: the selection becomes what it enclosed | | the selection mode button | makes those two the PLAIN gestures — a tap picks, a canvas drag is the band — for as long as it is on (panning stands aside meanwhile) | | ctrl/cmd + A | every node | | the fit-to-selection button | puts the viewport on what is selected | | drag any selected node | moves the whole selection, as one undo | | Delete / Backspace | deletes the selection, after a confirmation that counts what it takes | | Esc, or a click on the canvas | clears it |
The keys reach the graph only while the graph has focus — G6 gives its
canvas a tabIndex of its own — which is what keeps ctrl+A inside the find
box a selection of the TEXT and not of every node: the focus is in the input,
and the input is not inside the canvas. They arrive as EV_KEY_DOWN and the
action decides, so a key is as visible in the machine trace as a click.
Three decisions are worth knowing, because each one is where this could have gone wrong:
G6's
selectedelement state IS the selection.drag-elementdecides what a drag moves by asking the graph for it (getElementDataByState('node', 'selected')), so a set kept anywhere else would be a second truth the drag never consults — the ring would say five and one would move. It also batches the move, so a group drag is one history entry rather than one per node.The ring is painted into the card's own html, and it had to be. A state style paints on a node's KEY SHAPE, and every node here is an
htmlnode whose key shape is a DOM element — the same reason the amber highlight had never appeared before7.3.0. Selecting withbrush-selectand nothing else would have selected correctly and shown nothing. The ring is blue and drawn OUTSIDE the amber halo, so a node that is both a find match and selected wears both; one function composes them (ring_shadow), because before it each repaint wrote its own flag and erased the other's.The gesture is G6's, the result is an event.
brush-selectgets anonSelectthat sendsEV_BRUSH_SELECTwith the ids, and the action does the work — so a marquee shows up in themachinetrace like every other action. Shift+click is not G6'sclick-selectat all: this gclass already ownsEV_NODE_CLICK, and adding a second selection owner outside the FSM is how the two end up disagreeing. The band comes fromg6_brush_select_owned.js, abrush-selectunder our own id with ONE half removed: the built-in also rewrites theselectedstate of every node and every edge on each canvas click, behind the gclass and outside the history pause with which it sets and clears the selection — a recorded command whose before and after are identical, which is a lit Save on a graph nobody has touched (7.23.10).
Panning gives way while Shift is held AND while the pointer is on a card
(drag-canvas takes an enable predicate) — the first because the canvas
would otherwise pan under the rubber band, the second because it would
otherwise pan under the node being dragged. That second half is G6's own
default, and an enable REPLACES the default rather than adding to it: while
it was missing, a drag moved the card at twice the pointer and slid the whole
graph underneath it (7.23.10).
Undo, Redo and Save are the history plugin, and it follows the MODE. It is
installed on entering edition and removed on leaving it — including when
edition is reached through the mode selector on a graph whose data is already
loaded, which is the ordinary way in. Before 7.23.10 it was installed at the
arrival of the last topic of the load and only if the graph was in edition
right then, so that ordinary way left the graph with dead Undo/Redo buttons
and a history_pause() nothing answered, while Save still lit on its own.
A delete says what it takes, whether it is one or twenty. The Delete key
asks the same question the per-node delete icon asks, built by the same
function: the record's key when there is one, the count when there are more,
and then the two lines that are actually at stake — N children will be
UNLINKED, not deleted and it will be detached from M parents — where over a
set the numbers are the sums. These views delete with force, so an operator
pressing Delete over twelve cards has to read eleven detached children, not
"are you sure". It needs no new keys: the sentence is the one 7.10.0 already
defined. The question has no icon to hang off, so it is asked in the middle of
the graph it is about.
A marquee selects, it does not open. Even when it encloses exactly one
node, the handles and ports stay away: _selected_node_id means the node
opened for editing, and only a click sets it. Everything that hangs off a
single node reads that field, so a multiple selection puts all of it away by
construction rather than by a check in twenty places.
Finding a node in the graph
C_YUI_TREEDB_GRAPH carries a find box in the middle of its toolbar. It
matches the term against the node's **l
