@kubex/zinc 1.1.180 → 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.
@@ -0,0 +1,164 @@
1
+ import type { SeriesItem } from './builders';
2
+
3
+ export interface LivePoint { at: number; value: number | null }
4
+ export interface LivePrediction {
5
+ status: string; average_rates?: boolean;
6
+ points: { at: number; expected: number; lower: number; upper: number; available: boolean }[];
7
+ }
8
+ export interface LiveFrame {
9
+ from: number; until: number; server_time: number; expires_at: number;
10
+ resolution_seconds: number; metric: string; points: LivePoint[]; prediction?: LivePrediction;
11
+ }
12
+ export interface LiveGrant { stream_url: string; expires_at: number; server_time: number }
13
+
14
+ const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n);
15
+
16
+ /** Authoritative bucket replacement, never increment counts on delivery/reconnect. */
17
+ export class LiveWindow {
18
+ frame?: LiveFrame;
19
+ private points = new Map<number, number | null>();
20
+ private prediction?: LivePrediction;
21
+
22
+ apply(value: unknown, snapshot: boolean): LiveFrame {
23
+ const f = value as LiveFrame;
24
+ if (!f || !finite(f.from) || !finite(f.until) || f.until <= f.from || !finite(f.server_time) ||
25
+ !finite(f.expires_at) || !finite(f.resolution_seconds) || f.resolution_seconds <= 0 ||
26
+ !['hits', 'sum', 'average'].includes(f.metric) || !Array.isArray(f.points) || f.points.length > 10000 ||
27
+ f.points.some(p => !p || !finite(p.at) || !(p.value === null || finite(p.value)))) throw new Error('Invalid chart frame');
28
+ if (!snapshot && (!this.frame || this.frame.resolution_seconds !== f.resolution_seconds || this.frame.metric !== f.metric)) throw new Error('Snapshot required');
29
+ if (f.prediction && (!Array.isArray(f.prediction.points) || f.prediction.points.length > 10000 ||
30
+ f.prediction.points.some(p => !p || !finite(p.at) || !finite(p.lower) || !finite(p.upper) || !finite(p.expected) || p.upper < p.lower))) throw new Error('Invalid prediction');
31
+ if (snapshot) { this.points.clear(); this.prediction = f.prediction; }
32
+ else if (f.prediction) this.prediction = f.prediction;
33
+ const start = Math.floor(f.from / (f.resolution_seconds * 1000)) * f.resolution_seconds * 1000;
34
+ for (const p of f.points) if (p.at >= start && p.at < f.until) this.points.set(p.at, p.value);
35
+ for (const at of this.points.keys()) if (at < start || at >= f.until) this.points.delete(at);
36
+ // Never keep an unbounded history, including malformed streams with many timestamps.
37
+ if (this.points.size > 10000) {
38
+ const ordered = [...this.points.keys()].sort((a, b) => a - b);
39
+ for (const at of ordered.slice(0, ordered.length - 10000)) this.points.delete(at);
40
+ }
41
+ this.frame = f;
42
+ return f;
43
+ }
44
+
45
+ advance(serverTime: number) {
46
+ if (!this.frame || !finite(serverTime) || serverTime <= this.frame.server_time) return;
47
+ const delta = serverTime - this.frame.server_time;
48
+ this.frame = { ...this.frame, server_time: serverTime, from: this.frame.from + delta, until: this.frame.until + delta };
49
+ const start = Math.floor(this.frame.from / (this.frame.resolution_seconds * 1000)) * this.frame.resolution_seconds * 1000;
50
+ for (const at of this.points.keys()) if (at < start) this.points.delete(at);
51
+ }
52
+
53
+ get predictionStatus() { return this.prediction?.status ?? 'unavailable'; }
54
+
55
+ series(hideZero = false): SeriesItem[] {
56
+ const points = [...this.points].sort((a,b) => a[0]-b[0]);
57
+ const series: SeriesItem[] = [];
58
+ if (this.prediction && this.frame) {
59
+ const ps = this.prediction.points.filter(p => p.at < this.frame!.until && p.at >= this.frame!.from - 86400000);
60
+ const name = this.prediction.average_rates ? 'Expected range (average rate)' : 'Expected range';
61
+ const data = (pick: (p: typeof ps[number]) => number) => ps.map(p => ({ name: String(p.at), value: [p.at, p.available ? pick(p) : null] }));
62
+ if (ps.some(p => p.available)) {
63
+ series.push({ id: 'live-range-base', animation: false, name: '_range_base', data: data(p => p.lower), stack: 'expected', stackStrategy: 'all', symbol: 'none', silent: true,
64
+ lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 }, z: 1 });
65
+ series.push({ id: 'live-range', animation: false, name, data: data(p => p.upper - p.lower), stack: 'expected', stackStrategy: 'all', symbol: 'none', silent: true,
66
+ color: '#93c5fd', lineStyle: { opacity: 0 }, areaStyle: { opacity: 0.3 }, z: 1 });
67
+ }
68
+ }
69
+ series.push({ id: 'live-values', animation: false, name: this.frame?.metric ?? 'Values', data: points.map(([at, value]) => ({ name: String(at), value: [at, hideZero && value === 0 ? null : value] })), symbol: 'none', z: 3 });
70
+ return series;
71
+ }
72
+ }
73
+
74
+ /** One transport per chart. Grants renew only through a same-origin login gate. */
75
+ export class LiveTransport {
76
+ private source?: EventSource;
77
+ private timer?: ReturnType<typeof setTimeout>;
78
+ private expiryTimer?: ReturnType<typeof setTimeout>;
79
+ private abort?: AbortController;
80
+ private stopped = true;
81
+ private generation = 0;
82
+ private retry = 0;
83
+ private renewing = false;
84
+ private readonly frame: (data: unknown, snapshot: boolean) => void;
85
+ private readonly status: (status: string) => void;
86
+ private readonly heartbeat?: (serverTime: number) => void;
87
+ constructor(frame: (data: unknown, snapshot: boolean) => void, status: (status: string) => void,
88
+ heartbeat?: (serverTime: number) => void) {
89
+ this.frame = frame; this.status = status; this.heartbeat = heartbeat;
90
+ }
91
+
92
+ start(streamURL: string, renewURL: string) {
93
+ this.stop(); this.stopped = false;
94
+ const generation = this.generation;
95
+ if (streamURL) {
96
+ this.deadline(Date.now() + 60000, Date.now(), renewURL, generation);
97
+ this.connect(streamURL, renewURL, generation);
98
+ }
99
+ else if (renewURL) void this.renew(renewURL, generation);
100
+ }
101
+ stop() {
102
+ this.stopped = true; this.generation++;
103
+ this.source?.close(); this.source = undefined;
104
+ clearTimeout(this.timer); clearTimeout(this.expiryTimer); this.abort?.abort();
105
+ this.retry = 0; this.renewing = false;
106
+ }
107
+ private active(generation: number) { return !this.stopped && generation === this.generation; }
108
+ private deadline(expires: number, serverTime: number, renewURL: string, generation: number) {
109
+ if (!finite(expires) || !finite(serverTime)) throw new Error('Invalid grant expiry');
110
+ const remaining = expires - serverTime;
111
+ clearTimeout(this.expiryTimer); clearTimeout(this.timer);
112
+ this.expiryTimer = setTimeout(() => {
113
+ if (!this.active(generation)) return;
114
+ this.source?.close(); this.source = undefined; this.status('expired');
115
+ }, Math.max(0, remaining));
116
+ if (renewURL && !this.renewing) this.timer = setTimeout(() => void this.renew(renewURL, generation), Math.max(0, remaining - 10000));
117
+ }
118
+ private connect(streamURL: string, renewURL: string, generation: number) {
119
+ const url = new URL(streamURL, location.href);
120
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Invalid stream URL');
121
+ this.source?.close();
122
+ const source = new EventSource(url.href); // No cross-origin cookies or app credentials.
123
+ this.source = source;
124
+ this.status('connecting');
125
+ source.onopen = () => { if (this.active(generation)) this.status('connected'); };
126
+ for (const event of ['snapshot', 'delta', 'heartbeat']) source.addEventListener(event, (message: MessageEvent<string>) => {
127
+ if (!this.active(generation) || this.source !== source) return;
128
+ try {
129
+ const f = JSON.parse(message.data) as LiveFrame;
130
+ if (event !== 'heartbeat') this.frame(f, event === 'snapshot');
131
+ else this.heartbeat?.(f.server_time);
132
+ this.deadline(f.expires_at, f.server_time, renewURL, generation);
133
+ this.status('connected');
134
+ } catch { this.stop(); this.status('invalid'); }
135
+ });
136
+ source.onerror = () => { if (this.active(generation) && this.source === source) this.status('reconnecting'); };
137
+ }
138
+ private async renew(renewURL: string, generation: number) {
139
+ if (!this.active(generation) || this.renewing) return;
140
+ const url = new URL(renewURL, location.href);
141
+ if (url.origin !== location.origin || url.username || url.password) { this.stop(); this.status('denied'); return; }
142
+ this.renewing = true;
143
+ const controller = new AbortController(); this.abort = controller;
144
+ const timeout = setTimeout(() => controller.abort(), 8000);
145
+ try {
146
+ const response = await fetch(url.href, { credentials: 'same-origin', redirect: 'error', cache: 'no-store', signal: controller.signal, headers: { Accept: 'application/json', 'x-kx-fetch-style': 'raw-json' } });
147
+ if (!this.active(generation)) return;
148
+ if ([401,403].includes(response.status)) { this.stop(); this.status('denied'); return; }
149
+ if (!response.ok) throw new Error('Grant unavailable');
150
+ const grant = await response.json() as LiveGrant;
151
+ if (!this.active(generation)) return;
152
+ if (!finite(grant.expires_at) || !finite(grant.server_time) || grant.expires_at <= grant.server_time || typeof grant.stream_url !== 'string') throw new Error('Invalid grant');
153
+ this.renewing = false;
154
+ this.deadline(grant.expires_at, grant.server_time, renewURL, generation);
155
+ this.connect(grant.stream_url, renewURL, generation); this.retry = 0;
156
+ } catch {
157
+ if (this.active(generation)) {
158
+ if (this.retry >= 4) { this.stop(); this.status('unavailable'); return; }
159
+ this.status('reconnecting');
160
+ this.timer = setTimeout(() => void this.renew(renewURL, generation), Math.min(10000, 1000 * 2 ** Math.min(this.retry++, 4)));
161
+ }
162
+ } finally { clearTimeout(timeout); if (this.active(generation)) this.renewing = false; }
163
+ }
164
+ }
@@ -69,6 +69,11 @@
69
69
  gap: 0;
