@aceshooting/lyra-ui 1.0.1 → 1.1.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/README.md +8 -9
- package/custom-elements.json +12479 -6535
- package/dist/components/chart/chart.d.ts +46 -1
- package/dist/components/chart/chart.js +148 -10
- package/dist/components/chart/chart.stories.d.ts +32 -0
- package/dist/components/chart/chart.stories.js +141 -0
- package/dist/components/chart/chart.styles.js +16 -0
- package/dist/components/chart/histogram.d.ts +2 -0
- package/dist/components/chart/histogram.js +18 -2
- package/dist/components/chart/line-chart.stories.d.ts +17 -0
- package/dist/components/chart/line-chart.stories.js +75 -0
- package/dist/components/chart/lite-chart.d.ts +74 -0
- package/dist/components/chart/lite-chart.js +384 -0
- package/dist/components/chart/lite-chart.stories.d.ts +12 -0
- package/dist/components/chart/lite-chart.stories.js +98 -0
- package/dist/components/chart/lite-chart.styles.d.ts +1 -0
- package/dist/components/chart/lite-chart.styles.js +83 -0
- package/dist/components/combobox/combobox.d.ts +9 -0
- package/dist/components/combobox/combobox.js +75 -12
- package/dist/components/combobox/combobox.stories.d.ts +18 -0
- package/dist/components/combobox/combobox.stories.js +75 -0
- package/dist/components/date-picker/calendar-core.js +13 -2
- package/dist/components/date-picker/date-input.d.ts +9 -1
- package/dist/components/date-picker/date-input.js +55 -7
- package/dist/components/date-picker/date-input.stories.d.ts +5 -0
- package/dist/components/date-picker/date-input.stories.js +36 -0
- package/dist/components/date-picker/date-picker.d.ts +11 -3
- package/dist/components/date-picker/date-picker.js +87 -37
- package/dist/components/date-picker/date-picker.stories.d.ts +4 -0
- package/dist/components/date-picker/date-picker.stories.js +12 -0
- package/dist/components/date-picker/date-picker.styles.js +1 -1
- package/dist/components/empty/empty.d.ts +12 -1
- package/dist/components/empty/empty.js +20 -3
- package/dist/components/empty/empty.stories.d.ts +2 -0
- package/dist/components/empty/empty.stories.js +20 -0
- package/dist/components/empty/empty.styles.js +11 -0
- package/dist/components/export-button/csv.js +6 -2
- package/dist/components/export-button/export-button.d.ts +15 -1
- package/dist/components/export-button/export-button.js +89 -12
- package/dist/components/export-button/export-button.stories.d.ts +1 -0
- package/dist/components/export-button/export-button.stories.js +5 -0
- package/dist/components/export-button/export-button.styles.js +11 -3
- package/dist/components/file-input/accept.d.ts +8 -2
- package/dist/components/file-input/accept.js +9 -3
- package/dist/components/file-input/file-input.d.ts +5 -2
- package/dist/components/file-input/file-input.js +23 -8
- package/dist/components/file-input/file-input.stories.d.ts +2 -0
- package/dist/components/file-input/file-input.stories.js +9 -0
- package/dist/components/flag/flag.d.ts +48 -3
- package/dist/components/flag/flag.js +65 -29
- package/dist/components/flag/flag.styles.js +1 -1
- package/dist/components/flag/language-map.d.ts +11 -0
- package/dist/components/flag/language-map.js +12 -1
- package/dist/components/gauge/gauge.d.ts +2 -0
- package/dist/components/gauge/gauge.js +19 -5
- package/dist/components/gauge/gauge.stories.d.ts +2 -0
- package/dist/components/gauge/gauge.stories.js +6 -0
- package/dist/components/graph/graph-loader.d.ts +28 -0
- package/dist/components/graph/graph-loader.js +44 -0
- package/dist/components/graph/graph.d.ts +41 -15
- package/dist/components/graph/graph.js +146 -71
- package/dist/components/graph/graph.stories.d.ts +3 -0
- package/dist/components/graph/graph.stories.js +38 -0
- package/dist/components/graph/graph.styles.js +6 -1
- package/dist/components/heatmap/calendar-grid.d.ts +7 -0
- package/dist/components/heatmap/calendar-grid.js +27 -10
- package/dist/components/heatmap/heatmap.d.ts +137 -12
- package/dist/components/heatmap/heatmap.js +427 -27
- package/dist/components/heatmap/heatmap.stories.d.ts +17 -0
- package/dist/components/heatmap/heatmap.stories.js +130 -0
- package/dist/components/heatmap/heatmap.styles.js +60 -2
- package/dist/components/map/map.d.ts +17 -1
- package/dist/components/map/map.js +68 -21
- package/dist/components/map/map.stories.d.ts +10 -0
- package/dist/components/map/map.stories.js +107 -12
- package/dist/components/map/map.styles.js +2 -2
- package/dist/components/playback/playback.d.ts +6 -2
- package/dist/components/playback/playback.js +36 -12
- package/dist/components/playback/playback.stories.d.ts +2 -0
- package/dist/components/playback/playback.stories.js +6 -0
- package/dist/components/playback/playback.styles.js +10 -2
- package/dist/components/select/select.d.ts +122 -0
- package/dist/components/select/select.js +516 -0
- package/dist/components/select/select.stories.d.ts +19 -0
- package/dist/components/select/select.stories.js +75 -0
- package/dist/components/select/select.styles.d.ts +1 -0
- package/dist/components/select/select.styles.js +188 -0
- package/dist/components/skeleton/skeleton.js +4 -7
- package/dist/components/sparkline/sparkline.d.ts +1 -1
- package/dist/components/sparkline/sparkline.js +40 -13
- package/dist/components/sparkline/sparkline.stories.d.ts +4 -0
- package/dist/components/sparkline/sparkline.stories.js +22 -0
- package/dist/components/split/split.d.ts +24 -3
- package/dist/components/split/split.js +155 -31
- package/dist/components/split/split.stories.d.ts +2 -0
- package/dist/components/split/split.stories.js +20 -0
- package/dist/components/stat/stat.d.ts +27 -1
- package/dist/components/stat/stat.js +56 -3
- package/dist/components/stat/stat.stories.d.ts +3 -0
- package/dist/components/stat/stat.stories.js +49 -1
- package/dist/components/stat/stat.styles.js +44 -0
- package/dist/components/table/table.d.ts +64 -2
- package/dist/components/table/table.js +213 -17
- package/dist/components/table/table.stories.d.ts +6 -0
- package/dist/components/table/table.stories.js +45 -0
- package/dist/components/table/table.styles.js +51 -4
- package/dist/components/time-range/time-range.d.ts +34 -5
- package/dist/components/time-range/time-range.js +217 -36
- package/dist/components/time-range/time-range.stories.d.ts +2 -0
- package/dist/components/time-range/time-range.stories.js +13 -0
- package/dist/components/time-range/time-range.styles.js +44 -2
- package/dist/components/toast/toast-item.d.ts +14 -2
- package/dist/components/toast/toast-item.js +134 -17
- package/dist/components/toast/toast-item.styles.js +28 -2
- package/dist/components/toast/toast.stories.d.ts +3 -0
- package/dist/components/toast/toast.stories.js +41 -1
- package/dist/components/toast/toast.styles.js +11 -11
- package/dist/components/toast/toaster.d.ts +1 -1
- package/dist/components/toast/toaster.js +13 -6
- package/dist/components/tree/tree-node.d.ts +13 -7
- package/dist/components/tree/tree-node.js +24 -10
- package/dist/components/tree/tree.d.ts +64 -15
- package/dist/components/tree/tree.js +113 -22
- package/dist/components/tree/tree.stories.d.ts +4 -0
- package/dist/components/tree/tree.stories.js +31 -1
- package/dist/components/tree/update-cascade.d.ts +13 -0
- package/dist/components/tree/update-cascade.js +13 -0
- package/dist/components/widget/widget.d.ts +15 -1
- package/dist/components/widget/widget.js +140 -5
- package/dist/components/widget/widget.stories.d.ts +1 -0
- package/dist/components/widget/widget.stories.js +10 -0
- package/dist/components/widget/widget.styles.js +11 -1
- package/dist/components/word-cloud/word-cloud-layout.d.ts +63 -0
- package/dist/components/word-cloud/word-cloud-layout.js +145 -0
- package/dist/components/word-cloud/word-cloud.d.ts +65 -0
- package/dist/components/word-cloud/word-cloud.js +255 -0
- package/dist/components/word-cloud/word-cloud.stories.d.ts +10 -0
- package/dist/components/word-cloud/word-cloud.stories.js +55 -0
- package/dist/components/word-cloud/word-cloud.styles.d.ts +1 -0
- package/dist/components/word-cloud/word-cloud.styles.js +54 -0
- package/dist/internal/a11y.js +2 -1
- package/dist/internal/rtl.d.ts +7 -0
- package/dist/internal/rtl.js +9 -0
- package/dist/lyra.d.ts +9 -1
- package/dist/lyra.js +6 -0
- package/llms-full.txt +892 -276
- package/llms.txt +12 -9
- package/package.json +6 -4
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISO 3166-1 alpha-2 shape: exactly two ASCII letters, case-insensitive.
|
|
3
|
+
* Shared by `flag.ts` (validating `country`) and `languageToCountry` below
|
|
4
|
+
* (validating a language tag's region subtag), because both values end up
|
|
5
|
+
* passed to the peer package `@aceshooting/lyra-flags`' `flagUrl()`, which
|
|
6
|
+
* naively interpolates its `code` argument into a `new URL('./flags/${code}.svg',
|
|
7
|
+
* ...)` with no validation of its own -- an unvalidated value containing `../`
|
|
8
|
+
* segments can escape the intended flags/ directory. Anything that doesn't
|
|
9
|
+
* match this shape is treated the same as an unknown/missing flag.
|
|
10
|
+
*/
|
|
11
|
+
export declare const ALPHA2_RE: RegExp;
|
|
1
12
|
/**
|
|
2
13
|
* Default mapping from a language subtag to a representative country flag
|
|
3
14
|
* (ISO 3166-1 alpha-2). Languages don't map 1:1 to countries; these are the
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISO 3166-1 alpha-2 shape: exactly two ASCII letters, case-insensitive.
|
|
3
|
+
* Shared by `flag.ts` (validating `country`) and `languageToCountry` below
|
|
4
|
+
* (validating a language tag's region subtag), because both values end up
|
|
5
|
+
* passed to the peer package `@aceshooting/lyra-flags`' `flagUrl()`, which
|
|
6
|
+
* naively interpolates its `code` argument into a `new URL('./flags/${code}.svg',
|
|
7
|
+
* ...)` with no validation of its own -- an unvalidated value containing `../`
|
|
8
|
+
* segments can escape the intended flags/ directory. Anything that doesn't
|
|
9
|
+
* match this shape is treated the same as an unknown/missing flag.
|
|
10
|
+
*/
|
|
11
|
+
export const ALPHA2_RE = /^[a-z]{2}$/i;
|
|
1
12
|
/**
|
|
2
13
|
* Default mapping from a language subtag to a representative country flag
|
|
3
14
|
* (ISO 3166-1 alpha-2). Languages don't map 1:1 to countries; these are the
|
|
@@ -56,7 +67,7 @@ export function languageToCountry(language) {
|
|
|
56
67
|
const parts = language.toLowerCase().split(/[-_]/);
|
|
57
68
|
const base = parts[0];
|
|
58
69
|
const region = parts[1];
|
|
59
|
-
if (region && region
|
|
70
|
+
if (region && ALPHA2_RE.test(region))
|
|
60
71
|
return region;
|
|
61
72
|
return LANGUAGE_TO_COUNTRY[base];
|
|
62
73
|
}
|
|
@@ -15,6 +15,8 @@ export declare class LyraGauge extends LyraElement {
|
|
|
15
15
|
max: number;
|
|
16
16
|
type: GaugeType;
|
|
17
17
|
label: string;
|
|
18
|
+
/** Imperative override for the displayed/announced value text, e.g. `'72°F'` for a raw `value` of `72`.
|
|
19
|
+
* An empty string is treated the same as unset and falls back to the numeric `value`. */
|
|
18
20
|
valueLabel?: string;
|
|
19
21
|
private get ratio();
|
|
20
22
|
protected willUpdate(): void;
|
|
@@ -57,14 +57,28 @@ export class LyraGauge extends LyraElement {
|
|
|
57
57
|
}
|
|
58
58
|
static { this.styles = [LyraElement.styles, styles]; }
|
|
59
59
|
get ratio() {
|
|
60
|
+
if (isNaN(this.value) || isNaN(this.min) || isNaN(this.max))
|
|
61
|
+
return 0;
|
|
60
62
|
const span = this.max - this.min || 1;
|
|
61
63
|
return Math.min(1, Math.max(0, (this.value - this.min) / span));
|
|
62
64
|
}
|
|
63
65
|
willUpdate() {
|
|
64
66
|
this.setAttribute('role', 'meter');
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
if (!isNaN(this.value) && !isNaN(this.min) && !isNaN(this.max)) {
|
|
68
|
+
const clamped = Math.min(this.max, Math.max(this.min, this.value));
|
|
69
|
+
this.setAttribute('aria-valuenow', String(clamped));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
this.removeAttribute('aria-valuenow');
|
|
73
|
+
}
|
|
74
|
+
if (isNaN(this.min))
|
|
75
|
+
this.removeAttribute('aria-valuemin');
|
|
76
|
+
else
|
|
77
|
+
this.setAttribute('aria-valuemin', String(this.min));
|
|
78
|
+
if (isNaN(this.max))
|
|
79
|
+
this.removeAttribute('aria-valuemax');
|
|
80
|
+
else
|
|
81
|
+
this.setAttribute('aria-valuemax', String(this.max));
|
|
68
82
|
if (this.label)
|
|
69
83
|
this.setAttribute('aria-label', this.label);
|
|
70
84
|
else
|
|
@@ -75,7 +89,7 @@ export class LyraGauge extends LyraElement {
|
|
|
75
89
|
this.removeAttribute('aria-valuetext');
|
|
76
90
|
}
|
|
77
91
|
renderRadial() {
|
|
78
|
-
const text = this.valueLabel
|
|
92
|
+
const text = this.valueLabel || (isNaN(this.value) ? '' : String(this.value));
|
|
79
93
|
// Dashoffset counts down from the full arc length (nothing revealed) to 0
|
|
80
94
|
// (whole sweep revealed) as ratio goes 0 -> 1 — the classic "draw an SVG
|
|
81
95
|
// path" technique, which transitions smoothly via plain CSS.
|
|
@@ -94,7 +108,7 @@ export class LyraGauge extends LyraElement {
|
|
|
94
108
|
</svg>`;
|
|
95
109
|
}
|
|
96
110
|
renderLinear() {
|
|
97
|
-
const text = this.valueLabel
|
|
111
|
+
const text = this.valueLabel || (isNaN(this.value) ? '' : String(this.value));
|
|
98
112
|
const dashoffset = LINEAR_LENGTH * (1 - this.ratio);
|
|
99
113
|
return html `<svg part="base" viewBox="0 0 100 20" preserveAspectRatio="none">
|
|
100
114
|
<line part="track" x1="0" y1=${LINEAR_BAR_Y} x2="100" y2=${LINEAR_BAR_Y} stroke-width=${LINEAR_STROKE}></line>
|
|
@@ -11,3 +11,9 @@ export const Radial = {
|
|
|
11
11
|
export const Linear = {
|
|
12
12
|
render: () => html `<lyra-gauge type="linear" value="40" max="100" label="Disk"></lyra-gauge>`,
|
|
13
13
|
};
|
|
14
|
+
export const WithValueLabel = {
|
|
15
|
+
render: () => html `<lyra-gauge value="72" max="100" label="Temp" .valueLabel=${'72°F'}></lyra-gauge>`,
|
|
16
|
+
};
|
|
17
|
+
export const NonzeroMin = {
|
|
18
|
+
render: () => html `<lyra-gauge value="18" min="-20" max="40" label="Outdoor Temp"></lyra-gauge>`,
|
|
19
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type * as ForceModule from 'd3-force';
|
|
2
|
+
import type * as DragModule from 'd3-drag';
|
|
3
|
+
import type * as ZoomModule from 'd3-zoom';
|
|
4
|
+
import type * as SelectionModule from 'd3-selection';
|
|
5
|
+
export interface D3Modules {
|
|
6
|
+
forceSimulation: typeof ForceModule.forceSimulation;
|
|
7
|
+
forceLink: typeof ForceModule.forceLink;
|
|
8
|
+
forceManyBody: typeof ForceModule.forceManyBody;
|
|
9
|
+
forceCenter: typeof ForceModule.forceCenter;
|
|
10
|
+
forceCollide: typeof ForceModule.forceCollide;
|
|
11
|
+
drag: typeof DragModule.drag;
|
|
12
|
+
zoom: typeof ZoomModule.zoom;
|
|
13
|
+
select: typeof SelectionModule.select;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the optional peer dependencies `d3-force`/`d3-drag`/`d3-zoom`/
|
|
17
|
+
* `d3-selection` via the given importers (real dynamic imports by default).
|
|
18
|
+
* Uncached and dependency-injectable so both the success path and the
|
|
19
|
+
* caught-error warning path are directly testable without needing to
|
|
20
|
+
* actually uninstall any of the four packages.
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadD3Modules(importForce?: () => Promise<typeof ForceModule>, importDrag?: () => Promise<typeof DragModule>, importZoom?: () => Promise<typeof ZoomModule>, importSelection?: () => Promise<typeof SelectionModule>): Promise<D3Modules | null>;
|
|
23
|
+
/**
|
|
24
|
+
* Lazily loads the d3 peer dependencies (see `loadD3Modules()`) once per
|
|
25
|
+
* page. Resolves to `null` if they aren't installed — mirrors
|
|
26
|
+
* `<lyra-flag>`'s peer-dependency pattern.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadD3(): Promise<D3Modules | null>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the optional peer dependencies `d3-force`/`d3-drag`/`d3-zoom`/
|
|
3
|
+
* `d3-selection` via the given importers (real dynamic imports by default).
|
|
4
|
+
* Uncached and dependency-injectable so both the success path and the
|
|
5
|
+
* caught-error warning path are directly testable without needing to
|
|
6
|
+
* actually uninstall any of the four packages.
|
|
7
|
+
*/
|
|
8
|
+
export async function loadD3Modules(importForce = () => import('d3-force'), importDrag = () => import('d3-drag'), importZoom = () => import('d3-zoom'), importSelection = () => import('d3-selection')) {
|
|
9
|
+
try {
|
|
10
|
+
const [force, dragMod, zoomMod, selectionMod] = await Promise.all([
|
|
11
|
+
importForce(),
|
|
12
|
+
importDrag(),
|
|
13
|
+
importZoom(),
|
|
14
|
+
importSelection(),
|
|
15
|
+
]);
|
|
16
|
+
return {
|
|
17
|
+
forceSimulation: force.forceSimulation,
|
|
18
|
+
forceLink: force.forceLink,
|
|
19
|
+
forceManyBody: force.forceManyBody,
|
|
20
|
+
forceCenter: force.forceCenter,
|
|
21
|
+
forceCollide: force.forceCollide,
|
|
22
|
+
drag: dragMod.drag,
|
|
23
|
+
zoom: zoomMod.zoom,
|
|
24
|
+
select: selectionMod.select,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
console.warn('<lyra-graph> needs the optional peer dependencies `d3-force`, `d3-drag`, ' +
|
|
29
|
+
'`d3-zoom`, and `d3-selection` — install them with `pnpm add d3-force d3-drag d3-zoom d3-selection`:', err);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
let d3Modules;
|
|
34
|
+
/**
|
|
35
|
+
* Lazily loads the d3 peer dependencies (see `loadD3Modules()`) once per
|
|
36
|
+
* page. Resolves to `null` if they aren't installed — mirrors
|
|
37
|
+
* `<lyra-flag>`'s peer-dependency pattern.
|
|
38
|
+
*/
|
|
39
|
+
export function loadD3() {
|
|
40
|
+
if (!d3Modules) {
|
|
41
|
+
d3Modules = loadD3Modules();
|
|
42
|
+
}
|
|
43
|
+
return d3Modules;
|
|
44
|
+
}
|
|
@@ -17,6 +17,10 @@ export interface GraphLink {
|
|
|
17
17
|
* Requires the optional peer deps `d3-force`/`d3-drag`/`d3-zoom`/`d3-selection`
|
|
18
18
|
* (lazy-loaded; a consumer who never uses this component pays zero d3 cost).
|
|
19
19
|
*
|
|
20
|
+
* Set `seed` for a deterministic layout: node initial positions become
|
|
21
|
+
* reproducible (keyed by node id) and the settle happens synchronously
|
|
22
|
+
* instead of animating, like `prefers-reduced-motion`.
|
|
23
|
+
*
|
|
20
24
|
* @customElement lyra-graph
|
|
21
25
|
* @event lyra-node-click - `detail: { id }`.
|
|
22
26
|
* @event lyra-link-click - `detail: { source, target }`.
|
|
@@ -32,12 +36,14 @@ export declare class LyraGraph extends LyraElement {
|
|
|
32
36
|
linkDistance: number;
|
|
33
37
|
minZoom: number;
|
|
34
38
|
maxZoom: number;
|
|
39
|
+
/** When set, seeds each node's initial x/y deterministically (keyed by
|
|
40
|
+
* node id, not array index) instead of forceSimulation()'s own random
|
|
41
|
+
* start, and settles the simulation synchronously — see rebuildSimulation(). */
|
|
42
|
+
seed?: number;
|
|
35
43
|
/** True until the lazy-loaded d3 peer dependencies have settled (success or failure). */
|
|
36
44
|
private loading;
|
|
37
45
|
private simNodes;
|
|
38
46
|
private simLinks;
|
|
39
|
-
/** Current pan/zoom transform (SVG `transform` attribute syntax), driven by d3-zoom. */
|
|
40
|
-
private transform;
|
|
41
47
|
private simulation?;
|
|
42
48
|
/** The live charge/link force objects, kept so chargeStrength/linkDistance
|
|
43
49
|
* changes can retune them in place (see updated()) instead of requiring a
|
|
@@ -47,6 +53,14 @@ export declare class LyraGraph extends LyraElement {
|
|
|
47
53
|
private d3?;
|
|
48
54
|
/** The `<svg>` currently wired up with d3-zoom (guards a one-time bind). */
|
|
49
55
|
private zoomedEl?;
|
|
56
|
+
/** The pan/zoom `<g>`, cached alongside `zoomedEl` so the zoom handler can
|
|
57
|
+
* write the transform straight to the DOM (see applyInteractions()) instead
|
|
58
|
+
* of round-tripping through a Lit reactive property on every pan/zoom event. */
|
|
59
|
+
private gEl?;
|
|
60
|
+
/** The live zoom behavior, kept so minZoom/maxZoom changes can retune its
|
|
61
|
+
* scaleExtent in place (see applyInteractions()) instead of requiring the
|
|
62
|
+
* `<svg>` to be rebound. */
|
|
63
|
+
private zoomBehavior?;
|
|
50
64
|
/** Node `<circle>`s already wired up with d3-drag; cleared on every simulation rebuild
|
|
51
65
|
* so DOM elements Lit reuses across a rebuild get rebound to their fresh datum. */
|
|
52
66
|
private boundNodeEls;
|
|
@@ -64,25 +78,37 @@ export declare class LyraGraph extends LyraElement {
|
|
|
64
78
|
protected updated(changed: PropertyValues): void;
|
|
65
79
|
/**
|
|
66
80
|
* Imperatively wires up d3-zoom (pan/zoom on the `<svg>`) and d3-drag
|
|
67
|
-
* (per-node drag) against the just-rendered DOM. The zoom bind is a
|
|
68
|
-
* one-time
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* `
|
|
75
|
-
*
|
|
81
|
+
* (per-node drag) against the just-rendered DOM. The zoom bind itself is a
|
|
82
|
+
* one-time guard (`zoomedEl`) — but the bound `zoomBehavior`'s
|
|
83
|
+
* `scaleExtent` is re-read from `minZoom`/`maxZoom` on every call so a
|
|
84
|
+
* post-mount change to either still takes effect. The node-drag bind +
|
|
85
|
+
* node/link/label element caching for `onTick()` only run when
|
|
86
|
+
* `changed` indicates a fresh structural render just happened, not on
|
|
87
|
+
* every call — otherwise this would re-scan the DOM via
|
|
88
|
+
* `querySelectorAll` on every Lit update, which used to include every
|
|
89
|
+
* single simulation tick. `<circle>`s already bound are skipped
|
|
90
|
+
* (`boundNodeEls`) — a WeakSet reset on every `rebuildSimulation()` so DOM
|
|
91
|
+
* nodes Lit reuses across a rebuild get rebound against their new datum
|
|
92
|
+
* instead of a stale one. The zoom handler writes the resulting transform
|
|
93
|
+
* straight to the cached `gEl` (bound once alongside `zoomedEl`, since the
|
|
94
|
+
* outer `<g>` is a static part of the template and never recreated by Lit)
|
|
95
|
+
* instead of assigning a Lit reactive property — panning/zooming fires
|
|
96
|
+
* continuously while dragging, and reassigning a `@state()` there would
|
|
97
|
+
* force a full re-render (recomputing every node/link template) on every
|
|
98
|
+
* single event, the same class of cost `onTick()` already avoids for ticks.
|
|
76
99
|
*/
|
|
77
100
|
private applyInteractions;
|
|
78
101
|
/**
|
|
79
102
|
* Runs on every d3-force simulation tick (up to ~300 while a graph settles
|
|
80
103
|
* on load, continuously while a node is being dragged via
|
|
81
104
|
* `alphaTarget(0.3)`). Writes positions straight to the already-rendered
|
|
82
|
-
* DOM via
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* `querySelectorAll` scan) on every single frame.
|
|
105
|
+
* DOM via `setAttribute()` instead of reassigning the reactive
|
|
106
|
+
* `simNodes`/`simLinks` properties, which would force a full Lit re-render
|
|
107
|
+
* (and, before the structural-render gate in `applyInteractions()`, an
|
|
108
|
+
* unconditional `querySelectorAll` scan) on every single frame. Writing attributes
|
|
109
|
+
* directly (rather than wrapping each element in a d3 selection just to
|
|
110
|
+
* call `.attr()`) avoids allocating a throwaway Selection per element on
|
|
111
|
+
* this component's highest-frequency code path.
|
|
86
112
|
*/
|
|
87
113
|
private onTick;
|
|
88
114
|
private rebuildSimulation;
|
|
@@ -10,38 +10,39 @@ import { styleMap } from 'lit/directives/style-map.js';
|
|
|
10
10
|
import { LyraElement } from '../../internal/lyra-element.js';
|
|
11
11
|
import { defineElement } from '../../internal/prefix.js';
|
|
12
12
|
import { styles } from './graph.styles.js';
|
|
13
|
+
import { loadD3 } from './graph-loader.js';
|
|
13
14
|
import '../skeleton/skeleton.js';
|
|
14
|
-
|
|
15
|
+
function prefersReducedMotion() {
|
|
16
|
+
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
17
|
+
}
|
|
15
18
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
+
* Tiny deterministic PRNG (mulberry32, public-domain) used only when `seed`
|
|
20
|
+
* is set, so a node's initial x/y is reproducible instead of whatever
|
|
21
|
+
* forceSimulation() would otherwise randomize it to. This is the whole
|
|
22
|
+
* algorithm — not worth a dependency for.
|
|
19
23
|
*/
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'`d3-zoom`, and `d3-selection` — install them with `pnpm add d3-force d3-drag d3-zoom d3-selection`.');
|
|
41
|
-
return null;
|
|
42
|
-
});
|
|
24
|
+
function mulberry32(seed) {
|
|
25
|
+
let a = seed >>> 0;
|
|
26
|
+
return () => {
|
|
27
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
28
|
+
let t = Math.imul(a ^ (a >>> 15), a | 1);
|
|
29
|
+
t = (t + Math.imul(t ^ (t >>> 7), t | 61)) ^ t;
|
|
30
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Derives a per-node mulberry32 seed from the component's `seed` and the
|
|
35
|
+
* node's **id** — hashing by id (a simple FNV-1a-style mix), not by the
|
|
36
|
+
* node's index in `nodes`, is what keeps a seeded layout reproducible no
|
|
37
|
+
* matter how a caller orders/reorders the `nodes` array between renders.
|
|
38
|
+
*/
|
|
39
|
+
function hashNodeSeed(seed, id) {
|
|
40
|
+
let h = seed >>> 0;
|
|
41
|
+
for (let i = 0; i < id.length; i++) {
|
|
42
|
+
h = Math.imul(h ^ id.charCodeAt(i), 2654435761);
|
|
43
|
+
h ^= h >>> 15;
|
|
43
44
|
}
|
|
44
|
-
return
|
|
45
|
+
return h >>> 0;
|
|
45
46
|
}
|
|
46
47
|
const MIN_RADIUS = 6;
|
|
47
48
|
const MAX_RADIUS = 24;
|
|
@@ -64,6 +65,10 @@ function sanitizeNodeColor(color) {
|
|
|
64
65
|
* Requires the optional peer deps `d3-force`/`d3-drag`/`d3-zoom`/`d3-selection`
|
|
65
66
|
* (lazy-loaded; a consumer who never uses this component pays zero d3 cost).
|
|
66
67
|
*
|
|
68
|
+
* Set `seed` for a deterministic layout: node initial positions become
|
|
69
|
+
* reproducible (keyed by node id) and the settle happens synchronously
|
|
70
|
+
* instead of animating, like `prefers-reduced-motion`.
|
|
71
|
+
*
|
|
67
72
|
* @customElement lyra-graph
|
|
68
73
|
* @event lyra-node-click - `detail: { id }`.
|
|
69
74
|
* @event lyra-link-click - `detail: { source, target }`.
|
|
@@ -84,8 +89,6 @@ export class LyraGraph extends LyraElement {
|
|
|
84
89
|
this.loading = true;
|
|
85
90
|
this.simNodes = [];
|
|
86
91
|
this.simLinks = [];
|
|
87
|
-
/** Current pan/zoom transform (SVG `transform` attribute syntax), driven by d3-zoom. */
|
|
88
|
-
this.transform = '';
|
|
89
92
|
/** Node `<circle>`s already wired up with d3-drag; cleared on every simulation rebuild
|
|
90
93
|
* so DOM elements Lit reuses across a rebuild get rebound to their fresh datum. */
|
|
91
94
|
this.boundNodeEls = new WeakSet();
|
|
@@ -137,11 +140,15 @@ export class LyraGraph extends LyraElement {
|
|
|
137
140
|
if (!this.d3)
|
|
138
141
|
return;
|
|
139
142
|
if (!changed.has('nodes') && !changed.has('links')) {
|
|
143
|
+
// These two branches are independent (not else-if): a consumer can set
|
|
144
|
+
// width/height and chargeStrength/linkDistance in the same reactive
|
|
145
|
+
// update batch, and both retunes must apply — not just whichever branch
|
|
146
|
+
// happens to come first.
|
|
140
147
|
if (changed.has('width') || changed.has('height')) {
|
|
141
148
|
this.simulation?.force('center', this.d3.forceCenter(this.width / 2, this.height / 2));
|
|
142
149
|
this.simulation?.alpha(0.1).restart();
|
|
143
150
|
}
|
|
144
|
-
|
|
151
|
+
if (changed.has('chargeStrength') || changed.has('linkDistance')) {
|
|
145
152
|
// Without this branch, chargeStrength/linkDistance only took effect
|
|
146
153
|
// the next time nodes/links also changed (rebuildSimulation() reads
|
|
147
154
|
// them fresh) — retune the already-created force objects in place
|
|
@@ -157,35 +164,48 @@ export class LyraGraph extends LyraElement {
|
|
|
157
164
|
// rebuildSimulation()'s doc comment) — never on a tick — so gating the
|
|
158
165
|
// node/link querySelectorAll scan on that is equivalent to "structurally
|
|
159
166
|
// changed" without needing a separate flag.
|
|
160
|
-
this.applyInteractions(changed
|
|
167
|
+
this.applyInteractions(changed);
|
|
161
168
|
}
|
|
162
169
|
/**
|
|
163
170
|
* Imperatively wires up d3-zoom (pan/zoom on the `<svg>`) and d3-drag
|
|
164
|
-
* (per-node drag) against the just-rendered DOM. The zoom bind is a
|
|
165
|
-
* one-time
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* `
|
|
172
|
-
*
|
|
171
|
+
* (per-node drag) against the just-rendered DOM. The zoom bind itself is a
|
|
172
|
+
* one-time guard (`zoomedEl`) — but the bound `zoomBehavior`'s
|
|
173
|
+
* `scaleExtent` is re-read from `minZoom`/`maxZoom` on every call so a
|
|
174
|
+
* post-mount change to either still takes effect. The node-drag bind +
|
|
175
|
+
* node/link/label element caching for `onTick()` only run when
|
|
176
|
+
* `changed` indicates a fresh structural render just happened, not on
|
|
177
|
+
* every call — otherwise this would re-scan the DOM via
|
|
178
|
+
* `querySelectorAll` on every Lit update, which used to include every
|
|
179
|
+
* single simulation tick. `<circle>`s already bound are skipped
|
|
180
|
+
* (`boundNodeEls`) — a WeakSet reset on every `rebuildSimulation()` so DOM
|
|
181
|
+
* nodes Lit reuses across a rebuild get rebound against their new datum
|
|
182
|
+
* instead of a stale one. The zoom handler writes the resulting transform
|
|
183
|
+
* straight to the cached `gEl` (bound once alongside `zoomedEl`, since the
|
|
184
|
+
* outer `<g>` is a static part of the template and never recreated by Lit)
|
|
185
|
+
* instead of assigning a Lit reactive property — panning/zooming fires
|
|
186
|
+
* continuously while dragging, and reassigning a `@state()` there would
|
|
187
|
+
* force a full re-render (recomputing every node/link template) on every
|
|
188
|
+
* single event, the same class of cost `onTick()` already avoids for ticks.
|
|
173
189
|
*/
|
|
174
|
-
applyInteractions(
|
|
190
|
+
applyInteractions(changed) {
|
|
175
191
|
if (!this.d3)
|
|
176
192
|
return;
|
|
177
193
|
const svgEl = this.renderRoot.querySelector('svg');
|
|
178
194
|
if (svgEl && svgEl !== this.zoomedEl) {
|
|
179
195
|
this.zoomedEl = svgEl;
|
|
180
|
-
|
|
196
|
+
this.gEl = this.renderRoot.querySelector('g') ?? undefined;
|
|
197
|
+
this.zoomBehavior = this.d3
|
|
181
198
|
.zoom()
|
|
182
199
|
.scaleExtent([this.minZoom, this.maxZoom])
|
|
183
200
|
.on('zoom', (event) => {
|
|
184
|
-
this.transform
|
|
201
|
+
this.gEl?.setAttribute('transform', event.transform.toString());
|
|
185
202
|
});
|
|
186
|
-
this.d3.select(svgEl).call(zoomBehavior);
|
|
203
|
+
this.d3.select(svgEl).call(this.zoomBehavior);
|
|
187
204
|
}
|
|
188
|
-
if (
|
|
205
|
+
else if (this.zoomBehavior && (changed.has('minZoom') || changed.has('maxZoom'))) {
|
|
206
|
+
this.zoomBehavior.scaleExtent([this.minZoom, this.maxZoom]);
|
|
207
|
+
}
|
|
208
|
+
if (!(changed.has('simNodes') || changed.has('simLinks')))
|
|
189
209
|
return;
|
|
190
210
|
const nodeEls = Array.from(this.renderRoot.querySelectorAll('[part="node"]'));
|
|
191
211
|
this.nodeEls = nodeEls;
|
|
@@ -223,24 +243,25 @@ export class LyraGraph extends LyraElement {
|
|
|
223
243
|
* Runs on every d3-force simulation tick (up to ~300 while a graph settles
|
|
224
244
|
* on load, continuously while a node is being dragged via
|
|
225
245
|
* `alphaTarget(0.3)`). Writes positions straight to the already-rendered
|
|
226
|
-
* DOM via
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* `querySelectorAll` scan) on every single frame.
|
|
246
|
+
* DOM via `setAttribute()` instead of reassigning the reactive
|
|
247
|
+
* `simNodes`/`simLinks` properties, which would force a full Lit re-render
|
|
248
|
+
* (and, before the structural-render gate in `applyInteractions()`, an
|
|
249
|
+
* unconditional `querySelectorAll` scan) on every single frame. Writing attributes
|
|
250
|
+
* directly (rather than wrapping each element in a d3 selection just to
|
|
251
|
+
* call `.attr()`) avoids allocating a throwaway Selection per element on
|
|
252
|
+
* this component's highest-frequency code path.
|
|
230
253
|
*/
|
|
231
254
|
onTick() {
|
|
232
|
-
if (!this.d3)
|
|
233
|
-
return;
|
|
234
|
-
const d3 = this.d3;
|
|
235
255
|
this.simNodes.forEach((n, i) => {
|
|
236
256
|
const circle = this.nodeEls[i];
|
|
237
|
-
if (circle)
|
|
238
|
-
|
|
257
|
+
if (circle) {
|
|
258
|
+
circle.setAttribute('cx', String(n.x ?? 0));
|
|
259
|
+
circle.setAttribute('cy', String(n.y ?? 0));
|
|
260
|
+
}
|
|
239
261
|
const label = this.nodeLabelEls[i];
|
|
240
262
|
if (label) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
.attr('y', n.y ?? 0);
|
|
263
|
+
label.setAttribute('x', String((n.x ?? 0) + this.nodeRadius(n) + 2));
|
|
264
|
+
label.setAttribute('y', String(n.y ?? 0));
|
|
244
265
|
}
|
|
245
266
|
});
|
|
246
267
|
this.simLinks.forEach((l, i) => {
|
|
@@ -249,11 +270,10 @@ export class LyraGraph extends LyraElement {
|
|
|
249
270
|
return;
|
|
250
271
|
const source = l.source;
|
|
251
272
|
const target = l.target;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
.attr('y2', target.y ?? 0);
|
|
273
|
+
line.setAttribute('x1', String(source.x ?? 0));
|
|
274
|
+
line.setAttribute('y1', String(source.y ?? 0));
|
|
275
|
+
line.setAttribute('x2', String(target.x ?? 0));
|
|
276
|
+
line.setAttribute('y2', String(target.y ?? 0));
|
|
257
277
|
});
|
|
258
278
|
}
|
|
259
279
|
rebuildSimulation() {
|
|
@@ -261,20 +281,61 @@ export class LyraGraph extends LyraElement {
|
|
|
261
281
|
return;
|
|
262
282
|
this.simulation?.stop();
|
|
263
283
|
this.boundNodeEls = new WeakSet();
|
|
264
|
-
|
|
284
|
+
// Carry over each existing SimNode's settled position/velocity (and any
|
|
285
|
+
// in-progress drag fx/fy) by id instead of starting every node fresh —
|
|
286
|
+
// otherwise any structural nodes/links change (e.g. appending one new
|
|
287
|
+
// node to a live/streaming graph) would discard every already-settled
|
|
288
|
+
// node's (x, y) and restart the whole force layout's ~300-tick random-
|
|
289
|
+
// start settle animation from scratch. Only nodes with no previous
|
|
290
|
+
// counterpart (genuinely new ids) get forceSimulation()'s default
|
|
291
|
+
// fresh random start below.
|
|
292
|
+
const prevById = new Map(this.simNodes.map((n) => [n.id, n]));
|
|
293
|
+
const nodes = this.nodes.map((n) => {
|
|
294
|
+
const prev = prevById.get(n.id);
|
|
295
|
+
return prev ? { ...prev, ...n } : { ...n };
|
|
296
|
+
});
|
|
265
297
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
266
298
|
const links = this.links
|
|
267
299
|
.filter((l) => byId.has(l.source) && byId.has(l.target))
|
|
268
300
|
.map((l) => ({ ...l, source: byId.get(l.source), target: byId.get(l.target) }));
|
|
301
|
+
// Give brand-new nodes (no carried-over position from prevById above) a
|
|
302
|
+
// deterministic starting x/y, keyed by node id, instead of leaving them
|
|
303
|
+
// for forceSimulation() below to randomize. Nodes that already have a
|
|
304
|
+
// position are left untouched — same "only randomize nodes without x/y"
|
|
305
|
+
// rule forceSimulation() itself follows — so an incremental update to a
|
|
306
|
+
// seeded, already-settled graph doesn't reshuffle existing nodes.
|
|
307
|
+
const seed = this.seed;
|
|
308
|
+
if (seed != null) {
|
|
309
|
+
for (const n of nodes) {
|
|
310
|
+
if (n.x != null && n.y != null)
|
|
311
|
+
continue;
|
|
312
|
+
const rng = mulberry32(hashNodeSeed(seed, n.id));
|
|
313
|
+
n.x = rng() * this.width;
|
|
314
|
+
n.y = rng() * this.height;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
269
317
|
this.linkForce = this.d3.forceLink(links).distance(this.linkDistance);
|
|
270
318
|
this.chargeForce = this.d3.forceManyBody().strength(this.chargeStrength);
|
|
271
|
-
|
|
319
|
+
const simulation = this.d3
|
|
272
320
|
.forceSimulation(nodes)
|
|
273
321
|
.force('link', this.linkForce)
|
|
274
322
|
.force('charge', this.chargeForce)
|
|
275
323
|
.force('center', this.d3.forceCenter(this.width / 2, this.height / 2))
|
|
276
324
|
.force('collide', this.d3.forceCollide().radius((n) => this.nodeRadius(n) + 10))
|
|
277
325
|
.on('tick', () => this.onTick());
|
|
326
|
+
this.simulation = simulation;
|
|
327
|
+
if (prefersReducedMotion() || this.seed != null) {
|
|
328
|
+
// Converge synchronously instead of animating the settle over ~300
|
|
329
|
+
// rendered frames — the simulation is already stopped (alpha at
|
|
330
|
+
// alphaMin) by the time the DOM for this rebuild is first painted.
|
|
331
|
+
// A seed converging synchronously (not just deterministically-seeded)
|
|
332
|
+
// is what makes its end state reproducible: two runs must agree on the
|
|
333
|
+
// exact same number of ticks, not merely start from the same x/y.
|
|
334
|
+
// User-initiated motion (dragging a node) is unaffected.
|
|
335
|
+
simulation.stop();
|
|
336
|
+
while (simulation.alpha() > simulation.alphaMin())
|
|
337
|
+
simulation.tick();
|
|
338
|
+
}
|
|
278
339
|
// Assign these SAME array/object references (not copies) exactly once
|
|
279
340
|
// for this structural rebuild: forceSimulation(nodes) above already
|
|
280
341
|
// initialized their .x/.y synchronously, so this one Lit re-render
|
|
@@ -307,19 +368,33 @@ export class LyraGraph extends LyraElement {
|
|
|
307
368
|
}
|
|
308
369
|
return html `
|
|
309
370
|
<div part="base">
|
|
310
|
-
<svg
|
|
311
|
-
|
|
371
|
+
<svg
|
|
372
|
+
part="svg"
|
|
373
|
+
role="group"
|
|
374
|
+
aria-label="Node-link diagram with ${this.simNodes.length} nodes and ${this.simLinks.length} links"
|
|
375
|
+
viewBox="0 0 ${this.width} ${this.height}"
|
|
376
|
+
>
|
|
377
|
+
<g transform="">
|
|
312
378
|
${this.simLinks.map((l) => {
|
|
313
379
|
const source = l.source;
|
|
314
380
|
const target = l.target;
|
|
315
381
|
return svg `<line
|
|
316
382
|
part="link"
|
|
383
|
+
role="button"
|
|
384
|
+
tabindex="0"
|
|
385
|
+
aria-label="Link from ${source.label ?? source.id} to ${target.label ?? target.id}"
|
|
317
386
|
stroke-width=${l.width ?? 1.5}
|
|
318
387
|
x1=${source.x ?? 0}
|
|
319
388
|
y1=${source.y ?? 0}
|
|
320
389
|
x2=${target.x ?? 0}
|
|
321
390
|
y2=${target.y ?? 0}
|
|
322
391
|
@click=${() => this.onLinkClick(l)}
|
|
392
|
+
@keydown=${(e) => {
|
|
393
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
394
|
+
e.preventDefault();
|
|
395
|
+
this.onLinkClick(l);
|
|
396
|
+
}
|
|
397
|
+
}}
|
|
323
398
|
></line>`;
|
|
324
399
|
})}
|
|
325
400
|
${this.simNodes.map((n) => {
|
|
@@ -343,7 +418,7 @@ export class LyraGraph extends LyraElement {
|
|
|
343
418
|
}}
|
|
344
419
|
></circle>
|
|
345
420
|
${n.label
|
|
346
|
-
? svg `<text part="label" x=${(n.x ?? 0) + this.nodeRadius(n) + 2} y=${n.y ?? 0}>${n.label}</text>`
|
|
421
|
+
? svg `<text part="label" aria-hidden="true" x=${(n.x ?? 0) + this.nodeRadius(n) + 2} y=${n.y ?? 0}>${n.label}</text>`
|
|
347
422
|
: ''}
|
|
348
423
|
</g>`;
|
|
349
424
|
})}
|
|
@@ -377,6 +452,9 @@ __decorate([
|
|
|
377
452
|
__decorate([
|
|
378
453
|
property({ type: Number, attribute: 'max-zoom' })
|
|
379
454
|
], LyraGraph.prototype, "maxZoom", void 0);
|
|
455
|
+
__decorate([
|
|
456
|
+
property({ type: Number })
|
|
457
|
+
], LyraGraph.prototype, "seed", void 0);
|
|
380
458
|
__decorate([
|
|
381
459
|
state()
|
|
382
460
|
], LyraGraph.prototype, "loading", void 0);
|
|
@@ -386,7 +464,4 @@ __decorate([
|
|
|
386
464
|
__decorate([
|
|
387
465
|
state()
|
|
388
466
|
], LyraGraph.prototype, "simLinks", void 0);
|
|
389
|
-
__decorate([
|
|
390
|
-
state()
|
|
391
|
-
], LyraGraph.prototype, "transform", void 0);
|
|
392
467
|
defineElement('graph', LyraGraph);
|