@kubex/zinc 1.1.180 → 1.1.182

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.
@@ -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.
@@ -52,6 +52,53 @@ layout: component
52
52
  </zn-content-block>
53
53
  ```
54
54
 
55
+ ### Editor Formatting
56
+
57
+ Text that holds escaped HTML, such as a note saved from `zn-editor`, is sanitized and rendered with the
58
+ editor's formatting. Only the text and background colour survive from a `style` attribute.
59
+
60
+ ```html:preview
61
+
62
+ <zn-content-block
63
+ time="16 Jun 2025, 13:00"
64
+ sender="John Smith"
65
+ avatar="JS"
66
+ >
67
+ <div slot="text">
68
+ &lt;p&gt;&lt;strong&gt;Bold&lt;/strong&gt;&lt;/p&gt;
69
+ &lt;p&gt;&lt;em&gt;Italic&lt;/em&gt;&lt;/p&gt;
70
+ &lt;p&gt;&lt;u&gt;Underline&lt;/u&gt;&lt;/p&gt;
71
+ &lt;p&gt;&lt;s&gt;Strikethrough&lt;/s&gt;&lt;/p&gt;
72
+ &lt;blockquote&gt;Blockquote&lt;/blockquote&gt;
73
+ &lt;p&gt;&lt;code&gt;Inline code&lt;/code&gt;&lt;/p&gt;
74
+ &lt;pre data-language=plain&gt;Code block&lt;/pre&gt;
75
+ &lt;p&gt;&lt;span style=color:red&gt;Coloured text&lt;/span&gt;&lt;/p&gt;
76
+ &lt;ul&gt;&lt;li&gt;Bullet&lt;/li&gt;&lt;li&gt;Bullet&lt;ul&gt;&lt;li&gt;Nested bullet&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;/ul&gt;
77
+ &lt;ol&gt;&lt;li&gt;Numbered&lt;ol&gt;&lt;li&gt;Nested numbered&lt;/li&gt;&lt;li&gt;Nested numbered&lt;/li&gt;&lt;/ol&gt;&lt;/li&gt;&lt;/ol&gt;
78
+ &lt;ul&gt;&lt;li data-list=checked&gt;Checked&lt;/li&gt;&lt;li data-list=unchecked&gt;Unchecked&lt;/li&gt;&lt;/ul&gt;
79
+ &lt;hr class=ql-hr&gt;
80
+ &lt;p&gt;Divider above&lt;/p&gt;
81
+ &lt;p&gt;&lt;a href=https://example.com&gt;Link&lt;/a&gt;&lt;/p&gt;
82
+ </div>
83
+ <div slot="html">
84
+ <p><strong>Bold</strong></p>
85
+ <p><em>Italic</em></p>
86
+ <p><u>Underline</u></p>
87
+ <p><s>Strikethrough</s></p>
88
+ <blockquote>Blockquote</blockquote>
89
+ <p><code>Inline code</code></p>
90
+ <pre data-language="plain">Code block</pre>
91
+ <p><span style="color:red">Coloured text</span></p>
92
+ <ul><li>Bullet</li><li>Bullet<ul><li>Nested bullet</li></ul></li></ul>
93
+ <ol><li>Numbered<ol><li>Nested numbered</li><li>Nested numbered</li></ol></li></ol>
94
+ <ul><li data-list="checked">Checked</li><li data-list="unchecked">Unchecked</li></ul>
95
+ <hr class="ql-hr">
96
+ <p>Divider above</p>
97
+ <p><a href="https://example.com">Link</a></p>
98
+ </div>
99
+ </zn-content-block>
100
+ ```
101
+
55
102
  ### Attachments
56
103
 
57
104
  Attachments render in a row at the bottom of the content and are only visible while the block
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.180",
3
+ "version": "1.1.182",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -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 { property } from 'lit/decorators.js';
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
- if (this.live && this.dataUrl) this.startLive();
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.liveTimer = window.setInterval(async () => {
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
- const newData = await res.json() as SeriesItem[];
198
- this.data = newData;
199
- this.chart?.setOption({ ...this.buildOption() });
200
- } catch {
201
- /* swallow transient fetch errors */
202
- }
203
- }, this.liveInterval);
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
- if (this.liveTimer) clearInterval(this.liveTimer);
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></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
+ });