@kubex/zinc 1.1.179 → 1.1.181
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/dist/custom-elements.json +697 -454
- package/dist/vscode.html-custom-data.json +102 -64
- package/dist/web-types.json +243 -163
- package/dist/zn.d.ts +115 -11
- package/dist/zn.min.js +331 -322
- package/docs/pages/components/chart.md +49 -0
- package/docs/pages/components/stat.md +74 -0
- package/docs/pages/components/tile.md +16 -0
- package/package.json +1 -1
- package/src/components/chart/builders.ts +15 -0
- package/src/components/chart/chart-live.test.ts +82 -0
- package/src/components/chart/chart.component.ts +111 -14
- package/src/components/chart/live.test.ts +96 -0
- package/src/components/chart/live.ts +164 -0
- package/src/components/cols/cols.scss +16 -0
- package/src/components/data-table/data-table.component.ts +3 -2
- package/src/components/data-table-filter/data-table-filter.component.ts +2 -2
- package/src/components/data-table-search/data-table-search.component.ts +2 -2
- package/src/components/defined-label/defined-label.component.ts +1 -1
- package/src/components/filter-container/filter-container.component.ts +1 -1
- package/src/components/filter-wrapper/filter-wrapper.component.ts +2 -0
- package/src/components/filter-wrapper/filter-wrapper.test.ts +46 -0
- package/src/components/icon-picker/icon-picker.component.ts +1 -1
- package/src/components/input/input.component.ts +10 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +1 -1
- package/src/components/select/select.component.ts +1 -1
- package/src/components/stat/stat.component.ts +81 -12
- package/src/components/stat/stat.scss +64 -0
- package/src/components/tile/tile.component.ts +3 -0
- package/src/components/tile/tile.scss +30 -0
- package/src/internal/form.ts +18 -3
|
@@ -682,3 +682,52 @@ chart.enableAnimations = true;
|
|
|
682
682
|
- Round values appropriately for readability
|
|
683
683
|
- Use `y-axis-append` to add units or symbols to values
|
|
684
684
|
- Format dates consistently when using time series data
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
## Signed SSE charts
|
|
688
|
+
|
|
689
|
+
Use SSE for a moving time window with authoritative bucket updates:
|
|
690
|
+
|
|
691
|
+
```html
|
|
692
|
+
<zn-chart type="line" xaxis="datetime" live
|
|
693
|
+
live-renew-url="/sensors/live-grant?sensor=cpu&window=3600&interval=1&metric=hits"
|
|
694
|
+
enable-animations="200"></zn-chart>
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
Rubix rewrites `live-renew-url` through the app's authenticated route. The GET
|
|
698
|
+
endpoint must return JSON `{stream_url, expires_at, server_time}` with UTC
|
|
699
|
+
millisecond times, `Cache-Control: no-store`, and recheck login and permissions.
|
|
700
|
+
Renewal accepts only same-origin URLs, sends the login cookie only to that route,
|
|
701
|
+
and rejects redirects. Return 401/403 to stop streaming after logout or loss of
|
|
702
|
+
access. Grants must also expire on the streaming server; the client is not an
|
|
703
|
+
authorization boundary.
|
|
704
|
+
|
|
705
|
+
For a grant obtained elsewhere, supply `stream-url` directly. Without a renewal
|
|
706
|
+
route it stops at the first frame's expiry. Stream requests use EventSource with
|
|
707
|
+
no cross-origin credentials. Existing `live data-url` polling remains supported;
|
|
708
|
+
SSE takes precedence when `stream-url` or `live-renew-url` is present.
|
|
709
|
+
|
|
710
|
+
The stream uses `snapshot`, `delta`, and `heartbeat` events. Snapshots and deltas
|
|
711
|
+
contain `{from, until, server_time, expires_at, resolution_seconds, metric, points}`.
|
|
712
|
+
Times are UTC milliseconds; each point is `{at, value}`. Snapshots replace the
|
|
713
|
+
window, deltas replace buckets by timestamp, and heartbeats contain
|
|
714
|
+
`{server_time, expires_at}`. Reconnect must start with a snapshot. Buffers are
|
|
715
|
+
limited to 10,000 points. A resolution or metric change requires a snapshot.
|
|
716
|
+
|
|
717
|
+
Optional `prediction` contains `status`, `average_rates`, and `points` with
|
|
718
|
+
`{at, lower, upper, expected, available}`. Available ranges are drawn behind
|
|
719
|
+
actual values and labelled as average rates when necessary. Ordinary deltas
|
|
720
|
+
preserve the range; snapshots replace it. `hide-zero` hides actual zeros only.
|
|
721
|
+
|
|
722
|
+
The chart emits `zn-live-status` with `detail.status` (`connecting`, `connected`,
|
|
723
|
+
`reconnecting`, `expired`, `denied`, `invalid`, or `unavailable`) and shows status
|
|
724
|
+
below the plot. It closes its transport on removal or when live mode is disabled.
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
Live charts honour `enable-animations`: a numeric value sets the update
|
|
728
|
+
transition in milliseconds (capped at 1,000ms). Use `350` for gentle live
|
|
729
|
+
transitions. Initial reveal animations remain disabled; existing points and the
|
|
730
|
+
moving window interpolate without resetting the chart. The expected range moves
|
|
731
|
+
with the same transition. Animation remains enabled up to the 10,000-point live
|
|
732
|
+
buffer limit. Reduced-motion preferences and disabled animations produce instant
|
|
733
|
+
updates. Recorded values and peaks are unchanged.
|
|
@@ -99,6 +99,80 @@ Use the `show-delta` attribute with `previous` to show the percentage change fro
|
|
|
99
99
|
</zn-cols>
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
### Value First
|
|
103
|
+
|
|
104
|
+
Use `layout="value-first"` to show the amount under the caption, with the description beneath it in muted text. In this layout `color` tints the amount, so use `color="neutral"` for plain figures and a semantic color to flag one. Each stat has a box of its own, so `zn-cols divide` draws lines between them. The example adds `size="large"` for headline figures.
|
|
105
|
+
|
|
106
|
+
```html:preview
|
|
107
|
+
<zn-panel caption="Last 30 days">
|
|
108
|
+
<zn-cols layout="1,1,1,1" divide>
|
|
109
|
+
<zn-stat layout="value-first" size="large" caption="Quality score" description="Weighted by points" amount="88.9" previous="86.5" type="percent" delta-format="points" show-delta color="neutral"></zn-stat>
|
|
110
|
+
<zn-stat layout="value-first" size="large" caption="Evaluations" description="Completed assessments" amount="42" color="neutral"></zn-stat>
|
|
111
|
+
<zn-stat layout="value-first" size="large" caption="Fails" description="1 automatic" amount="3" color="error"></zn-stat>
|
|
112
|
+
<zn-stat layout="value-first" size="large" caption="Kudos" description="From reviewers" amount="2" icon="award@lu" color="neutral"></zn-stat>
|
|
113
|
+
</zn-cols>
|
|
114
|
+
</zn-panel>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Padded
|
|
118
|
+
|
|
119
|
+
Use `padded` to give each stat its own padding, for stats laid edge to edge in a flush panel. With `zn-cols no-gap divide` the dividers run the full height of the panel body.
|
|
120
|
+
|
|
121
|
+
```html:preview
|
|
122
|
+
<zn-panel caption="Last 30 days" flush>
|
|
123
|
+
<zn-cols layout="1,1,1" no-gap divide stack-at="md">
|
|
124
|
+
<zn-stat layout="value-first" size="large" padded caption="Quality score" description="Weighted by points" amount="88.9" type="percent" color="neutral"></zn-stat>
|
|
125
|
+
<zn-stat layout="value-first" size="large" padded caption="Evaluations" description="Completed assessments" amount="42" color="neutral"></zn-stat>
|
|
126
|
+
<zn-stat layout="value-first" size="large" padded caption="Fails" description="1 automatic" amount="3" color="error"></zn-stat>
|
|
127
|
+
</zn-cols>
|
|
128
|
+
</zn-panel>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Large
|
|
132
|
+
|
|
133
|
+
Use `size="large"` to give the amount more weight, for a row of a few headline figures.
|
|
134
|
+
|
|
135
|
+
```html:preview
|
|
136
|
+
<zn-cols>
|
|
137
|
+
<zn-panel>
|
|
138
|
+
<zn-stat size="large" caption="Total Users" description="Active accounts" amount="1247"></zn-stat>
|
|
139
|
+
</zn-panel>
|
|
140
|
+
<zn-panel>
|
|
141
|
+
<zn-stat size="large" caption="Conversion Rate" description="Last 30 days" amount="4.8" type="percent" color="success"></zn-stat>
|
|
142
|
+
</zn-panel>
|
|
143
|
+
</zn-cols>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Delta In Points
|
|
147
|
+
|
|
148
|
+
Use `delta-format="points"` with `show-delta` to show the difference between `amount` and `previous` in points rather than the relative change. This suits values that are already percentages.
|
|
149
|
+
|
|
150
|
+
```html:preview
|
|
151
|
+
<zn-cols>
|
|
152
|
+
<zn-panel>
|
|
153
|
+
<zn-stat caption="Pass rate" description="This month" amount="91.5" previous="88.2" type="percent" delta-format="points" show-delta color="success"></zn-stat>
|
|
154
|
+
</zn-panel>
|
|
155
|
+
<zn-panel>
|
|
156
|
+
<zn-stat caption="Error rate" description="Last 24 hours" amount="2.1" previous="3.4" type="percent" delta-format="points" show-delta color="neutral"></zn-stat>
|
|
157
|
+
</zn-panel>
|
|
158
|
+
</zn-cols>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Icon
|
|
162
|
+
|
|
163
|
+
Use `icon` to show an icon beside the amount. It takes the amount's color unless you set `icon-color`.
|
|
164
|
+
|
|
165
|
+
```html:preview
|
|
166
|
+
<zn-cols>
|
|
167
|
+
<zn-panel>
|
|
168
|
+
<zn-stat caption="Awards" description="This quarter" amount="12" icon="award@lu" color="warning"></zn-stat>
|
|
169
|
+
</zn-panel>
|
|
170
|
+
<zn-panel>
|
|
171
|
+
<zn-stat caption="Kudos" description="From reviewers" amount="2" icon="award@lu" icon-color="warning" color="neutral"></zn-stat>
|
|
172
|
+
</zn-panel>
|
|
173
|
+
</zn-cols>
|
|
174
|
+
```
|
|
175
|
+
|
|
102
176
|
### Color Variants
|
|
103
177
|
|
|
104
178
|
Use the `color` attribute to set the semantic color of the stat. Available colors: `primary`, `error`, `info`, `warning`, `success`, and `neutral`.
|
|
@@ -179,6 +179,22 @@ Use the `actions` slot to add buttons or other interactive elements to the right
|
|
|
179
179
|
</zn-sp>
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
+
### Stacked Actions
|
|
183
|
+
|
|
184
|
+
Use `stack-actions` when a tile carries several actions, such as a row of chips. Once the tile is narrower than 600px the actions move under the caption and description and wrap, rather than squeezing the text.
|
|
185
|
+
|
|
186
|
+
```html:preview
|
|
187
|
+
<zn-sp divide no-gap>
|
|
188
|
+
<zn-tile stack-actions caption="Refund not received" description="Support scorecard · scored 13 Oct 2026 13:22">
|
|
189
|
+
<zn-icon slot="image" size="24" src="at-sign@lu"></zn-icon>
|
|
190
|
+
<zn-chip slot="actions" size="small" type="info" icon="messages-square@lu">Coached</zn-chip>
|
|
191
|
+
<zn-chip slot="actions" size="small" type="error" icon="circle-x@lu">Auto fail</zn-chip>
|
|
192
|
+
<zn-chip slot="actions" size="small" type="error" icon="shield-alert@lu">Compliance</zn-chip>
|
|
193
|
+
<zn-chip slot="actions" size="small" type="error">0%</zn-chip>
|
|
194
|
+
</zn-tile>
|
|
195
|
+
</zn-sp>
|
|
196
|
+
```
|
|
197
|
+
|
|
182
198
|
### Properties with Actions
|
|
183
199
|
|
|
184
200
|
Combine both properties and actions for comprehensive tile layouts.
|
package/package.json
CHANGED
|
@@ -17,11 +17,19 @@ export type ChartType =
|
|
|
17
17
|
| 'treemap';
|
|
18
18
|
|
|
19
19
|
export interface SeriesItem {
|
|
20
|
+
/** Stable identity for incremental live updates. */
|
|
21
|
+
id?: string;
|
|
22
|
+
animation?: boolean;
|
|
23
|
+
animationDuration?: number;
|
|
24
|
+
animationDurationUpdate?: number;
|
|
25
|
+
animationEasingUpdate?: 'linear';
|
|
26
|
+
animationThreshold?: number;
|
|
20
27
|
name: string;
|
|
21
28
|
data: any[];
|
|
22
29
|
color?: string;
|
|
23
30
|
/** Series sharing a stack ID are stacked, overriding the chart-wide `stacked` setting. */
|
|
24
31
|
stack?: string;
|
|
32
|
+
stackStrategy?: 'all' | 'samesign' | 'positive' | 'negative';
|
|
25
33
|
/** Fill below a line, for example `{ opacity: 0.15 }`; `{ opacity: 0 }` keeps a stacked base invisible. */
|
|
26
34
|
areaStyle?: Record<string, unknown>;
|
|
27
35
|
lineStyle?: Record<string, unknown>;
|
|
@@ -191,6 +199,13 @@ function seriesFromProps(
|
|
|
191
199
|
// followed by a filled series holding the band's height.
|
|
192
200
|
function seriesStyle(s: SeriesItem): Record<string, unknown> {
|
|
193
201
|
return {
|
|
202
|
+
...(s.id ? { id: s.id } : {}),
|
|
203
|
+
...(s.animation !== undefined ? { animation: s.animation } : {}),
|
|
204
|
+
...(s.animationDuration !== undefined ? { animationDuration: s.animationDuration } : {}),
|
|
205
|
+
...(s.animationDurationUpdate !== undefined ? { animationDurationUpdate: s.animationDurationUpdate } : {}),
|
|
206
|
+
...(s.animationEasingUpdate ? { animationEasingUpdate: s.animationEasingUpdate } : {}),
|
|
207
|
+
...(s.animationThreshold !== undefined ? { animationThreshold: s.animationThreshold } : {}),
|
|
208
|
+
...(s.stackStrategy ? { stackStrategy: s.stackStrategy } : {}),
|
|
194
209
|
...(s.areaStyle ? { areaStyle: s.areaStyle } : {}),
|
|
195
210
|
...(s.lineStyle ? { lineStyle: s.lineStyle } : {}),
|
|
196
211
|
...(s.symbol ? { symbol: s.symbol } : {}),
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
|
|
2
|
+
import { expect, fixture, html } from '@open-wc/testing';
|
|
3
|
+
import ZnChart from './chart.component';
|
|
4
|
+
|
|
5
|
+
ZnChart.define('zn-chart-live-test');
|
|
6
|
+
class Source extends EventTarget {
|
|
7
|
+
static instances: Source[]=[]; closed=false; onopen?:()=>void; onerror?:()=>void;
|
|
8
|
+
url: string;
|
|
9
|
+
constructor(url: string) { super(); this.url = url;Source.instances.push(this); }
|
|
10
|
+
close() {this.closed=true;}
|
|
11
|
+
send(event:string,frame:unknown) {this.dispatchEvent(new MessageEvent(event,{data:JSON.stringify(frame)}));}
|
|
12
|
+
}
|
|
13
|
+
const pause=()=>new Promise(r=>setTimeout(r,50));
|
|
14
|
+
|
|
15
|
+
describe('chart SSE component',()=>{
|
|
16
|
+
const original=window.EventSource;
|
|
17
|
+
beforeEach(()=>{Source.instances=[];window.EventSource=Source as unknown as typeof EventSource;});
|
|
18
|
+
afterEach(()=>{window.EventSource=original;});
|
|
19
|
+
it('renders SSE buckets and expected bands, reacts to display changes, and stops when removed',async()=>{
|
|
20
|
+
const el:any=await fixture(html`<zn-chart-live-test type="line" xaxis="datetime" enable-animations="350" live stream-url="https://relay.example/events"></zn-chart-live-test>`);
|
|
21
|
+
await el.updateComplete;await pause();
|
|
22
|
+
const source=Source.instances.at(-1)!;
|
|
23
|
+
expect(source).to.exist;
|
|
24
|
+
const f={from:0,until:10000,server_time:Date.now(),expires_at:Date.now()+60000,resolution_seconds:1,metric:'hits',points:[{at:1000,value:0},{at:2000,value:2}],prediction:{status:'ready',points:[{at:0,lower:1,upper:5,expected:3,available:true}]}};
|
|
25
|
+
source.send('snapshot',f);await el.updateComplete;
|
|
26
|
+
expect(el.chart.getOption().series.length).to.equal(3);
|
|
27
|
+
expect(el.chart.getOption().series[2].data.map((p:any)=>p.value)).to.deep.equal([[1000,0],[2000,2]]);
|
|
28
|
+
const valueModel = el.chart.getModel().getSeriesByIndex(2);
|
|
29
|
+
const axisModel = el.chart.getModel().getComponent('xAxis',0);
|
|
30
|
+
expect(valueModel.get('animation')).to.equal(true);
|
|
31
|
+
expect(valueModel.get('animationDuration')).to.equal(0);
|
|
32
|
+
expect(valueModel.get('animationDurationUpdate')).to.equal(350);
|
|
33
|
+
expect(valueModel.get('animationThreshold')).to.equal(10000);
|
|
34
|
+
const rangeModel = el.chart.getModel().getSeriesByIndex(1);
|
|
35
|
+
const rangeData = rangeModel.option.data;
|
|
36
|
+
expect(rangeModel.id).to.equal('live-range');
|
|
37
|
+
expect(rangeModel.get('animation')).to.equal(true);
|
|
38
|
+
expect(rangeModel.get('animationDuration')).to.equal(0);
|
|
39
|
+
el.hideZero=true;await el.updateComplete;await el.updateComplete;
|
|
40
|
+
expect(el.chart.getOption().series[2].data[0].value[1]).to.equal(null);
|
|
41
|
+
source.send('delta',{...f,prediction:undefined,points:[{at:2000,value:7}]});await el.updateComplete;
|
|
42
|
+
expect(el.chart.getOption().series[2].data[1].value[1]).to.equal(7);
|
|
43
|
+
expect(el.chart.getViewOfSeriesModel(valueModel)._polyline.animators.length).to.be.greaterThan(0);
|
|
44
|
+
const sameAxis = el.chart.getModel().getComponent('xAxis',0) === axisModel;
|
|
45
|
+
const sameValues = el.chart.getModel().getSeriesByIndex(2) === valueModel;
|
|
46
|
+
expect(sameAxis).to.equal(true);
|
|
47
|
+
expect(sameValues).to.equal(true);
|
|
48
|
+
const sameModel = el.chart.getModel().getSeriesByIndex(1) === rangeModel;
|
|
49
|
+
expect(sameModel).to.equal(true);
|
|
50
|
+
const sameSource = el.chart.getModel().getSeriesByIndex(1).option.data === rangeData;
|
|
51
|
+
expect(sameSource).to.equal(true);
|
|
52
|
+
source.send('heartbeat',{server_time:f.server_time+1000,expires_at:f.expires_at});await el.updateComplete;
|
|
53
|
+
const sameHeartbeatSource = el.chart.getModel().getSeriesByIndex(1).option.data === rangeData;
|
|
54
|
+
expect(sameHeartbeatSource).to.equal(true);
|
|
55
|
+
source.send('snapshot',{...f,prediction:undefined,points:[]});await el.updateComplete;
|
|
56
|
+
expect(el.chart.getModel().getSeries().length).to.equal(1);
|
|
57
|
+
el.remove();expect(source.closed).to.equal(true);
|
|
58
|
+
});
|
|
59
|
+
it('keeps transitions enabled for a high-volume hour', async()=>{
|
|
60
|
+
const el:any=await fixture(html`<zn-chart-live-test type="line" xaxis="datetime" enable-animations="350" live stream-url="https://relay.example/hour"></zn-chart-live-test>`);
|
|
61
|
+
await el.updateComplete;await pause();
|
|
62
|
+
Source.instances.at(-1)!.send('snapshot',{
|
|
63
|
+
from:0,until:3600000,server_time:Date.now(),expires_at:Date.now()+60000,resolution_seconds:1,metric:'average',
|
|
64
|
+
points:Array.from({length:3600},(_,i)=>({at:i*1000,value:10+i%3})),
|
|
65
|
+
});
|
|
66
|
+
await el.updateComplete;
|
|
67
|
+
const model=el.chart.getModel().getSeriesByIndex(0);
|
|
68
|
+
expect(model.getData().count()).to.equal(3600);
|
|
69
|
+
expect(model.isAnimationEnabled()).to.equal(true);
|
|
70
|
+
expect(model.get('animationDuration')).to.equal(0);
|
|
71
|
+
el.remove();
|
|
72
|
+
});
|
|
73
|
+
it('restarts transport when its URL changes and when reattached',async()=>{
|
|
74
|
+
const el:any=await fixture(html`<zn-chart-live-test type="line" live stream-url="https://relay.example/a"></zn-chart-live-test>`);
|
|
75
|
+
await el.updateComplete;await pause();
|
|
76
|
+
const old=Source.instances.at(-1)!;
|
|
77
|
+
el.streamUrl='https://relay.example/b';await el.updateComplete;
|
|
78
|
+
expect(old.closed).to.equal(true);expect(Source.instances.at(-1)!.url).to.include('/b');
|
|
79
|
+
const parent=el.parentElement;el.remove();parent.append(el);await el.updateComplete;await pause();
|
|
80
|
+
expect(Source.instances.at(-1)!.closed).to.equal(false);el.remove();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
} from './builders';
|
|
19
19
|
import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
|
|
20
20
|
import { type EChartsModule, loadECharts } from './echarts-loader';
|
|
21
|
-
import {
|
|
21
|
+
import { LiveTransport, LiveWindow } from './live';
|
|
22
|
+
import { property, state } from 'lit/decorators.js';
|
|
22
23
|
import { ResizeController } from '@lit-labs/observers/resize-controller.js';
|
|
23
24
|
import styles from './chart.scss';
|
|
24
25
|
import ZincElement from '../../internal/zinc-element';
|
|
@@ -47,6 +48,9 @@ export default class ZnChart extends ZincElement {
|
|
|
47
48
|
|
|
48
49
|
@property({ type: Boolean }) live = false;
|
|
49
50
|
@property({ attribute: 'data-url' }) dataUrl = '';
|
|
51
|
+
@property({ attribute: 'stream-url' }) streamUrl = '';
|
|
52
|
+
@property({ attribute: 'live-renew-url' }) liveRenewUrl = '';
|
|
53
|
+
@property({ attribute: 'hide-zero', type: Boolean }) hideZero = false;
|
|
50
54
|
@property({ attribute: 'live-interval', type: Number }) liveInterval = 1000;
|
|
51
55
|
@property({ type: Number, reflect: true }) height = 300;
|
|
52
56
|
|
|
@@ -86,6 +90,80 @@ export default class ZnChart extends ZincElement {
|
|
|
86
90
|
private echarts?: EChartsModule;
|
|
87
91
|
private initPromise?: Promise<void>;
|
|
88
92
|
private liveTimer?: number;
|
|
93
|
+
private pollAbort?: AbortController;
|
|
94
|
+
@state() private liveStatus = 'connecting';
|
|
95
|
+
private liveWindow = new LiveWindow();
|
|
96
|
+
private replaceLiveSeries = true;
|
|
97
|
+
private transport = new LiveTransport((frame, snapshot) => {
|
|
98
|
+
const applied = this.liveWindow.apply(frame, snapshot);
|
|
99
|
+
this.replaceLiveSeries ||= snapshot || applied.prediction !== undefined;
|
|
100
|
+
this.data = this.liveSeries();
|
|
101
|
+
this.requestUpdate();
|
|
102
|
+
}, status => {
|
|
103
|
+
this.liveStatus = status;
|
|
104
|
+
this.dispatchEvent(new CustomEvent('zn-live-status', { detail: { status }, bubbles: true, composed: true }));
|
|
105
|
+
}, serverTime => {
|
|
106
|
+
this.liveWindow.advance(serverTime);
|
|
107
|
+
this.data = this.liveSeries();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
private liveTransitionDuration() {
|
|
111
|
+
if (this.enableAnimations === false || this.enableAnimations === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return 0;
|
|
112
|
+
return Math.max(0, Math.min(1000, typeof this.enableAnimations === 'number' ? this.enableAnimations : 350));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private liveSeries(): SeriesItem[] {
|
|
116
|
+
const duration = this.liveTransitionDuration();
|
|
117
|
+
return this.liveWindow.series(this.hideZero).map(series => ({ ...series,
|
|
118
|
+
animation: duration > 0, animationDuration: 0, animationDurationUpdate: duration,
|
|
119
|
+
animationEasingUpdate: 'linear', animationThreshold: 10000,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private updateChart() {
|
|
124
|
+
const option = this.buildOption();
|
|
125
|
+
if (this.liveWindow.frame && this.live && (this.streamUrl || this.liveRenewUrl)) {
|
|
126
|
+
const axis = Array.isArray(option.xAxis) ? option.xAxis[0] : option.xAxis;
|
|
127
|
+
option.xAxis = { ...axis, id: 'live-time', min: this.liveWindow.frame.from, max: this.liveWindow.frame.until };
|
|
128
|
+
const series = this.liveSeries();
|
|
129
|
+
option.legend = { ...option.legend, data: series.filter(s => !s.name.startsWith('_')).map(s => s.name) };
|
|
130
|
+
option.animation = this.liveTransitionDuration() > 0;
|
|
131
|
+
option.animationDuration = 0;
|
|
132
|
+
option.animationDurationUpdate = this.liveTransitionDuration();
|
|
133
|
+
option.animationThreshold = 10000;
|
|
134
|
+
option.animationEasingUpdate = 'linear';
|
|
135
|
+
}
|
|
136
|
+
if (this.liveWindow.frame && this.live && !this.replaceLiveSeries) {
|
|
137
|
+
// Keep the prediction's models and data in ECharts between snapshots.
|
|
138
|
+
// Only measurements and the moving viewport change on deltas/heartbeats.
|
|
139
|
+
const series = Array.isArray(option.series) ? option.series : option.series ? [option.series] : [];
|
|
140
|
+
option.series = series.filter(s => s?.id === 'live-values');
|
|
141
|
+
this.chart?.setOption({
|
|
142
|
+
series: option.series,
|
|
143
|
+
xAxis: { id: 'live-time', min: this.liveWindow.frame.from, max: this.liveWindow.frame.until },
|
|
144
|
+
animation: this.liveTransitionDuration() > 0,
|
|
145
|
+
animationDuration: 0,
|
|
146
|
+
animationDurationUpdate: this.liveTransitionDuration(),
|
|
147
|
+
animationEasingUpdate: 'linear',
|
|
148
|
+
animationThreshold: 10000,
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
this.chart?.setOption(option, { replaceMerge: ['series', 'legend', 'xAxis'] });
|
|
152
|
+
}
|
|
153
|
+
this.replaceLiveSeries = false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
protected updated(changed: PropertyValues) {
|
|
157
|
+
super.updated(changed);
|
|
158
|
+
if (!this.chart) return;
|
|
159
|
+
if (['live', 'dataUrl', 'streamUrl', 'liveRenewUrl', 'liveInterval'].some(key => changed.has(key))) this.startLive();
|
|
160
|
+
if (changed.size !== 1 || !changed.has('liveStatus')) this.updateChart();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
connectedCallback() {
|
|
164
|
+
super.connectedCallback();
|
|
165
|
+
if (this.hasUpdated && !this.chart) this.initPromise = this.initChart().catch(() => undefined);
|
|
166
|
+
}
|
|
89
167
|
|
|
90
168
|
private readonly resizeObserver = new ResizeController(this, {
|
|
91
169
|
target: null,
|
|
@@ -129,7 +207,7 @@ export default class ZnChart extends ZincElement {
|
|
|
129
207
|
private buildOption() {
|
|
130
208
|
const props: BuilderProps = {
|
|
131
209
|
type: this.type,
|
|
132
|
-
data: this.data ?? [],
|
|
210
|
+
data: this.liveWindow.frame && this.live ? this.liveSeries() : this.data ?? [],
|
|
133
211
|
categories: Array.isArray(this.categories) ? this.categories : [],
|
|
134
212
|
xAxisType: this.xAxis,
|
|
135
213
|
yAxisAppend: this.yAxisAppend,
|
|
@@ -187,23 +265,40 @@ export default class ZnChart extends ZincElement {
|
|
|
187
265
|
|
|
188
266
|
this.resizeObserver.observe(host);
|
|
189
267
|
|
|
190
|
-
|
|
268
|
+
this.startLive();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private stopLive() {
|
|
272
|
+
this.transport.stop();
|
|
273
|
+
if (this.liveTimer) clearTimeout(this.liveTimer);
|
|
274
|
+
this.liveTimer = undefined;
|
|
275
|
+
this.pollAbort?.abort();
|
|
191
276
|
}
|
|
192
277
|
|
|
193
278
|
private startLive() {
|
|
194
|
-
this.
|
|
279
|
+
this.stopLive(); this.liveWindow = new LiveWindow(); this.replaceLiveSeries = true;
|
|
280
|
+
if (!this.live || !this.isConnected) return;
|
|
281
|
+
if (this.streamUrl || this.liveRenewUrl) {
|
|
282
|
+
try { this.transport.start(this.streamUrl, this.liveRenewUrl); } catch { this.stopLive(); }
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (!this.dataUrl) return;
|
|
286
|
+
const controller = new AbortController(); this.pollAbort = controller;
|
|
287
|
+
const poll = async () => {
|
|
195
288
|
try {
|
|
196
|
-
const res = await fetch(this.dataUrl);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
289
|
+
const res = await fetch(this.dataUrl, { signal: controller.signal });
|
|
290
|
+
if (res.ok) {
|
|
291
|
+
const data = await res.json() as SeriesItem[];
|
|
292
|
+
if (!controller.signal.aborted) { this.data = data; }
|
|
293
|
+
}
|
|
294
|
+
} catch { /* transient polling failure */ }
|
|
295
|
+
if (!controller.signal.aborted) this.liveTimer = window.setTimeout(() => void poll(), Math.max(250, this.liveInterval));
|
|
296
|
+
};
|
|
297
|
+
this.liveTimer = window.setTimeout(() => void poll(), Math.max(250, this.liveInterval));
|
|
204
298
|
}
|
|
205
299
|
|
|
206
300
|
private reinit() {
|
|
301
|
+
this.stopLive();
|
|
207
302
|
this.chart?.dispose();
|
|
208
303
|
this.chart = undefined;
|
|
209
304
|
this.initPromise = this.initChart().catch(() => undefined);
|
|
@@ -235,12 +330,14 @@ export default class ZnChart extends ZincElement {
|
|
|
235
330
|
|
|
236
331
|
disconnectedCallback() {
|
|
237
332
|
super.disconnectedCallback();
|
|
238
|
-
|
|
333
|
+
this.stopLive();
|
|
239
334
|
this.chart?.dispose();
|
|
240
335
|
this.chart = undefined;
|
|
241
336
|
}
|
|
242
337
|
|
|
243
338
|
protected render(): unknown {
|
|
244
|
-
return html`<div class="chart"><div id="chart"></div
|
|
339
|
+
return html`<div class="chart"><div id="chart"></div>
|
|
340
|
+
${this.live && (this.streamUrl || this.liveRenewUrl) ? html`<div role="status" aria-live="polite">Live: ${this.liveStatus}${this.liveWindow.frame ? ` · ${this.liveWindow.frame.resolution_seconds}s intervals · Expected range: ${this.liveWindow.predictionStatus}` : ''}</div>` : ''}
|
|
341
|
+
</div>`;
|
|
245
342
|
}
|
|
246
343
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/require-await, @typescript-eslint/no-empty-function, no-return-assign */
|
|
2
|
+
import { expect } from '@open-wc/testing';
|
|
3
|
+
import { type LiveFrame, LiveTransport, LiveWindow } from './live';
|
|
4
|
+
|
|
5
|
+
const frame = (points: LiveFrame['points'], from = 0): LiveFrame => ({ points, from, until: from + 10000, server_time: Date.now(), expires_at: Date.now()+60000, resolution_seconds: 1, metric: 'hits' });
|
|
6
|
+
const sleep = (ms = 20) => new Promise(resolve => setTimeout(resolve,ms));
|
|
7
|
+
class MockSource extends EventTarget {
|
|
8
|
+
static sources: MockSource[] = [];
|
|
9
|
+
onopen?: () => void; onerror?: () => void; closed = false;
|
|
10
|
+
url: string;
|
|
11
|
+
constructor(url: string) { super(); this.url = url; MockSource.sources.push(this); }
|
|
12
|
+
close() { this.closed = true; }
|
|
13
|
+
send(event: string, data: unknown) { this.dispatchEvent(new MessageEvent(event,{data: JSON.stringify(data)})); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('chart live window', () => {
|
|
17
|
+
it('replaces duplicate buckets, sorts, prunes, and reconnect snapshots clear old values', () => {
|
|
18
|
+
const w = new LiveWindow();
|
|
19
|
+
w.apply(frame([{at:1000,value:1},{at:2000,value:2}]),true);
|
|
20
|
+
w.apply(frame([{at:3000,value:0},{at:2000,value:5}],2000),false);
|
|
21
|
+
const data = w.series()[0].data;
|
|
22
|
+
expect(data.map(p=>p.value)).to.deep.equal([[2000,5],[3000,0]]);
|
|
23
|
+
expect(w.series(true)[0].data[1].value).to.deep.equal([3000,null]);
|
|
24
|
+
expect(w.series()[0].data[1].value).to.deep.equal([3000,0]);
|
|
25
|
+
w.apply(frame([{at:5000,value:8}],2000),true);
|
|
26
|
+
expect(w.series()[0].data.length).to.equal(1);
|
|
27
|
+
});
|
|
28
|
+
it('preserves optional ranges across deltas and clears stale ranges on a snapshot', () => {
|
|
29
|
+
const w = new LiveWindow(); const f=frame([{at:1000,value:8}]);
|
|
30
|
+
f.prediction={status:'ready',average_rates:true,points:[{at:0,expected:2,lower:-1,upper:5,available:true}]};
|
|
31
|
+
w.apply(f,true); w.apply(frame([{at:2000,value:9}]),false);
|
|
32
|
+
expect(w.series().length).to.equal(3);
|
|
33
|
+
expect(w.series()[1].name).to.include('average rate');
|
|
34
|
+
expect(w.series()[0].stackStrategy).to.equal('all');
|
|
35
|
+
w.apply(frame([]),true); expect(w.series().length).to.equal(1);
|
|
36
|
+
});
|
|
37
|
+
it('advances the viewport on heartbeats without inventing readings', () => {
|
|
38
|
+
const w = new LiveWindow(); const f = frame([{at:1000,value:1},{at:3000,value:2}]);
|
|
39
|
+
w.apply(f,true);w.advance(f.server_time+2500);
|
|
40
|
+
expect(w.frame!.from).to.equal(2500);
|
|
41
|
+
expect(w.series()[0].data.map(p=>p.value)).to.deep.equal([[3000,2]]);
|
|
42
|
+
});
|
|
43
|
+
it('rejects invalid frames and resolution changes in a delta', () => {
|
|
44
|
+
const w=new LiveWindow();
|
|
45
|
+
expect(()=>w.apply({},true)).to.throw();
|
|
46
|
+
w.apply(frame([]),true);
|
|
47
|
+
expect(()=>w.apply({...frame([]),resolution_seconds:60},false)).to.throw();
|
|
48
|
+
expect(()=>w.apply(frame([{at:1,value:NaN}]),false)).to.throw();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('chart SSE transport', () => {
|
|
53
|
+
const originalSource=window.EventSource; const originalFetch=window.fetch;
|
|
54
|
+
beforeEach(()=>{MockSource.sources=[]; window.EventSource=MockSource as unknown as typeof EventSource;});
|
|
55
|
+
afterEach(()=>{window.EventSource=originalSource;window.fetch=originalFetch;});
|
|
56
|
+
it('opens a renewed URL and closes connections on disconnect', async()=>{
|
|
57
|
+
let requests=0; const events: boolean[]=[];
|
|
58
|
+
window.fetch=async (_url,options)=>{
|
|
59
|
+
requests++; expect(options?.credentials).to.equal('same-origin');expect(options?.redirect).to.equal('error');
|
|
60
|
+
return new Response(JSON.stringify({stream_url:'https://relay.example/events',expires_at:Date.now()+60000,server_time:Date.now()}));
|
|
61
|
+
};
|
|
62
|
+
const t=new LiveTransport((_data,snapshot)=>events.push(snapshot),()=>{});
|
|
63
|
+
t.start('', '/grant'); await sleep();
|
|
64
|
+
expect(requests).to.equal(1); expect(MockSource.sources.length).to.equal(1);
|
|
65
|
+
const source=MockSource.sources[0];source.send('snapshot',frame([]));source.send('delta',frame([]));
|
|
66
|
+
expect(events).to.deep.equal([true,false]);
|
|
67
|
+
t.stop();expect(source.closed).to.equal(true);
|
|
68
|
+
});
|
|
69
|
+
it('stops at the grant deadline without a renewal route',async()=>{
|
|
70
|
+
const statuses: string[]=[]; const t=new LiveTransport(()=>{},s=>statuses.push(s));
|
|
71
|
+
t.start('https://relay.example/events',''); const source=MockSource.sources[0];
|
|
72
|
+
source.send('heartbeat',{server_time:1000,expires_at:1025}); await sleep(50);
|
|
73
|
+
expect(source.closed).to.equal(true);expect(statuses).to.include('expired');t.stop();
|
|
74
|
+
});
|
|
75
|
+
it('rejects cross-origin renewal and stops immediately on an authentication failure',async()=>{
|
|
76
|
+
const statuses: string[]=[];const t=new LiveTransport(()=>{},s=>statuses.push(s));
|
|
77
|
+
t.start('','https://evil.example/grant');expect(statuses).to.include('denied');expect(MockSource.sources.length).to.equal(0);
|
|
78
|
+
window.fetch=async ()=>new Response('',{status:403});t.start('','/grant');await sleep();
|
|
79
|
+
expect(statuses.filter(s=>s==='denied').length).to.equal(2);t.stop();
|
|
80
|
+
});
|
|
81
|
+
it('ignores an outstanding renewal response after stopping',async()=>{
|
|
82
|
+
let resolve!: (r:Response)=>void;
|
|
83
|
+
window.fetch=()=>new Promise(r=>resolve=r);
|
|
84
|
+
const t=new LiveTransport(()=>{},()=>{});t.start('','/grant');t.stop();
|
|
85
|
+
resolve(new Response(JSON.stringify({stream_url:'https://relay.example/events',expires_at:Date.now()+60000,server_time:Date.now()})));await sleep();
|
|
86
|
+
expect(MockSource.sources.length).to.equal(0);
|
|
87
|
+
});
|
|
88
|
+
it('does not overlap renewals while heartbeats arrive',async()=>{
|
|
89
|
+
let count=0;let resolve!: (r:Response)=>void;
|
|
90
|
+
window.fetch=()=>{count++;return new Promise(r=>resolve=r);};
|
|
91
|
+
const t=new LiveTransport(()=>{},()=>{});t.start('https://relay.example/events','/grant');
|
|
92
|
+
const source=MockSource.sources[0];source.send('heartbeat',{server_time:1000,expires_at:11000});await sleep();
|
|
93
|
+
source.send('heartbeat',{server_time:2000,expires_at:11000});await sleep();expect(count).to.equal(1);
|
|
94
|
+
t.stop();resolve(new Response('{}'));await sleep();
|
|
95
|
+
});
|
|
96
|
+
});
|