70
70
  }
71
71
 
72
+ // Without a gap to sit in, the divider goes on the column's own edge.
73
+ &--no-gap.cols--divide ::slotted(*:not(:last-child)):after {
74
+ right: 0;
75
+ }
76
+
72
77
  // The row rule below each child is drawn by a pseudo reaching to -100vw, so border mode has to clip. Clipping
73
78
  // every layout would cut the focus ring off any control sitting flush against a column edge.
74
79
  &--border {
@@ -146,6 +151,17 @@ $stack-sizes: (sm, smp, ph, md, lg, hd, '3k', '4k');
146
151
  .cols--divide ::slotted(*:not(:last-child)):after {
147
152
  display: none;
148
153
  }
154
+
155
+ // Edge-to-edge columns keep their separation as a rule between the stacked rows
156
+ .cols--no-gap.cols--divide ::slotted(*:not(:last-child)):after {
157
+ display: block;
158
+ top: auto;
159
+ right: 0;
160
+ bottom: 0;
161
+ left: 0;
162
+ width: auto;
163
+ height: 1px;
164
+ }
149
165
  }
150
166
  }
151
167
  }
@@ -1,26 +1,19 @@
1
1
  import {classMap} from "lit/directives/class-map.js";
2
2
  import {type CSSResultGroup, html, unsafeCSS} from 'lit';
