@fusion.dev/helix-headless 0.115.0 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/custom-elements.json +2347 -0
- package/dist/element-events.generated.js +7 -0
- package/dist/graph/index.d.ts +3 -0
- package/dist/graph/index.js +2 -0
- package/dist/graph/root/graph-model.d.ts +63 -0
- package/dist/graph/root/graph-model.js +75 -0
- package/dist/graph/root/graph-root.d.ts +189 -0
- package/dist/graph/root/graph-root.js +321 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/map/root/map-root.d.ts +78 -0
- package/dist/map/root/map-root.js +137 -1
- package/package.json +5 -5
- package/types/jsx.d.ts +1000 -0
- package/types/svelte.d.ts +1 -0
- package/types/vue.d.ts +265 -2
|
@@ -87,6 +87,13 @@ registerElementEvents({
|
|
|
87
87
|
'toggle',
|
|
88
88
|
],
|
|
89
89
|
'helix-headless-filter-menu': ['helix-facet'],
|
|
90
|
+
'helix-headless-graph': [
|
|
91
|
+
'helix-activate',
|
|
92
|
+
'helix-before-activate',
|
|
93
|
+
'helix-graph-pan',
|
|
94
|
+
'helix-graph-ready',
|
|
95
|
+
'helix-graph-zoom',
|
|
96
|
+
],
|
|
90
97
|
'helix-headless-heatmap': ['helix-resize'],
|
|
91
98
|
'helix-headless-image': ['error', 'load'],
|
|
92
99
|
'helix-headless-input': ['change', 'helix-before-change', 'input'],
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** One note in a graph. */
|
|
2
|
+
export interface GraphNode {
|
|
3
|
+
/** Unique in the graph, and what `active` and a link's ends name. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** What the note is called. Its `id` where it has none. */
|
|
6
|
+
label?: string | undefined;
|
|
7
|
+
/** A kind the note belongs to, such as a tag, which a renderer colors it by. */
|
|
8
|
+
group?: string | undefined;
|
|
9
|
+
/** How large the renderer draws it, relative to the others. */
|
|
10
|
+
size?: number | undefined;
|
|
11
|
+
/** Where the note stands, where the page already knows. Laid out where it does not. */
|
|
12
|
+
x?: number | undefined;
|
|
13
|
+
/** Where the note stands, where the page already knows. Laid out where it does not. */
|
|
14
|
+
y?: number | undefined;
|
|
15
|
+
}
|
|
16
|
+
/** One link between two notes, from `source` to `target`. */
|
|
17
|
+
export interface GraphLink {
|
|
18
|
+
source: string;
|
|
19
|
+
target: string;
|
|
20
|
+
/** What the link says, such as "cites". */
|
|
21
|
+
label?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
/** Which notes each note links to and is linked from. */
|
|
24
|
+
export type Adjacency = Map<string, {
|
|
25
|
+
from: Set<string>;
|
|
26
|
+
to: Set<string>;
|
|
27
|
+
}>;
|
|
28
|
+
/** How a note in the list stands to the selected one. */
|
|
29
|
+
export type Relation = 'both' | 'from' | 'selected' | 'to';
|
|
30
|
+
/** One row of the list: a note, and how it stands to the selected one where it does. */
|
|
31
|
+
export interface Entry {
|
|
32
|
+
node: GraphNode;
|
|
33
|
+
relation?: Relation | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** A note's name, its `id` where it has no label. */
|
|
36
|
+
export declare const nameOf: (node: GraphNode) => string;
|
|
37
|
+
/**
|
|
38
|
+
* Which notes each note links to and is linked from, in one pass over the
|
|
39
|
+
* links. A link naming a note that is not in the graph is kept: the list
|
|
40
|
+
* shows notes and not links, so it does no harm, and a page adding the note a
|
|
41
|
+
* moment later finds its links already counted.
|
|
42
|
+
*/
|
|
43
|
+
export declare const adjacencyOf: (links: readonly GraphLink[]) => Adjacency;
|
|
44
|
+
/**
|
|
45
|
+
* The notes within `depth` links of `from`, whichever way each link runs, the
|
|
46
|
+
* note itself included: the local graph.
|
|
47
|
+
*
|
|
48
|
+
* **Both ways, as a reader thinks of a note's neighborhood**: a note that
|
|
49
|
+
* cites this one is as near to it as one this one cites.
|
|
50
|
+
*/
|
|
51
|
+
export declare const within: (adjacency: Adjacency, from: string, depth: number) => Set<string>;
|
|
52
|
+
/**
|
|
53
|
+
* The list's rows, in the order a reader wants them: the selected note, then
|
|
54
|
+
* the notes it links to and from, then every other note, each part in the
|
|
55
|
+
* order of its names, and only the notes `shown` holds and `query` matches.
|
|
56
|
+
*
|
|
57
|
+
* **The neighborhood first**, because it is what a reader came to the graph
|
|
58
|
+
* for and what a picture of it shows by its lines, which a list has to say in
|
|
59
|
+
* its order instead.
|
|
60
|
+
*/
|
|
61
|
+
export declare const entriesOf: (sorted: readonly GraphNode[], adjacency: Adjacency, active: string, query: string, shown?: ReadonlySet<string>) => Entry[];
|
|
62
|
+
/** The notes in the order of their names, as the page's language orders words. */
|
|
63
|
+
export declare const sortedOf: (nodes: readonly GraphNode[], lang?: string) => GraphNode[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const nameOf = (node) => node.label || node.id;
|
|
2
|
+
export const adjacencyOf = (links) => {
|
|
3
|
+
const adjacency = new Map();
|
|
4
|
+
const at = (id) => {
|
|
5
|
+
let found = adjacency.get(id);
|
|
6
|
+
if (!found) {
|
|
7
|
+
found = { from: new Set(), to: new Set() };
|
|
8
|
+
adjacency.set(id, found);
|
|
9
|
+
}
|
|
10
|
+
return found;
|
|
11
|
+
};
|
|
12
|
+
for (const { source, target } of links) {
|
|
13
|
+
if (source === target) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
at(source).to.add(target);
|
|
17
|
+
at(target).from.add(source);
|
|
18
|
+
}
|
|
19
|
+
return adjacency;
|
|
20
|
+
};
|
|
21
|
+
export const within = (adjacency, from, depth) => {
|
|
22
|
+
const reached = new Set([from]);
|
|
23
|
+
let edge = [from];
|
|
24
|
+
for (let step = 0; step < depth && edge.length > 0; step++) {
|
|
25
|
+
const next = [];
|
|
26
|
+
for (const id of edge) {
|
|
27
|
+
const near = adjacency.get(id);
|
|
28
|
+
for (const one of [...(near?.to ?? []), ...(near?.from ?? [])]) {
|
|
29
|
+
if (!reached.has(one)) {
|
|
30
|
+
reached.add(one);
|
|
31
|
+
next.push(one);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
edge = next;
|
|
36
|
+
}
|
|
37
|
+
return reached;
|
|
38
|
+
};
|
|
39
|
+
export const entriesOf = (sorted, adjacency, active, query, shown) => {
|
|
40
|
+
const wanted = query.trim().toLocaleLowerCase();
|
|
41
|
+
const kept = (node) => (!shown || shown.has(node.id)) &&
|
|
42
|
+
(wanted === '' || nameOf(node).toLocaleLowerCase().includes(wanted));
|
|
43
|
+
const near = adjacency.get(active);
|
|
44
|
+
const relationOf = (id) => {
|
|
45
|
+
if (id === active) {
|
|
46
|
+
return 'selected';
|
|
47
|
+
}
|
|
48
|
+
const to = near?.to.has(id) === true;
|
|
49
|
+
const from = near?.from.has(id) === true;
|
|
50
|
+
return to && from ? 'both' : to ? 'to' : from ? 'from' : undefined;
|
|
51
|
+
};
|
|
52
|
+
const first = [];
|
|
53
|
+
const neighbors = [];
|
|
54
|
+
const rest = [];
|
|
55
|
+
for (const node of sorted) {
|
|
56
|
+
if (!kept(node)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const relation = relationOf(node.id);
|
|
60
|
+
if (relation === 'selected') {
|
|
61
|
+
first.push({ node, relation });
|
|
62
|
+
}
|
|
63
|
+
else if (relation) {
|
|
64
|
+
neighbors.push({ node, relation });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
rest.push({ node });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...first, ...neighbors, ...rest];
|
|
71
|
+
};
|
|
72
|
+
export const sortedOf = (nodes, lang) => {
|
|
73
|
+
const collator = new Intl.Collator(lang || undefined, { numeric: true, sensitivity: 'base' });
|
|
74
|
+
return [...nodes].sort((one, other) => collator.compare(nameOf(one), nameOf(other)));
|
|
75
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { HelixElement, type ViewportPanDetail, type ViewportZoomDetail } from '@fusion.dev/helix-core';
|
|
2
|
+
import { type PropertyValues, type TemplateResult } from 'lit';
|
|
3
|
+
import { type GraphLink, type GraphNode } from './graph-model.js';
|
|
4
|
+
declare const HelixHeadlessGraph_base: (new (...args: any[]) => import("@fusion.dev/helix-core").ViewportElement) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerActiveInterface) & typeof HelixElement;
|
|
5
|
+
/**
|
|
6
|
+
* @summary A frame around a graph of notes drawn by another library, and the list that makes it readable.
|
|
7
|
+
*
|
|
8
|
+
* @description
|
|
9
|
+
* Helix draws no graph. A force layout of thousands of notes is physics and
|
|
10
|
+
* WebGL, which a renderer such as Sigma.js does. Put the renderer's box in the
|
|
11
|
+
* frame, draw `nodes` and `links` in it, and answer the frame's pan and zoom
|
|
12
|
+
* events by moving the drawing.
|
|
13
|
+
*
|
|
14
|
+
* What the frame adds is what a drawing of a graph cannot do: a list of every
|
|
15
|
+
* note beside it, which is the graph for a screen reader and a quick way to
|
|
16
|
+
* find a note for anyone. The selected note comes first, then the notes it
|
|
17
|
+
* links to and from, then every other note, and the search field narrows it.
|
|
18
|
+
* Selecting a note in the list or in the drawing is the same selection.
|
|
19
|
+
*
|
|
20
|
+
* Set `depth` to show the local graph: only the notes within that many links
|
|
21
|
+
* of the selected one.
|
|
22
|
+
*
|
|
23
|
+
* `nodes` and `links` are properties, never markup: a graph of ten thousand
|
|
24
|
+
* notes as ten thousand elements would cost what the renderer exists to save.
|
|
25
|
+
* The list draws only the rows in view.
|
|
26
|
+
*
|
|
27
|
+
* The list scrolls on this tier. It carries `data-scroller`, and `core` ships
|
|
28
|
+
* `[data-scroller] { overflow: auto }` into every shadow root. You still need
|
|
29
|
+
* to set a block size on the list, or it grows to hold every row.
|
|
30
|
+
*
|
|
31
|
+
* @since 0.116.0
|
|
32
|
+
* @status experimental
|
|
33
|
+
* @category media
|
|
34
|
+
* @aliases Knowledge graph, Network, Note graph, Second brain
|
|
35
|
+
* @documentation https://fusion.dev/helix/components/graph
|
|
36
|
+
*
|
|
37
|
+
* @slot - The renderer's box, which the drawing is made in.
|
|
38
|
+
* @slot filter-icon - The icon in the search field. Defaults to a magnifying glass.
|
|
39
|
+
* @slot footer - Content below the graph.
|
|
40
|
+
* @slot header - Content above the graph.
|
|
41
|
+
*
|
|
42
|
+
* @event {HelixActivateEvent} helix-activate - A note was selected, in the list or in the drawing. The detail's `value` is its `id`.
|
|
43
|
+
* @event {HelixBeforeActivateEvent} helix-before-activate - A note is about to be selected. Cancelable: cancel it to keep the selection in your own state.
|
|
44
|
+
* @event {HelixGraphPanEvent} helix-graph-pan - A pan button or arrow key was pressed. Move the drawing.
|
|
45
|
+
* @event {HelixGraphReadyEvent} helix-graph-ready - The graph has been drawn whole, laid out and settled. Sent each time `drawn()` says it is drawn after it was not.
|
|
46
|
+
* @event {HelixGraphZoomEvent} helix-graph-zoom - A zoom button or key was pressed. Zoom the drawing.
|
|
47
|
+
*
|
|
48
|
+
* @csspart after - The room the notes after those drawn would take, so the list scrolls its whole length.
|
|
49
|
+
* @csspart before - The room the notes before those drawn would take.
|
|
50
|
+
* @csspart body - The drawing beside the list, or above it where the frame is narrow.
|
|
51
|
+
* @csspart controls - The group the pan and zoom buttons sit in.
|
|
52
|
+
* @csspart count - How many notes the list holds, which a search narrows, at the end of the search bar.
|
|
53
|
+
* @csspart filter-icon - The icon beside the search field.
|
|
54
|
+
* @csspart filter-mark - The default icon beside the search field, shown when none is slotted.
|
|
55
|
+
* @csspart footer - The strip below the graph.
|
|
56
|
+
* @csspart fullscreen - The button that makes the graph fill the screen.
|
|
57
|
+
* @csspart header - The strip above the graph.
|
|
58
|
+
* @csspart list - The list of notes, which draws only the rows in view.
|
|
59
|
+
* @csspart live - What screen readers hear when a search changes how many notes there are.
|
|
60
|
+
* @csspart note - One note in the list.
|
|
61
|
+
* @csspart note-cursor - The note the keyboard is on in the list.
|
|
62
|
+
* @csspart note-linked - A note linked to or from the selected one.
|
|
63
|
+
* @csspart note-name - A note's name in the list.
|
|
64
|
+
* @csspart note-relation - How a note stands to the selected one, such as "Linked from Alpha".
|
|
65
|
+
* @csspart note-selected - The selected note, first in the list.
|
|
66
|
+
* @csspart pan-down - The button that moves the graph down.
|
|
67
|
+
* @csspart pan-end - The button that moves the graph toward the end of a line of text: right in English.
|
|
68
|
+
* @csspart pan-start - The button that moves the graph toward the start of a line of text: left in English.
|
|
69
|
+
* @csspart pan-up - The button that moves the graph up.
|
|
70
|
+
* @csspart panel - The search field and the list, beside the drawing or below it.
|
|
71
|
+
* @csspart region - The frame: the drawing, its controls and the list.
|
|
72
|
+
* @csspart search - The bar holding the icon, the search field and the count.
|
|
73
|
+
* @csspart search-field - The field that finds a note by its name.
|
|
74
|
+
* @csspart stage - The drawing and its controls.
|
|
75
|
+
* @csspart viewport - The box the renderer draws in.
|
|
76
|
+
* @csspart zoom-in - The zoom in button.
|
|
77
|
+
* @csspart zoom-out - The zoom out button.
|
|
78
|
+
*
|
|
79
|
+
* @cssstate fullscreen - The graph fills the screen.
|
|
80
|
+
* @cssstate idle - The user has been still long enough for the header and footer to fade.
|
|
81
|
+
* @cssstate ready - The graph has been drawn whole, after `helix-graph-ready`.
|
|
82
|
+
*/
|
|
83
|
+
export declare class HelixHeadlessGraph extends HelixHeadlessGraph_base {
|
|
84
|
+
/** What the list says of a note's count, with `{count}`. */
|
|
85
|
+
countLabel: string;
|
|
86
|
+
/**
|
|
87
|
+
* How many links from the selected note to show, for its local graph. Unset
|
|
88
|
+
* shows every note. The list holds what the drawing shows.
|
|
89
|
+
*/
|
|
90
|
+
depth?: number;
|
|
91
|
+
/**
|
|
92
|
+
* The graph's name, read by screen readers, such as "Research notes".
|
|
93
|
+
* Required.
|
|
94
|
+
*/
|
|
95
|
+
label: string;
|
|
96
|
+
/** How a note linked both to and from the selected one is described, with `{note}`. */
|
|
97
|
+
linkedBothLabel: string;
|
|
98
|
+
/** How a note the selected one links to is described, with `{note}`, the selected note's name. */
|
|
99
|
+
linkedFromLabel: string;
|
|
100
|
+
/** The links between notes, each from `source` to `target`. A property, never an attribute. */
|
|
101
|
+
links: readonly GraphLink[];
|
|
102
|
+
/** How a note that links to the selected one is described, with `{note}`. */
|
|
103
|
+
linksToLabel: string;
|
|
104
|
+
/** The notes. A property, never an attribute. */
|
|
105
|
+
nodes: readonly GraphNode[];
|
|
106
|
+
/** The list's name, read by screen readers. */
|
|
107
|
+
notesLabel: string;
|
|
108
|
+
/** The search field's name. */
|
|
109
|
+
searchLabel: string;
|
|
110
|
+
/** How the selected note is described in the list. */
|
|
111
|
+
selectedLabel: string;
|
|
112
|
+
/** What the search field holds. */
|
|
113
|
+
private query;
|
|
114
|
+
/** The row the keyboard is on, as an index into the list. */
|
|
115
|
+
private cursor;
|
|
116
|
+
/** Which notes link to and from each, read again when `links` changes. */
|
|
117
|
+
private adjacency;
|
|
118
|
+
/** The notes in the order of their names, read again when `nodes` changes. */
|
|
119
|
+
private sorted;
|
|
120
|
+
/** The list's rows, read again when anything they come from changes. */
|
|
121
|
+
private entries;
|
|
122
|
+
/** The notes the local graph keeps, or every note where there is no `depth`. */
|
|
123
|
+
private kept;
|
|
124
|
+
/** Whether the graph has been drawn whole, which `drawn()` says. */
|
|
125
|
+
private whole;
|
|
126
|
+
/** How many notes there were when the count was last said. */
|
|
127
|
+
private said;
|
|
128
|
+
private readonly virtualize;
|
|
129
|
+
/**
|
|
130
|
+
* @internal The notes the drawing shows, for the code that draws it: those
|
|
131
|
+
* within `depth` of the selected note, or `undefined` for every note.
|
|
132
|
+
*/
|
|
133
|
+
get shown(): ReadonlySet<string> | undefined;
|
|
134
|
+
/** Whether the graph has been drawn whole. For code that runs after `helix-graph-ready`. */
|
|
135
|
+
get ready(): boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Says the graph has been drawn whole, or with `false`, that it no longer
|
|
138
|
+
* is. Call it when the renderer's layout has settled and the graph is
|
|
139
|
+
* finished. The first call after
|
|
140
|
+
* the graph was not drawn sends `helix-graph-ready` and turns on
|
|
141
|
+
* `:state(ready)`.
|
|
142
|
+
*/
|
|
143
|
+
drawn(whole?: boolean): void;
|
|
144
|
+
protected willUpdate(changed: PropertyValues): void;
|
|
145
|
+
protected updated(changed: PropertyValues): void;
|
|
146
|
+
protected panEvent(detail: ViewportPanDetail): Event;
|
|
147
|
+
protected zoomEvent(detail: ViewportZoomDetail): Event;
|
|
148
|
+
protected get restingControls(): string;
|
|
149
|
+
protected get restingControlsLabel(): string;
|
|
150
|
+
/** How many notes the list holds, in the page's numbers. */
|
|
151
|
+
private countText;
|
|
152
|
+
/** How a row stands to the selected note, in words, or nothing where it does not. */
|
|
153
|
+
private relationText;
|
|
154
|
+
/** Selects the note a row names, through the request a page can refuse. */
|
|
155
|
+
private choose;
|
|
156
|
+
/**
|
|
157
|
+
* Moves the keyboard to a row, and the list to show it.
|
|
158
|
+
*
|
|
159
|
+
* **Scrolled by the row's own box where it is drawn, and by the estimate
|
|
160
|
+
* where it is not**, so a jump to the end of ten thousand notes lands there
|
|
161
|
+
* and the row is drawn by the update the scroll asks for.
|
|
162
|
+
*/
|
|
163
|
+
private moveTo;
|
|
164
|
+
/**
|
|
165
|
+
* The keys in the list. **Kept from the frame**, whose arrows pan the
|
|
166
|
+
* drawing: a reader moving through the list was moving the graph under it.
|
|
167
|
+
*/
|
|
168
|
+
private readonly handleListKeydown;
|
|
169
|
+
/**
|
|
170
|
+
* The search field's keys, which belong to the field and never pan the
|
|
171
|
+
* graph. Escape empties a search that has words in it, and passes through
|
|
172
|
+
* an empty one to whatever the page does with it.
|
|
173
|
+
*/
|
|
174
|
+
private readonly handleSearchKeydown;
|
|
175
|
+
private readonly handleSearch;
|
|
176
|
+
/**
|
|
177
|
+
* The mark in the search field, behind a seam: a slot for the page, and a
|
|
178
|
+
* method for the tier above, as the listbox's filter has.
|
|
179
|
+
*/
|
|
180
|
+
protected renderFilterIcon(): TemplateResult;
|
|
181
|
+
private renderRows;
|
|
182
|
+
protected render(): TemplateResult;
|
|
183
|
+
}
|
|
184
|
+
declare global {
|
|
185
|
+
interface HTMLElementTagNameMap {
|
|
186
|
+
'helix-headless-graph': HelixHeadlessGraph;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export {};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { customElement, fillTemplate, formatNumbers, HelixElement, HelixGraphPanEvent, HelixGraphReadyEvent, HelixGraphZoomEvent, MixinControllerActive, MixinViewport, VirtualizeController, } from '@fusion.dev/helix-core';
|
|
8
|
+
import { magnifyingGlass } from '@fusion.dev/helix-icons';
|
|
9
|
+
import { html, nothing } from 'lit';
|
|
10
|
+
import { property, state } from 'lit/decorators.js';
|
|
11
|
+
import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
|
|
12
|
+
import { adjacencyOf, entriesOf, nameOf, sortedOf, within, } from './graph-model.js';
|
|
13
|
+
const PAGE = 10;
|
|
14
|
+
let HelixHeadlessGraph = class HelixHeadlessGraph extends MixinViewport(MixinControllerActive(HelixElement)) {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(...arguments);
|
|
17
|
+
this.countLabel = '{count} notes';
|
|
18
|
+
this.label = '';
|
|
19
|
+
this.linkedBothLabel = 'Linked both ways with {note}';
|
|
20
|
+
this.linkedFromLabel = 'Linked from {note}';
|
|
21
|
+
this.links = [];
|
|
22
|
+
this.linksToLabel = 'Links to {note}';
|
|
23
|
+
this.nodes = [];
|
|
24
|
+
this.notesLabel = 'Notes';
|
|
25
|
+
this.searchLabel = 'Find a note';
|
|
26
|
+
this.selectedLabel = 'Selected';
|
|
27
|
+
this.query = '';
|
|
28
|
+
this.cursor = 0;
|
|
29
|
+
this.adjacency = new Map();
|
|
30
|
+
this.sorted = [];
|
|
31
|
+
this.entries = [];
|
|
32
|
+
this.whole = false;
|
|
33
|
+
this.said = '';
|
|
34
|
+
this.virtualize = new VirtualizeController(this, {
|
|
35
|
+
container: () => this.partNamed('list'),
|
|
36
|
+
count: () => this.entries.length,
|
|
37
|
+
estimate: () => 32,
|
|
38
|
+
rows: () => [...(this.renderRoot.querySelectorAll?.('[data-index]') ?? [])].map((element) => ({
|
|
39
|
+
element: element,
|
|
40
|
+
index: Number(element.dataset.index),
|
|
41
|
+
})),
|
|
42
|
+
});
|
|
43
|
+
this.handleListKeydown = (event) => {
|
|
44
|
+
const moves = {
|
|
45
|
+
ArrowDown: this.cursor + 1,
|
|
46
|
+
ArrowUp: this.cursor - 1,
|
|
47
|
+
End: this.entries.length - 1,
|
|
48
|
+
Home: 0,
|
|
49
|
+
PageDown: this.cursor + PAGE,
|
|
50
|
+
PageUp: this.cursor - PAGE,
|
|
51
|
+
};
|
|
52
|
+
if (event.key in moves) {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
event.stopPropagation();
|
|
55
|
+
this.moveTo(moves[event.key]);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
event.stopPropagation();
|
|
61
|
+
this.choose(this.cursor);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
this.handleSearchKeydown = (event) => {
|
|
65
|
+
if (event.key === 'ArrowDown') {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
this.partNamed('list')?.focus();
|
|
68
|
+
}
|
|
69
|
+
if (event.key === 'Escape') {
|
|
70
|
+
if (this.query === '') {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
this.query = '';
|
|
75
|
+
}
|
|
76
|
+
event.stopPropagation();
|
|
77
|
+
};
|
|
78
|
+
this.handleSearch = (event) => {
|
|
79
|
+
this.query = event.target.value;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
get shown() {
|
|
83
|
+
return this.kept;
|
|
84
|
+
}
|
|
85
|
+
get ready() {
|
|
86
|
+
return this.whole;
|
|
87
|
+
}
|
|
88
|
+
drawn(whole = true) {
|
|
89
|
+
if (whole === this.whole) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.whole = whole;
|
|
93
|
+
this.toggleState('ready', whole);
|
|
94
|
+
if (whole) {
|
|
95
|
+
this.dispatchEvent(new HelixGraphReadyEvent());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
willUpdate(changed) {
|
|
99
|
+
super.willUpdate(changed);
|
|
100
|
+
this.toggleState('fullscreen', this.filled);
|
|
101
|
+
this.toggleState('idle', this.resting);
|
|
102
|
+
if (changed.has('links')) {
|
|
103
|
+
this.adjacency = adjacencyOf(this.links);
|
|
104
|
+
}
|
|
105
|
+
if (changed.has('nodes')) {
|
|
106
|
+
this.sorted = sortedOf(this.nodes, this.closest?.('[lang]')?.getAttribute('lang') ?? '');
|
|
107
|
+
}
|
|
108
|
+
if (changed.has('active') || changed.has('depth') || changed.has('links')) {
|
|
109
|
+
this.kept =
|
|
110
|
+
typeof this.depth === 'number' && this.active !== ''
|
|
111
|
+
? within(this.adjacency, this.active, Math.max(0, this.depth))
|
|
112
|
+
: undefined;
|
|
113
|
+
}
|
|
114
|
+
if (['nodes', 'links', 'active', 'depth', 'query'].some((name) => changed.has(name))) {
|
|
115
|
+
this.entries = entriesOf(this.sorted, this.adjacency, this.active, this.query, this.kept);
|
|
116
|
+
this.cursor = 0;
|
|
117
|
+
const list = this.partNamed('list');
|
|
118
|
+
if (list) {
|
|
119
|
+
list.scrollTop = 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
updated(changed) {
|
|
124
|
+
super.updated(changed);
|
|
125
|
+
this.virtualize.measure();
|
|
126
|
+
if (changed.has('query') && changed.get('query') !== undefined) {
|
|
127
|
+
this.said = this.countText();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
panEvent(detail) {
|
|
131
|
+
return new HelixGraphPanEvent(detail);
|
|
132
|
+
}
|
|
133
|
+
zoomEvent(detail) {
|
|
134
|
+
return new HelixGraphZoomEvent(detail);
|
|
135
|
+
}
|
|
136
|
+
get restingControls() {
|
|
137
|
+
return 'zoom fullscreen';
|
|
138
|
+
}
|
|
139
|
+
get restingControlsLabel() {
|
|
140
|
+
return 'Graph controls';
|
|
141
|
+
}
|
|
142
|
+
countText() {
|
|
143
|
+
const lang = this.closest?.('[lang]')?.getAttribute('lang') ?? '';
|
|
144
|
+
return fillTemplate(this.countLabel, { count: formatNumbers(lang)(this.entries.length) });
|
|
145
|
+
}
|
|
146
|
+
relationText(entry) {
|
|
147
|
+
const selected = this.nodes.find((node) => node.id === this.active);
|
|
148
|
+
const note = selected ? nameOf(selected) : '';
|
|
149
|
+
switch (entry.relation) {
|
|
150
|
+
case 'selected':
|
|
151
|
+
return this.selectedLabel;
|
|
152
|
+
case 'to':
|
|
153
|
+
return fillTemplate(this.linkedFromLabel, { note });
|
|
154
|
+
case 'from':
|
|
155
|
+
return fillTemplate(this.linksToLabel, { note });
|
|
156
|
+
case 'both':
|
|
157
|
+
return fillTemplate(this.linkedBothLabel, { note });
|
|
158
|
+
default:
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
choose(index) {
|
|
163
|
+
const entry = this.entries[index];
|
|
164
|
+
if (entry) {
|
|
165
|
+
this.requestActive(entry.node.id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
moveTo(index) {
|
|
169
|
+
const last = this.entries.length - 1;
|
|
170
|
+
this.cursor = Math.min(Math.max(index, 0), Math.max(last, 0));
|
|
171
|
+
const list = this.partNamed('list');
|
|
172
|
+
if (!list) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const row = this.renderRoot.querySelector?.(`[data-index="${this.cursor}"]`);
|
|
176
|
+
if (row) {
|
|
177
|
+
row.scrollIntoView?.({ block: 'nearest' });
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
list.scrollTop = this.cursor * 32;
|
|
181
|
+
}
|
|
182
|
+
this.virtualize.measure();
|
|
183
|
+
}
|
|
184
|
+
renderFilterIcon() {
|
|
185
|
+
return html `<slot name="filter-icon">
|
|
186
|
+
<svg part="filter-mark" fill="currentColor" viewBox=${magnifyingGlass.viewBox}>
|
|
187
|
+
${unsafeSVG(magnifyingGlass.content)}
|
|
188
|
+
</svg>
|
|
189
|
+
</slot>`;
|
|
190
|
+
}
|
|
191
|
+
renderRows() {
|
|
192
|
+
const { first, last } = this.virtualize.window;
|
|
193
|
+
const total = this.entries.length;
|
|
194
|
+
return this.entries.slice(first, last).map((entry, offset) => {
|
|
195
|
+
const index = first + offset;
|
|
196
|
+
const relation = this.relationText(entry);
|
|
197
|
+
const chosen = entry.relation === 'selected';
|
|
198
|
+
return html `<div
|
|
199
|
+
part="note${chosen ? ' note-selected' : entry.relation ? ' note-linked' : ''}${index === this.cursor ? ' note-cursor' : ''}"
|
|
200
|
+
role="option"
|
|
201
|
+
id=${`note-${index}`}
|
|
202
|
+
data-index=${index}
|
|
203
|
+
aria-selected=${chosen ? 'true' : 'false'}
|
|
204
|
+
aria-posinset=${index + 1}
|
|
205
|
+
aria-setsize=${total}
|
|
206
|
+
aria-description=${relation || nothing}
|
|
207
|
+
@click=${() => {
|
|
208
|
+
this.cursor = index;
|
|
209
|
+
this.choose(index);
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<span part="note-name">${nameOf(entry.node)}</span>
|
|
213
|
+
${relation ? html `<span part="note-relation" aria-hidden="true">${relation}</span>` : nothing}
|
|
214
|
+
</div>`;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
render() {
|
|
218
|
+
const { after, before } = this.virtualize.window;
|
|
219
|
+
const cursor = this.entries.length > 0 ? `note-${this.cursor}` : nothing;
|
|
220
|
+
return html `<div
|
|
221
|
+
part="region"
|
|
222
|
+
role="group"
|
|
223
|
+
aria-label=${this.label || this.getAttribute?.('aria-label') || 'Graph'}
|
|
224
|
+
tabindex="0"
|
|
225
|
+
@focusin=${this.idle.stir}
|
|
226
|
+
@keydown=${this.handleKeydown}
|
|
227
|
+
@pointerleave=${this.idle.leave}
|
|
228
|
+
@pointermove=${this.idle.stir}
|
|
229
|
+
>
|
|
230
|
+
<div part="header">
|
|
231
|
+
<slot name="header"></slot>
|
|
232
|
+
</div>
|
|
233
|
+
<div part="body">
|
|
234
|
+
<div part="stage">
|
|
235
|
+
<div part="viewport">
|
|
236
|
+
<slot></slot>
|
|
237
|
+
</div>
|
|
238
|
+
${this.renderControls()}
|
|
239
|
+
</div>
|
|
240
|
+
<div part="panel">
|
|
241
|
+
<div part="search">
|
|
242
|
+
<span part="filter-icon" aria-hidden="true">${this.renderFilterIcon()}</span>
|
|
243
|
+
<input
|
|
244
|
+
part="search-field"
|
|
245
|
+
type="text"
|
|
246
|
+
autocomplete="off"
|
|
247
|
+
spellcheck="false"
|
|
248
|
+
aria-label=${this.searchLabel}
|
|
249
|
+
placeholder=${this.searchLabel}
|
|
250
|
+
.value=${this.query}
|
|
251
|
+
@input=${this.handleSearch}
|
|
252
|
+
@keydown=${this.handleSearchKeydown}
|
|
253
|
+
/>
|
|
254
|
+
<span part="count" aria-hidden="true">${this.countText()}</span>
|
|
255
|
+
</div>
|
|
256
|
+
<div
|
|
257
|
+
part="list"
|
|
258
|
+
role="listbox"
|
|
259
|
+
tabindex="0"
|
|
260
|
+
data-scroller
|
|
261
|
+
aria-label=${this.notesLabel}
|
|
262
|
+
aria-activedescendant=${cursor}
|
|
263
|
+
@keydown=${this.handleListKeydown}
|
|
264
|
+
@scroll=${() => this.virtualize.measure()}
|
|
265
|
+
>
|
|
266
|
+
<div part="before" style="block-size: ${before}px"></div>
|
|
267
|
+
${this.renderRows()}
|
|
268
|
+
<div part="after" style="block-size: ${after}px"></div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
<div part="footer">
|
|
273
|
+
<slot name="footer"></slot>
|
|
274
|
+
</div>
|
|
275
|
+
<span part="live" role="status">${this.said}</span>
|
|
276
|
+
</div>`;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
__decorate([
|
|
280
|
+
property({ attribute: 'count-label' })
|
|
281
|
+
], HelixHeadlessGraph.prototype, "countLabel", void 0);
|
|
282
|
+
__decorate([
|
|
283
|
+
property({ type: Number })
|
|
284
|
+
], HelixHeadlessGraph.prototype, "depth", void 0);
|
|
285
|
+
__decorate([
|
|
286
|
+
property()
|
|
287
|
+
], HelixHeadlessGraph.prototype, "label", void 0);
|
|
288
|
+
__decorate([
|
|
289
|
+
property({ attribute: 'linked-both-label' })
|
|
290
|
+
], HelixHeadlessGraph.prototype, "linkedBothLabel", void 0);
|
|
291
|
+
__decorate([
|
|
292
|
+
property({ attribute: 'linked-from-label' })
|
|
293
|
+
], HelixHeadlessGraph.prototype, "linkedFromLabel", void 0);
|
|
294
|
+
__decorate([
|
|
295
|
+
property({ attribute: false })
|
|
296
|
+
], HelixHeadlessGraph.prototype, "links", void 0);
|
|
297
|
+
__decorate([
|
|
298
|
+
property({ attribute: 'links-to-label' })
|
|
299
|
+
], HelixHeadlessGraph.prototype, "linksToLabel", void 0);
|
|
300
|
+
__decorate([
|
|
301
|
+
property({ attribute: false })
|
|
302
|
+
], HelixHeadlessGraph.prototype, "nodes", void 0);
|
|
303
|
+
__decorate([
|
|
304
|
+
property({ attribute: 'notes-label' })
|
|
305
|
+
], HelixHeadlessGraph.prototype, "notesLabel", void 0);
|
|
306
|
+
__decorate([
|
|
307
|
+
property({ attribute: 'search-label' })
|
|
308
|
+
], HelixHeadlessGraph.prototype, "searchLabel", void 0);
|
|
309
|
+
__decorate([
|
|
310
|
+
property({ attribute: 'selected-label' })
|
|
311
|
+
], HelixHeadlessGraph.prototype, "selectedLabel", void 0);
|
|
312
|
+
__decorate([
|
|
313
|
+
state()
|
|
314
|
+
], HelixHeadlessGraph.prototype, "query", void 0);
|
|
315
|
+
__decorate([
|
|
316
|
+
state()
|
|
317
|
+
], HelixHeadlessGraph.prototype, "cursor", void 0);
|
|
318
|
+
HelixHeadlessGraph = __decorate([
|
|
319
|
+
customElement('helix-headless-graph')
|
|
320
|
+
], HelixHeadlessGraph);
|
|
321
|
+
export { HelixHeadlessGraph };
|
package/dist/index.d.ts
CHANGED
|
@@ -47,6 +47,7 @@ export * from './empty/index.js';
|
|
|
47
47
|
export * from './file-input/index.js';
|
|
48
48
|
export * from './filter-input/index.js';
|
|
49
49
|
export * from './format-date/index.js';
|
|
50
|
+
export * from './graph/index.js';
|
|
50
51
|
export * from './heatmap/index.js';
|
|
51
52
|
export * from './icon/index.js';
|
|
52
53
|
export * from './icon-button/index.js';
|