3
+ import {ifDefined} from "lit/directives/if-defined.js";
3
4
  import {property} from 'lit/decorators.js';
4
5
  import ZincElement from '../../internal/zinc-element';
6
+ import type {IconColor} from '../icon';
5
7
 
6
8
  import styles from './stat.scss';
7
9
 
8
10
  /**
9
- * @summary Short summary of the component's intended use.
11
+ * @summary Displays a key metric, optionally compared with a previous value.
10
12
  * @documentation https://zinc.style/components/stats-tile
11
13
  * @status experimental
12
14
  * @since 1.0
13
15
  *
14
- * @dependency zn-example
15
- *
16
- * @event zn-event-name - Emitted as an example.
17
- *
18
- * @slot - The default slot.
19
- * @slot example - An example slot.
20
- *
21
- * @csspart base - The component's base wrapper.
22
- *
23
- * @cssproperty --example - An example CSS custom property.
16
+ * @dependency zn-icon
24
17
  */
25
18
  export default class ZnStatsTile extends ZincElement {
26
19
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -34,6 +27,25 @@ export default class ZnStatsTile extends ZincElement {
34
27
  @property({type: Boolean, attribute: 'show-delta'}) showDelta = false;
35
28
  @property() color: 'primary' | 'error' | 'info' | 'warning' | 'success' | 'neutral' = 'primary';
36
29
 
30
+ /** `value-first` puts the amount under the caption with the description beneath it, and tints the amount rather
31
+ * than the description with `color`. */
32
+ @property({reflect: true}) layout: 'default' | 'value-first' = 'default';
33
+
34
+ /** How `show-delta` compares with `previous`: the relative change, or the difference in points. */
35
+ @property({attribute: 'delta-format'}) deltaFormat: 'percent' | 'points' = 'percent';
36
+
37
+ /** An icon shown beside the amount. */
38
+ @property() icon = '';
39
+
40
+ /** The icon's color; without one it takes the amount's. */
41
+ @property({attribute: 'icon-color'}) iconColor: IconColor;
42
+
43
+ /** `large` gives the amount more weight, for a summary row of a few headline figures. */
44
+ @property({reflect: true}) size: 'medium' | 'large' = 'medium';
45
+
46
+ /** Pads the stat, for stats laid edge to edge in a flush container such as `zn-cols no-gap divide`. */
47
+ @property({type: Boolean}) padded = false;
48
+
37
49
  calcPercentageDifference() {
38
50
  const previous = parseFloat(this.previous);
39
51
  const amount = parseFloat(this.amount);
@@ -93,16 +105,73 @@ export default class ZnStatsTile extends ZincElement {
93
105
  diffIcon = '-';
94
106
  }
95
107
 
108
+ if (this.deltaFormat === 'points') {
109
+ return html`<p class="diff">${this.pointsDelta()} on Previous Period</p>`;
110
+ }
111
+
96
112
  return html`<p class="diff"><span class="${diffClass}">${diffIcon}${diff}%</span> on Previous Period</p>`;
97
113
  }
98
114
 
99
115
  return null;
100
116
  }
101
117
 
118
+ private pointsDelta() {
119
+ const diff = Math.round((parseFloat(this.amount) - parseFloat(this.previous)) * 10) / 10;
120
+ if (diff > 0) {
121
+ return html`<span class="positive">+${diff} pts</span>`;
122
+ }
123
+ if (diff < 0) {
124
+ return html`<span class="negative">-${Math.abs(diff)} pts</span>`;
125
+ }
126
+ return html`<span class="neutral">No change</span>`;
127
+ }
128
+
129
+ private renderAmount() {
130
+ return html`${this.getDisplayAmount()}${this.icon ? html`
131
+ <zn-icon class="icon" src="${this.icon}" size="20" color="${ifDefined(this.iconColor)}"></zn-icon>` : null}`;
132
+ }
133
+
134
+ private renderValueFirst() {
135
+ const delta = this.showDelta
136
+ ? (this.deltaFormat === 'points' ? this.pointsDelta() : this.percentDelta())
137
+ : null;
138
+
139
+ return html`
140
+ <div class="${classMap({
141
+ 'stat-tile': true,
142
+ 'stat-tile--value-first': true,
143
+ 'stat-tile--large': this.size === 'large',
144
+ 'stat-tile--padded': this.padded,
145
+ [`stat-tile--${this.color}`]: true
146
+ })}">
147
+ <h3 class="caption">${this.caption}</h3>
148
+ <p class="amount">${this.renderAmount()}${delta ? html`<span class="diff">${delta}</span>` : null}</p>
149
+ ${this.description ? html`<p class="description">${this.description}</p>` : null}
150
+ </div>
151
+ `;
152
+ }
153
+
154
+ private percentDelta() {
155
+ const diff = this.calcPercentageDifference();
156
+ if (diff > 0) {
157
+ return html`<span class="positive">+${diff}%</span>`;
158
+ }
159
+ if (diff < 0) {
160
+ return html`<span class="negative">${diff}%</span>`;
161
+ }
162
+ return html`<span class="neutral">No change</span>`;
163
+ }
164
+
102
165
  render() {
166
+ if (this.layout === 'value-first') {
167
+ return this.renderValueFirst();
168
+ }
169
+
103
170
  return html`
104
171
  <div class="${classMap({
105
172
  'stat-tile': true,
173
+ 'stat-tile--large': this.size === 'large',
174
+ 'stat-tile--padded': this.padded,
106
175
  [`stat-tile--${this.color}`]: true
107
176
  })}">
108
177
  <div class="container">
@@ -111,7 +180,7 @@ export default class ZnStatsTile extends ZincElement {
111
180
  <h3 class="caption">${this.caption}</h3>
112
181
  <p class="description">${this.description}</p>
113
182
  </div>
114
- <p class="amount">${this.getDisplayAmount()}</p>
183
+ <p class="amount">${this.renderAmount()}</p>
115
184
  <p class="bottom">${this.diffText()}</p>
116
185
  </div>
117
186
  </div>
@@ -5,6 +5,11 @@
5
5
  display: contents;
6
6
  }
7
7
 
8
+ // A box of its own lets zn-cols draw its dividers between value-first stats.
9
+ :host([layout="value-first"]) {
10
+ display: block;
11
+ }
12
+
8
13
  .stat-tile {
9
14
 
10
15
  .caption, .description, .amount, .bottom {
@@ -68,6 +73,55 @@
68
73
  }
69
74
  }
70
75
 
76
+ .icon {
77
+ margin-left: var(--zn-spacing-x-small);
78
+ }
79
+
80
+ &--value-first {
81
+ display: flex;
82
+ flex-direction: column;
83
+ gap: var(--zn-spacing-2x-small);
84
+ color: var(--zn-text-chart-major-color);
85
+
86
+ .caption {
87
+ margin-bottom: 0;
88
+ }
89
+
90
+ .amount {
91
+ flex-grow: 0;
92
+ gap: var(--zn-spacing-x-small);
93
+ color: inherit;
94
+ }
95
+
96
+ .diff {
97
+ font-size: var(--zn-font-size-small);
98
+ font-weight: 400;
99
+ }
100
+
101
+ .diff .neutral,
102
+ .description {
103
+ color: rgba(var(--zn-color-text), 0.6);
104
+ }
105
+
106
+ .description {
107
+ font-size: var(--zn-font-size-x-small);
108
+ }
109
+
110
+ .icon {
111
+ margin-left: 0;
112
+ }
113
+ }
114
+
115
+ &--padded {
116
+ padding: var(--zn-spacing-large);
117
+ }
118
+
119
+ &--large .amount {
120
+ font-size: var(--zn-font-size-3x-large);
121
+ font-weight: var(--zn-font-weight-semibold);
122
+ line-height: 1.15;
123
+ }
124
+
71
125
  &--chart {
72
126
  .amount {
73
127
  padding-top: var(--zn-spacing-small);
@@ -102,6 +156,10 @@
102
156
  color: rgb(var(--zn-text));
103
157
  }
104
158
 
159
+ &--value-first.stat-tile--neutral {
160
+ color: var(--zn-text-chart-major-color);
161
+ }
162
+
105
163
  @container (max-width:550px) {
106
164
  ::slotted(.zn-col-1) {
107
165
  flex-basis: 170px;
@@ -112,5 +170,11 @@
112
170
  line-height: 20px;
113
171
  padding-top: 5px;
114
172
  }
173
+
174
+ &--large .amount {
175
+ font-size: var(--zn-font-size-2x-large);
176
+ line-height: 1.15;
177
+ padding-top: 0;
178
+ }
115
179
  }
116
180
  }
@@ -53,6 +53,9 @@ export default class ZnTile extends ZincElement {
53
53
  /** Renders the caption in the normal table-content weight instead of bold. */
54
54
  @property({type: Boolean}) plain: boolean;
55
55
 
56
+ /** Moves the actions under the caption and description when the tile is narrower than 600px. */
57
+ @property({attribute: 'stack-actions', type: Boolean, reflect: true}) stackActions: boolean;
58
+
56
59
  /** Set by `zn-tile-group` to lay the tile out as a shared-column subgrid row. */
57
60
  @property({type: Boolean, reflect: true}) grouped: boolean;
58
61
 
@@ -124,6 +124,11 @@ a {
124
124
  text-overflow: ellipsis;
125
125
  }
126
126
 
127
+ // A slot has no box of its own, so the slotted description would overflow rather than truncate
128
+ slot.tile__description {
129
+ display: block;
130
+ }
131
+
127
132
  .tile--inline .tile__description {
128
133
  margin: 0;
129
134
  }
@@ -190,3 +195,28 @@ a {
190
195
  min-width: 0;
191
196
  overflow: hidden;
192
197
  }
198
+
199
+ :host([stack-actions]) {
200
+ container-type: inline-size;
201
+ }
202
+
203
+ :host([stack-actions]) .tile {
204
+ @include wc.container-query(null, ph) {
205
+ flex-wrap: wrap;
206
+ row-gap: var(--zn-spacing-x-small);
207
+
208
+ .tile__link {
209
+ flex-basis: 100%;
210
+ }
211
+
212
+ .tile__right {
213
+ justify-content: flex-start;
214
+ flex-wrap: wrap;
215
+ }
216
+
217
+ .tile__actions {
218
+ flex-wrap: wrap;
219
+ row-gap: var(--zn-spacing-x-small);
220
+ }
221
+ }
222
+ }