@zumer/orbit-kit 0.1.0 → 2.0.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 +108 -316
- package/package.json +20 -8
- package/src/base.js +564 -41
- package/src/color.js +139 -0
- package/src/o-clock.js +106 -129
- package/src/o-color-wheel.js +322 -0
- package/src/o-compass.js +45 -49
- package/src/o-donut.js +67 -79
- package/src/o-gauge.js +51 -117
- package/src/o-instrument.js +116 -105
- package/src/o-knob.js +137 -129
- package/src/o-menu.js +271 -183
- package/src/o-meter.js +35 -89
- package/src/o-radar-chart.js +391 -0
- package/src/o-radar.js +80 -123
- package/src/o-range.js +509 -0
- package/src/o-rings.js +66 -77
- package/src/o-spinner.js +214 -0
- package/src/o-status.js +209 -0
- package/src/o-sunburst.js +569 -0
- package/src/o-time-picker.js +447 -0
- package/src/o-timer.js +240 -0
- package/src/o-wheel.js +436 -0
- package/src/orbit-kit.js +23 -5
package/src/o-rings.js
CHANGED
|
@@ -1,93 +1,82 @@
|
|
|
1
|
+
import { OrbitKitElement, define, injectStyleOnce, universe, hub, fixed, finiteNumber, cssValue, normalizeRange, escapeHTML as e, withShared, FORMAT_ATTRS } from './base.js';
|
|
2
|
+
const PALETTE = ['#fa114f', '#a3ff00', '#00fff0', '#ffd60a', '#ff9500'];
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
* <o-rings
|
|
3
|
-
*
|
|
4
|
-
* Collapses the Orbit recipe:
|
|
5
|
-
*
|
|
6
|
-
* .bigbang > .gravity-spot
|
|
7
|
-
* ├── .orbit-{outer} > <o-progress> (ring 1, outermost)
|
|
8
|
-
* ├── .orbit-{outer-1} > <o-progress> (ring 2)
|
|
9
|
-
* ├── ...
|
|
10
|
-
* └── .orbit-0 > .satellite.at-center > .capsule (optional center label)
|
|
11
|
-
*
|
|
12
|
-
* ...into one tag fed by a JSON array (outer ring first):
|
|
5
|
+
* <o-rings>: concentric activity rings, outermost first.
|
|
13
6
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* Each datum may set its own `max` (default 100), `color`, and `label`.
|
|
7
|
+
* Ring i sits on orbit (12 - i × spacing) with its band hugging the inside of
|
|
8
|
+
* that lane (inner-orbit), so any thickness fits and the outer ring touches
|
|
9
|
+
* the edge of the universe. Each ring is a track <o-arc> under an <o-progress>.
|
|
19
10
|
*/
|
|
20
|
-
import { OrbitKitElement, define } from './base.js';
|
|
21
|
-
|
|
22
11
|
export class OrbitRings extends OrbitKitElement {
|
|
23
12
|
static get observedAttributes() {
|
|
24
|
-
return ['data', 'size', 'stroke-width', 'track', 'shape',
|
|
25
|
-
'range', 'from', 'step', 'label', 'unit', 'glow'];
|
|
13
|
+
return withShared(['data', 'size', 'thickness', 'stroke-width', 'track', 'shape', 'range', 'from', 'spacing', 'step', 'label', 'min', 'max', 'glow'], FORMAT_ATTRS);
|
|
26
14
|
}
|
|
27
|
-
|
|
28
15
|
readConfig() {
|
|
29
|
-
const range = this._num('range', 360);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
16
|
+
const range = Math.max(0, Math.min(360, this._num('range', 360)));
|
|
17
|
+
// Each ring has its own min/max; the attributes are the defaults (0 and 100).
|
|
18
|
+
const lo = this._num('min', 0), hi = this._num('max', 100);
|
|
19
|
+
const data = this._array('data').map((d, i) => {
|
|
20
|
+
const min = finiteNumber(d.min, lo);
|
|
21
|
+
let max = finiteNumber(d.max, hi);
|
|
22
|
+
if (!(max > min)) max = min + 100;
|
|
23
|
+
const r = normalizeRange({ min, max });
|
|
24
|
+
return {
|
|
25
|
+
value: Math.min(r.max, Math.max(r.min, finiteNumber(d.value, r.min))), min: r.min, max: r.max,
|
|
26
|
+
color: cssValue(d.color, PALETTE[i % PALETTE.length]), label: d.label == null ? null : String(d.label)
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
// Spacing between ring centers, in whole lanes: as wide as fits, 1 to 3.
|
|
30
|
+
const n = Math.max(1, data.length);
|
|
31
|
+
const base = Math.max(1, Math.min(3, Math.floor(10 / n)));
|
|
32
|
+
// `spacing` (1.0: `step`) multiplies the distance between rings.
|
|
33
|
+
let spacing = Math.max(1, Math.round(base * Math.max(1, this._num('spacing', this._num('step', 1)))));
|
|
34
|
+
while (spacing > 1 && (n - 1) * spacing > 10) spacing--;
|
|
35
|
+
const thickness = this._thickness(base * .86, spacing);
|
|
36
36
|
return {
|
|
37
|
-
data,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
strokeWidth: this._num('stroke-width', 12),
|
|
43
|
-
track: this.hasAttribute('track') ? this.getAttribute('track') : null,
|
|
44
|
-
shape: this._str('shape', 'rounded'),
|
|
45
|
-
step: Math.round(this._num('step', 1)),
|
|
46
|
-
unit: this._str('unit', ''),
|
|
47
|
-
glow: this.getAttribute('glow'),
|
|
48
|
-
label: this.hasAttribute('label') ? this.getAttribute('label') : null
|
|
37
|
+
data, range, from: this.hasAttribute('from') ? this._num('from', 0) : (range === 360 ? 0 : this._autoFrom(range)),
|
|
38
|
+
size: this._size('size', '200px'), thickness, spacing,
|
|
39
|
+
strokeWidth: Math.max(0, this._num('stroke-width', 0)),
|
|
40
|
+
track: this._color('track', ''), shape: this._str('shape', 'rounded'),
|
|
41
|
+
unit: this._unitText(), glow: this.hasAttribute('glow') ? this._glowPx() : null, label: this.getAttribute('label')
|
|
49
42
|
};
|
|
50
43
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return JSON.stringify(cfg);
|
|
54
|
-
}
|
|
55
|
-
|
|
44
|
+
structuralSignature({ data, label, unit, track, ...structure }) { return JSON.stringify({ ...structure, count: data.length }); }
|
|
45
|
+
_track(cfg, d) { return cfg.track || `color-mix(in srgb, ${d.color} 18%, transparent)`; }
|
|
56
46
|
build(cfg) {
|
|
57
|
-
const
|
|
58
|
-
// Outermost ring gets the highest orbit number so rings nest inward.
|
|
59
|
-
const outer = 1 + cfg.data.length * cfg.step;
|
|
60
|
-
// Scale --o-force so the outer ring fits within `size` (orbit-N = N/12 *
|
|
61
|
-
// force) with a margin for the rounded caps + glow bloom, instead of the
|
|
62
|
-
// default 500px that overflows the box.
|
|
63
|
-
const sizeNum = parseFloat(cfg.size) || 200;
|
|
64
|
-
const force = (sizeNum * 12 / (outer + 1)).toFixed(1);
|
|
65
|
-
|
|
47
|
+
const stroke = cfg.strokeWidth ? `--o-stroke-width:${cfg.strokeWidth};` : '';
|
|
66
48
|
const rings = cfg.data.map((d, i) => {
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const track = cfg.track || `color-mix(in srgb, ${color} 14%, #0c0f14)`;
|
|
72
|
-
const ring = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;`;
|
|
73
|
-
const fill = `--o-fill:${color};--o-stroke:${color};--o-stroke-width:${cfg.strokeWidth};${this._glow(color)}`;
|
|
74
|
-
return `
|
|
75
|
-
<div class="orbit-${orbit}" style="${ring}"><o-arc value="100" shape="${cfg.shape}" style="--o-fill:${track};--o-stroke:${track};--o-stroke-width:${cfg.strokeWidth}"></o-arc></div>
|
|
76
|
-
<div class="orbit-${orbit}" style="${ring}"><o-progress value="${d.value}" max="${d.max}" shape="${cfg.shape}" style="${fill}"></o-progress></div>`;
|
|
49
|
+
const ring = `class="orbit-${12 - i * cfg.spacing}" style="--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;--o-size-ratio:${fixed(cfg.thickness)};--o-gap:0"`;
|
|
50
|
+
const glow = cfg.glow == null ? '' : `filter:drop-shadow(0 0 ${cfg.glow}px ${d.color});`;
|
|
51
|
+
return `<div ${ring}><o-arc class="inner-orbit ok-rings-track" value="100" shape="${e(cfg.shape)}" style="${e(`--o-fill:${this._track(cfg, d)};${stroke}`)}"></o-arc></div>` +
|
|
52
|
+
`<div ${ring}><o-progress class="inner-orbit ok-rings-value" value="${d.value - d.min}" max="${d.max - d.min}" shape="${e(cfg.shape)}" style="${e(`--o-fill:${d.color};${stroke}${glow}`)}"></o-progress></div>`;
|
|
77
53
|
}).join('');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
54
|
+
const hole = 12 - (cfg.data.length - 1) * cfg.spacing - cfg.thickness;
|
|
55
|
+
this.innerHTML = universe(cfg.size, `${rings}
|
|
56
|
+
${hub(2 * hole - .5, `${e(cfg.label ?? '')}${cfg.label == null ? '' : e(cfg.unit)}`)}`);
|
|
57
|
+
this._rings = [...this.querySelectorAll('.ok-rings-value')];
|
|
58
|
+
this._tracks = [...this.querySelectorAll('.ok-rings-track')];
|
|
59
|
+
this._center = this.querySelector('.ok-hub');
|
|
60
|
+
}
|
|
61
|
+
sync(cfg) {
|
|
62
|
+
this._rings?.forEach((ring, i) => {
|
|
63
|
+
const d = cfg.data[i];
|
|
64
|
+
ring.setAttribute('max', String(d.max - d.min));
|
|
65
|
+
ring.setAttribute('value', String(d.value - d.min));
|
|
66
|
+
ring.style.setProperty('--o-fill', d.color);
|
|
67
|
+
if (cfg.glow != null) ring.style.setProperty('filter', `drop-shadow(0 0 ${cfg.glow}px ${d.color})`);
|
|
68
|
+
else ring.style.removeProperty?.('filter');
|
|
69
|
+
this._tracks[i]?.style.setProperty('--o-fill', this._track(cfg, d));
|
|
70
|
+
});
|
|
71
|
+
if (this._center) {
|
|
72
|
+
this._center.textContent = cfg.label == null ? '' : `${cfg.label}${cfg.unit}`;
|
|
73
|
+
this._center.hidden = cfg.label == null;
|
|
74
|
+
}
|
|
75
|
+
this._describe([cfg.label == null ? null : `${cfg.label}${cfg.unit}`, ...cfg.data.map((d, i) => `${d.label ?? `Ring ${i + 1}`}: ${this._format(d.value, { min: d.min, max: d.max })} of ${this._formatText(d.max, { min: d.min, max: d.max })}`)].filter(Boolean).join('; ') || 'Empty activity rings');
|
|
90
76
|
}
|
|
91
77
|
}
|
|
92
|
-
|
|
78
|
+
injectStyleOnce('orbit-kit-rings', `
|
|
79
|
+
o-rings :is(.ok-rings-track, .ok-rings-value) { --o-stroke: var(--o-fill); }
|
|
80
|
+
o-rings .ok-hub { --ok-hub-size: 1.2em; }
|
|
81
|
+
`);
|
|
93
82
|
define('o-rings', OrbitRings);
|
package/src/o-spinner.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <o-spinner>: a circular loading indicator, in five variants.
|
|
3
|
+
*
|
|
4
|
+
* ring .orbit-12 > o-arc × 2, turning at two paces, so the arc
|
|
5
|
+
* they draw together stretches and shrinks as it goes round
|
|
6
|
+
* dots .orbit-9 > .satellite × count, pulsing one after another
|
|
7
|
+
* segments .orbit-12 > .vector × count, ticks fading in turn (iOS-like)
|
|
8
|
+
* orbit .orbit-11 and .orbit-6, each with satellites, turning at
|
|
9
|
+
* their own pace around a core in .orbit-0
|
|
10
|
+
* progress .orbit-12 > o-arc track + value; determinate once `value`
|
|
11
|
+
* is set, a turning arc until then
|
|
12
|
+
*
|
|
13
|
+
* Everything moves with CSS (rotate / opacity / scale), so a spinner costs no
|
|
14
|
+
* script while it runs. With prefers-reduced-motion nothing turns: the arcs,
|
|
15
|
+
* dots and ticks breathe slowly in place instead.
|
|
16
|
+
*/
|
|
17
|
+
import { OrbitKitElement, define, injectStyleOnce, universe, hub, fixed, escapeHTML as e, withShared, RANGE_ATTRS, FORMAT_ATTRS } from './base.js';
|
|
18
|
+
|
|
19
|
+
export const SPINNER_VARIANTS = ['ring', 'dots', 'segments', 'orbit', 'progress'];
|
|
20
|
+
|
|
21
|
+
const SPINNER_CSS = `
|
|
22
|
+
:where(o-spinner) { display: inline-block; vertical-align: middle; line-height: 0; }
|
|
23
|
+
o-spinner {
|
|
24
|
+
--ok-font-size: 17cqmin;
|
|
25
|
+
--_c: var(--ok-spinner-color, var(--ok-fill, #4fd1c5));
|
|
26
|
+
--_t: var(--ok-spinner-track, var(--ok-track, rgba(127,127,127,.2)));
|
|
27
|
+
--_a: var(--ok-spinner-accent, var(--ok-accent, #ffb547));
|
|
28
|
+
--_s: var(--ok-spinner-speed, 1s);
|
|
29
|
+
}
|
|
30
|
+
o-spinner .ok-track { --o-fill: var(--_t); --o-stroke: var(--o-fill); }
|
|
31
|
+
o-spinner .ok-value { --o-fill: var(--_c); --o-stroke: var(--o-fill); }
|
|
32
|
+
|
|
33
|
+
/* ring: two equal arcs, one turning steadily and one easing around it. */
|
|
34
|
+
o-spinner .ok-spin-rotor { animation: ok-spin-turn calc(var(--_s) * 1.1) linear infinite; }
|
|
35
|
+
o-spinner .ok-spin-rotor.is-lead { animation-timing-function: cubic-bezier(.55, .1, .45, .9); }
|
|
36
|
+
@keyframes ok-spin-turn { to { rotate: 360deg; } }
|
|
37
|
+
|
|
38
|
+
/* dots: each one swells and brightens in turn. */
|
|
39
|
+
o-spinner .ok-spin-dot {
|
|
40
|
+
width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--_c);
|
|
41
|
+
animation: ok-spin-dot var(--_s) ease-in-out infinite;
|
|
42
|
+
animation-delay: calc(var(--i) / var(--n) * var(--_s) - var(--_s));
|
|
43
|
+
}
|
|
44
|
+
@keyframes ok-spin-dot { 0%, 100% { scale: .42; opacity: .3; } 30% { scale: 1; opacity: 1; } }
|
|
45
|
+
|
|
46
|
+
/* segments: rounded ticks; the bright one walks around. */
|
|
47
|
+
o-spinner .ok-spin-tick {
|
|
48
|
+
height: max(1.5px, 7.2cqmin * var(--ok-spinner-weight, 1)); border-radius: 99px; background: var(--_c);
|
|
49
|
+
animation: ok-spin-fade var(--_s) linear infinite;
|
|
50
|
+
animation-delay: calc(var(--i) / var(--n) * var(--_s) - var(--_s));
|
|
51
|
+
}
|
|
52
|
+
@keyframes ok-spin-fade { 0% { opacity: 1; } 100% { opacity: .16; } }
|
|
53
|
+
|
|
54
|
+
/* orbit: two lanes turning at their own pace around a breathing core. */
|
|
55
|
+
o-spinner .ok-spin-path { box-shadow: inset 0 0 0 max(1px, .9cqmin) var(--_t); }
|
|
56
|
+
o-spinner .ok-spin-path.is-outer { animation: ok-spin-turn calc(var(--_s) * 1.6) linear infinite; }
|
|
57
|
+
o-spinner .ok-spin-path.is-inner { animation: ok-spin-turn calc(var(--_s) * 1.1) linear infinite reverse; }
|
|
58
|
+
o-spinner .ok-spin-planet { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--_c); box-shadow: 0 0 6cqmin color-mix(in srgb, var(--_c) 60%, transparent); }
|
|
59
|
+
o-spinner .ok-spin-moon { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--_a); }
|
|
60
|
+
o-spinner .ok-spin-core {
|
|
61
|
+
width: 100%; aspect-ratio: 1; border-radius: 50%;
|
|
62
|
+
background: radial-gradient(circle at 40% 35%, color-mix(in srgb, var(--_c) 55%, #fff), var(--_c));
|
|
63
|
+
animation: ok-spin-core calc(var(--_s) * 1.6) ease-in-out infinite;
|
|
64
|
+
}
|
|
65
|
+
@keyframes ok-spin-core { 50% { scale: .72; opacity: .7; } }
|
|
66
|
+
|
|
67
|
+
o-spinner .ok-spin-readout { font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
|
|
68
|
+
|
|
69
|
+
/* Reduced motion: nothing turns or swells; the drawing breathes in place. */
|
|
70
|
+
@keyframes ok-spin-breathe { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
|
|
71
|
+
@media (prefers-reduced-motion: reduce) {
|
|
72
|
+
o-spinner .ok-spin-rotor { animation: ok-spin-breathe calc(var(--_s) * 2.4) ease-in-out infinite; }
|
|
73
|
+
o-spinner .ok-spin-rotor.is-lead { visibility: hidden; }
|
|
74
|
+
o-spinner .ok-spin-dot { animation-name: ok-spin-breathe; animation-duration: calc(var(--_s) * 2.4); scale: .8; }
|
|
75
|
+
o-spinner .ok-spin-tick { animation-duration: calc(var(--_s) * 2.4); }
|
|
76
|
+
o-spinner .ok-spin-path.is-outer, o-spinner .ok-spin-path.is-inner { animation: none; }
|
|
77
|
+
o-spinner .ok-spin-planet, o-spinner .ok-spin-moon { animation: ok-spin-breathe calc(var(--_s) * 2.4) ease-in-out infinite; }
|
|
78
|
+
o-spinner .ok-spin-moon { animation-delay: calc(var(--_s) * -1.2); }
|
|
79
|
+
o-spinner .ok-spin-core { animation: none; }
|
|
80
|
+
}
|
|
81
|
+
o-spinner[paused] *, o-spinner[paused] { animation-play-state: paused; }
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const DEFAULT_COUNT = { dots: 8, segments: 12, orbit: 2 };
|
|
85
|
+
|
|
86
|
+
export class OrbitSpinner extends OrbitKitElement {
|
|
87
|
+
static get observedAttributes() {
|
|
88
|
+
return withShared(['variant', 'value', 'size', 'speed', 'color', 'track', 'accent', 'thickness', 'count', 'label', 'readout', 'shape', 'paused'], RANGE_ATTRS, FORMAT_ATTRS);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
constructor() {
|
|
92
|
+
super();
|
|
93
|
+
this._shown = null;
|
|
94
|
+
this._tween = 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
readConfig() {
|
|
98
|
+
const variant = SPINNER_VARIANTS.includes(this.getAttribute('variant')) ? this.getAttribute('variant') : 'ring';
|
|
99
|
+
const raw = this.getAttribute('value');
|
|
100
|
+
const determinate = variant === 'progress' && raw != null && raw.trim() !== '' && Number.isFinite(Number(raw));
|
|
101
|
+
const r = this._range({ min: 0, max: 100, step: 0 });
|
|
102
|
+
const count = Math.round(this._num('count', DEFAULT_COUNT[variant] || 0));
|
|
103
|
+
return {
|
|
104
|
+
variant, determinate, value: r.value, min: r.min, max: r.max, fraction: r.fraction,
|
|
105
|
+
size: this._size('size', '48px'), speed: Math.max(.05, this._num('speed', 1)),
|
|
106
|
+
color: this._color('color', null), track: this._color('track', null), accent: this._color('accent', null),
|
|
107
|
+
thickness: this._thickness(variant === 'progress' ? 1.8 : 2, 6),
|
|
108
|
+
count: variant === 'orbit' ? Math.max(1, Math.min(4, count)) : Math.max(3, Math.min(36, count)),
|
|
109
|
+
label: this.getAttribute('label'), shape: this._str('shape', 'rounded'),
|
|
110
|
+
readout: variant === 'progress' && this.hasAttribute('readout') && this.getAttribute('readout') !== 'false'
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
structuralSignature({ variant, determinate, size, count, thickness, shape, readout }) {
|
|
115
|
+
return JSON.stringify({ variant, determinate, size, count, thickness, shape, readout });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
build(cfg) {
|
|
119
|
+
this.onDisconnected();
|
|
120
|
+
this._shown = null;
|
|
121
|
+
const T = fixed(cfg.thickness);
|
|
122
|
+
const shape = e(cfg.shape);
|
|
123
|
+
const arc = (cls, value, extra = '') => `<o-arc class="inner-orbit ${cls}" value="${value}" shape="${shape}"${extra}></o-arc>`;
|
|
124
|
+
let body = '';
|
|
125
|
+
if (cfg.variant === 'dots') {
|
|
126
|
+
body = `<div class="orbit-9" style="--o-size-ratio:3.4">${Array.from({ length: cfg.count }, (_, i) =>
|
|
127
|
+
`<div class="satellite"><div class="capsule ok-spin-dot" style="--i:${i};--n:${cfg.count}"></div></div>`).join('')}</div>`;
|
|
128
|
+
} else if (cfg.variant === 'segments') {
|
|
129
|
+
body = `<div class="orbit-12">${Array.from({ length: cfg.count }, (_, i) =>
|
|
130
|
+
`<div class="vector inner-orbit ok-spin-tick" style="--o-size-ratio:4.6;--i:${i};--n:${cfg.count}"></div>`).join('')}</div>`;
|
|
131
|
+
} else if (cfg.variant === 'orbit') {
|
|
132
|
+
const planets = '<div class="satellite"><div class="capsule ok-spin-planet"></div></div>';
|
|
133
|
+
const moons = '<div class="satellite"><div class="capsule ok-spin-moon"></div></div>'.repeat(cfg.count);
|
|
134
|
+
body = `<div class="orbit-10 ok-spin-path is-outer" style="--o-size-ratio:3.2">${planets}</div>
|
|
135
|
+
<div class="orbit-5 ok-spin-path is-inner" style="--o-size-ratio:2.2">${moons}</div>
|
|
136
|
+
<div class="orbit-0" style="--o-size-ratio:4.2"><div class="satellite at-center"><div class="capsule ok-spin-core"></div></div></div>`;
|
|
137
|
+
} else {
|
|
138
|
+
const ring = `--o-size-ratio:${T};--o-gap:0`;
|
|
139
|
+
const track = `<div class="orbit-12" style="${ring}"><o-arc class="inner-orbit ok-track" value="100"></o-arc></div>`;
|
|
140
|
+
if (cfg.determinate) {
|
|
141
|
+
body = `${track}<div class="orbit-12" style="${ring}">${arc('ok-value ok-spin-fill', 0)}</div>` +
|
|
142
|
+
(cfg.readout ? hub(2 * (12 - cfg.thickness) - 2, '', 'ok-hub ok-spin-readout', ' aria-hidden="true"') : '');
|
|
143
|
+
} else {
|
|
144
|
+
// Two equal arcs: one turns steadily, the other eases ahead and falls
|
|
145
|
+
// behind, so together they draw an arc that stretches and shrinks.
|
|
146
|
+
body = `${track}<div class="orbit-12 ok-spin-rotor" style="${ring}">${arc('ok-value', 18)}</div>` +
|
|
147
|
+
`<div class="orbit-12 ok-spin-rotor is-lead" style="${ring}">${arc('ok-value', 18)}</div>`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this.innerHTML = universe(cfg.size, body);
|
|
151
|
+
this._fill = this.querySelector('.ok-spin-fill');
|
|
152
|
+
this._readout = this.querySelector('.ok-spin-readout');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
sync(cfg) {
|
|
156
|
+
this._applyTheme({
|
|
157
|
+
'--ok-spinner-color': cfg.color, '--ok-spinner-track': cfg.track, '--ok-spinner-accent': cfg.accent,
|
|
158
|
+
'--ok-spinner-speed': this.hasAttribute('speed') ? `${fixed(cfg.speed)}s` : null
|
|
159
|
+
});
|
|
160
|
+
const name = this.getAttribute('label') || 'Loading';
|
|
161
|
+
if (cfg.determinate) {
|
|
162
|
+
const range = { min: cfg.min, max: cfg.max };
|
|
163
|
+
// Without a format, the readout is the share done: 40 of 200 reads 20%.
|
|
164
|
+
const fallback = () => `${Math.round(cfg.fraction * 100)}%`;
|
|
165
|
+
const unit = this._formatOptions() ? this._unitText() : '';
|
|
166
|
+
const text = this._formatText(cfg.value, { ...range, fallback }, unit);
|
|
167
|
+
if (this._readout) this._readout.textContent = text;
|
|
168
|
+
this._describe(name, 'progressbar');
|
|
169
|
+
this._ariaRange(cfg, cfg.value, this._formatText(cfg.value, { ...range, kind: 'aria', fallback }, unit));
|
|
170
|
+
this.setAttribute('aria-busy', String(cfg.fraction < 1));
|
|
171
|
+
this._animateTo(cfg.fraction * 100);
|
|
172
|
+
} else {
|
|
173
|
+
this._describe(name, 'progressbar');
|
|
174
|
+
for (const a of ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext']) this.removeAttribute(a);
|
|
175
|
+
this.setAttribute('aria-busy', 'true');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Determinate progress glides to its new value (instantly with reduced motion). */
|
|
180
|
+
_animateTo(target) {
|
|
181
|
+
if (!this._fill) return;
|
|
182
|
+
this.onDisconnected();
|
|
183
|
+
const from = this._shown;
|
|
184
|
+
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
185
|
+
if (from == null || reduce || Math.abs(target - from) < .05 || typeof requestAnimationFrame !== 'function') {
|
|
186
|
+
this._shown = target;
|
|
187
|
+
this._fill.setAttribute('value', target.toFixed(2));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const start = performance.now(), duration = 360;
|
|
191
|
+
const step = (now) => {
|
|
192
|
+
const t = Math.min(1, (now - start) / duration), k = 1 - (1 - t) ** 3;
|
|
193
|
+
this._shown = from + (target - from) * k;
|
|
194
|
+
this._fill?.setAttribute('value', this._shown.toFixed(2));
|
|
195
|
+
if (t < 1) this._tween = requestAnimationFrame(step);
|
|
196
|
+
};
|
|
197
|
+
this._tween = requestAnimationFrame(step);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
onDisconnected() { if (this._tween && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this._tween); this._tween = 0; }
|
|
201
|
+
|
|
202
|
+
/** The progress value; undefined while indeterminate. Setting it does not fire events. */
|
|
203
|
+
get value() {
|
|
204
|
+
const raw = this.getAttribute('value');
|
|
205
|
+
return raw == null || raw.trim() === '' ? undefined : this.readConfig().value;
|
|
206
|
+
}
|
|
207
|
+
set value(v) {
|
|
208
|
+
if (v == null || v === '') this.removeAttribute('value');
|
|
209
|
+
else this.setAttribute('value', String(v));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
injectStyleOnce('orbit-kit-spinner', SPINNER_CSS);
|
|
214
|
+
define('o-spinner', OrbitSpinner);
|
package/src/o-status.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <o-status>: layered status rings. Each item is a segment colored by its
|
|
3
|
+
* status; items with `children` become an inner ring of groups with their
|
|
4
|
+
* children fanned out around them, like a sunburst.
|
|
5
|
+
*
|
|
6
|
+
* .orbit-12 > o-arc[value] × leaves outer ring (the deepest level)
|
|
7
|
+
* .orbit-10 > o-arc[value] × groups inner ring, each as wide as its children
|
|
8
|
+
* .orbit-0 > .satellite.at-center summary, or the item under the pointer
|
|
9
|
+
*
|
|
10
|
+
* Arcs with a `value` stack around their ring, so every group lines up with
|
|
11
|
+
* its children. Segments are `interactive`, focusable and fire `select`.
|
|
12
|
+
*/
|
|
13
|
+
import { OrbitKitElement, define, injectStyleOnce, universe, hub, fixed, cssValue, escapeHTML as e } from './base.js';
|
|
14
|
+
|
|
15
|
+
export const STATUSES = ['error', 'warn', 'info', 'ok', 'off'];
|
|
16
|
+
const ALIASES = { good: 'ok', up: 'ok', healthy: 'ok', success: 'ok', warning: 'warn', degraded: 'warn', down: 'error', critical: 'error', fail: 'error', failed: 'error', danger: 'error', unknown: 'off', idle: 'off', disabled: 'off', maintenance: 'info' };
|
|
17
|
+
const WORDS = { ok: 'OK', warn: 'Warning', error: 'Error', info: 'Info', off: 'Off' };
|
|
18
|
+
|
|
19
|
+
export function normalizeStatus(value) {
|
|
20
|
+
const key = String(value ?? '').trim().toLowerCase();
|
|
21
|
+
return STATUSES.includes(key) ? key : (ALIASES[key] || 'off');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const STATUS_CSS = `
|
|
25
|
+
o-status { --ok-font-size: 4.6cqmin; }
|
|
26
|
+
o-status .ok-status-seg { --o-color: var(--ok-status-text, #fff); cursor: pointer; outline: none; transition: filter .15s, opacity .15s; }
|
|
27
|
+
o-status .ok-status-seg[data-status="ok"] { --o-fill: var(--ok-status-ok, #16a34a); }
|
|
28
|
+
o-status .ok-status-seg[data-status="warn"] { --o-fill: var(--ok-status-warn, #d97706); }
|
|
29
|
+
o-status .ok-status-seg[data-status="error"] { --o-fill: var(--ok-status-error, #dc2626); }
|
|
30
|
+
o-status .ok-status-seg[data-status="info"] { --o-fill: var(--ok-status-info, #2563eb); }
|
|
31
|
+
o-status .ok-status-seg[data-status="off"] { --o-fill: var(--ok-status-off, #475569); }
|
|
32
|
+
o-status .ok-status-seg { --o-stroke: var(--o-fill); }
|
|
33
|
+
o-status .ok-status-seg:is(:hover, :focus-visible) { filter: brightness(1.25) drop-shadow(0 0 4px var(--o-fill)); }
|
|
34
|
+
o-status .ok-status-seg:focus-visible { --o-stroke: var(--ok-focus, #fff); }
|
|
35
|
+
o-status .ok-status-seg.is-selected { filter: brightness(1.3); }
|
|
36
|
+
o-status .ok-status-spacer { --o-fill: transparent; --o-stroke: transparent; }
|
|
37
|
+
o-status[pulse] .ok-status-seg[data-status="error"] { animation: ok-status-pulse 1.6s ease-in-out infinite; }
|
|
38
|
+
@keyframes ok-status-pulse { 50% { opacity: .55; } }
|
|
39
|
+
o-status .ok-status-hub { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .1em; padding: 8%; }
|
|
40
|
+
o-status .ok-status-title { font-size: .95em; opacity: .7; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
41
|
+
o-status .ok-status-main { font-size: 1.9em; font-weight: 700; line-height: 1.05; overflow-wrap: anywhere; }
|
|
42
|
+
o-status .ok-status-sub { font-size: 1em; font-weight: 700; }
|
|
43
|
+
o-status .ok-status-sub[data-status="ok"] { color: var(--ok-status-ok, #16a34a); }
|
|
44
|
+
o-status .ok-status-sub[data-status="warn"] { color: var(--ok-status-warn, #d97706); }
|
|
45
|
+
o-status .ok-status-sub[data-status="error"] { color: var(--ok-status-error, #dc2626); }
|
|
46
|
+
o-status .ok-status-sub[data-status="info"] { color: var(--ok-status-info, #2563eb); }
|
|
47
|
+
o-status .ok-status-sub[data-status="off"] { color: var(--ok-status-off, #94a3b8); }
|
|
48
|
+
@media (prefers-reduced-motion: reduce) { o-status[pulse] .ok-status-seg { animation: none; } o-status .ok-status-seg { transition: none; } }
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
export class OrbitStatus extends OrbitKitElement {
|
|
52
|
+
static get observedAttributes() {
|
|
53
|
+
return ['data', 'size', 'thickness', 'gap', 'range', 'from', 'label', 'labels', 'shape', 'pulse'];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_norm(items, depth = 0) {
|
|
57
|
+
if (!Array.isArray(items) || depth > 2) return [];
|
|
58
|
+
return items.filter((item) => item && typeof item === 'object' && !Array.isArray(item)).map((item, i) => {
|
|
59
|
+
const children = this._norm(item.children, depth + 1);
|
|
60
|
+
const own = item.status == null ? null : normalizeStatus(item.status);
|
|
61
|
+
// A group without its own status shows the worst of its children.
|
|
62
|
+
const worst = children.length ? STATUSES.find((s) => children.some((c) => c.status === s)) : null;
|
|
63
|
+
return {
|
|
64
|
+
label: item.label != null ? String(item.label) : `Item ${i + 1}`,
|
|
65
|
+
status: own ?? worst ?? 'off',
|
|
66
|
+
note: item.note == null ? null : String(item.note),
|
|
67
|
+
color: cssValue(item.color, null),
|
|
68
|
+
value: item.value,
|
|
69
|
+
children,
|
|
70
|
+
leaves: children.length ? children.reduce((n, c) => n + c.leaves, 0) : 1
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
readConfig() {
|
|
76
|
+
const items = this._norm(this._json('data', []));
|
|
77
|
+
const depth = (list) => list.reduce((n, item) => Math.max(n, item.children.length ? 1 + depth(item.children) : 1), 0);
|
|
78
|
+
const levels = Math.max(1, depth(items));
|
|
79
|
+
const thickness = this._thickness(levels === 1 ? 2.4 : 2, 4);
|
|
80
|
+
const range = Math.max(0, Math.min(360, this._num('range', 360)));
|
|
81
|
+
return {
|
|
82
|
+
items, levels, thickness, spacing: Math.max(1, Math.ceil(thickness + .15)),
|
|
83
|
+
range, from: this.hasAttribute('from') ? this._num('from', 0) : (range === 360 ? 0 : this._autoFrom(range)),
|
|
84
|
+
gap: this._gap(.8), size: this._size('size', '220px'), label: this.getAttribute('label'),
|
|
85
|
+
labels: this.hasAttribute('labels') && this.getAttribute('labels') !== 'false', shape: this._str('shape', 'none')
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
structuralSignature(cfg) { const { label, ...rest } = cfg; return JSON.stringify(rest); }
|
|
90
|
+
|
|
91
|
+
build(cfg) {
|
|
92
|
+
injectStyleOnce('orbit-kit-status', STATUS_CSS);
|
|
93
|
+
const total = cfg.items.reduce((n, item) => n + item.leaves, 0) || 1;
|
|
94
|
+
this._levels = [];
|
|
95
|
+
let rings = '';
|
|
96
|
+
// Level d holds the items at depth d; an item that ends early leaves a
|
|
97
|
+
// transparent spacer in the outer rings so everything stays aligned.
|
|
98
|
+
let row = cfg.items.map((item, i) => ({ item, path: [i] }));
|
|
99
|
+
for (let d = 0; d < cfg.levels; d++) {
|
|
100
|
+
const orbit = 12 - (cfg.levels - 1 - d) * cfg.spacing;
|
|
101
|
+
let before = 0;
|
|
102
|
+
const arcs = row.map(({ item, path, spacer }) => {
|
|
103
|
+
const value = fixed(item.leaves / total * 100, 4);
|
|
104
|
+
const phi = ((cfg.from + (before + item.leaves / 2) / total * cfg.range) % 360 + 360) % 360;
|
|
105
|
+
before += item.leaves;
|
|
106
|
+
if (spacer) return `<o-arc class="inner-orbit ok-status-spacer" value="${value}" aria-hidden="true"></o-arc>`;
|
|
107
|
+
const name = `${item.label}: ${WORDS[item.status]}${item.note ? `, ${item.note}` : ''}`;
|
|
108
|
+
const color = item.color ? ` style="--o-fill:${e(item.color)}"` : '';
|
|
109
|
+
return `<o-arc class="inner-orbit ok-status-seg" interactive role="button" tabindex="-1" data-path="${path.join('.')}" data-status="${item.status}" aria-label="${e(name)}" value="${value}" shape="${e(cfg.shape)}"${cfg.labels && phi > 90 && phi < 270 ? ' flip' : ''}${color}>${cfg.labels ? e(item.label) : ''}</o-arc>`;
|
|
110
|
+
}).join('');
|
|
111
|
+
rings += `<div class="orbit-${orbit} ok-status-ring" style="--o-range:${cfg.range}deg;--o-from:${fixed(cfg.from)}deg;--o-size-ratio:${fixed(cfg.thickness)};--o-gap:${fixed(cfg.gap * 6)};font-weight:700">${arcs}</div>`;
|
|
112
|
+
row = row.flatMap(({ item, path, spacer }) => item.children.length && !spacer
|
|
113
|
+
? item.children.map((child, i) => ({ item: child, path: [...path, i] }))
|
|
114
|
+
: [{ item, path, spacer: true }]);
|
|
115
|
+
}
|
|
116
|
+
const hole = 12 - (cfg.levels - 1) * cfg.spacing - cfg.thickness;
|
|
117
|
+
this.innerHTML = universe(cfg.size, `${rings}
|
|
118
|
+
${hub(2 * hole - .6, '<span class="ok-status-title" hidden></span><span class="ok-status-main"></span><span class="ok-status-sub"></span>', 'ok-hub ok-status-hub', ' aria-hidden="true"')}`);
|
|
119
|
+
this._segments = [...this.querySelectorAll('.ok-status-seg')];
|
|
120
|
+
this._title = this.querySelector('.ok-status-title');
|
|
121
|
+
this._main = this.querySelector('.ok-status-main');
|
|
122
|
+
this._sub = this.querySelector('.ok-status-sub');
|
|
123
|
+
for (const seg of this._segments) {
|
|
124
|
+
seg.addEventListener('click', () => { seg.focus(); this._select(seg); });
|
|
125
|
+
seg.addEventListener('pointerenter', () => this._show(seg));
|
|
126
|
+
seg.addEventListener('pointerleave', () => this._show(this.contains(this.ownerDocument.activeElement) ? this.ownerDocument.activeElement : null));
|
|
127
|
+
seg.addEventListener('focus', () => { this._roving(seg); this._show(seg); });
|
|
128
|
+
seg.addEventListener('blur', () => this._show(null));
|
|
129
|
+
}
|
|
130
|
+
this._roving(this._segments.find((s) => s.dataset.path === this._focusPath) || this._segments[0]);
|
|
131
|
+
if (!this._keys) {
|
|
132
|
+
this._keys = true;
|
|
133
|
+
this.addEventListener('keydown', (event) => this._onKey(event));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
sync(cfg) {
|
|
138
|
+
this._cfg = cfg;
|
|
139
|
+
const leaves = [];
|
|
140
|
+
const walk = (list) => list.forEach((item) => (item.children.length ? walk(item.children) : leaves.push(item)));
|
|
141
|
+
walk(cfg.items);
|
|
142
|
+
const counts = Object.fromEntries(STATUSES.map((s) => [s, leaves.filter((l) => l.status === s).length]));
|
|
143
|
+
this._counts = counts;
|
|
144
|
+
this._leaves = leaves;
|
|
145
|
+
if (this._title) { this._title.textContent = cfg.label ?? ''; this._title.hidden = !cfg.label; }
|
|
146
|
+
this._show(this.contains?.(this.ownerDocument?.activeElement) ? this.ownerDocument.activeElement : null);
|
|
147
|
+
const parts = STATUSES.filter((s) => counts[s]).map((s) => `${counts[s]} ${WORDS[s].toLowerCase()}`);
|
|
148
|
+
this._describe(`${cfg.label ? `${cfg.label}: ` : ''}${parts.join(', ') || 'no items'}`, 'group');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The hub: the item under the pointer or in focus, else a summary. */
|
|
152
|
+
_show(seg) {
|
|
153
|
+
if (!this._main || !this._cfg) return;
|
|
154
|
+
const item = seg?.dataset?.path ? this._itemAt(seg.dataset.path) : null;
|
|
155
|
+
if (item) {
|
|
156
|
+
this._main.textContent = item.label;
|
|
157
|
+
this._sub.textContent = item.note ? `${WORDS[item.status]} · ${item.note}` : WORDS[item.status];
|
|
158
|
+
this._sub.dataset.status = item.status;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const counts = this._counts || {};
|
|
162
|
+
const issues = (counts.error || 0) + (counts.warn || 0);
|
|
163
|
+
const worst = STATUSES.find((s) => s !== 'off' && s !== 'info' && counts[s]) || 'off';
|
|
164
|
+
this._main.textContent = issues ? String(issues) : `${counts.ok || 0}/${this._leaves?.length || 0}`;
|
|
165
|
+
const total = this._leaves?.length || 0;
|
|
166
|
+
this._sub.textContent = issues ? (issues === 1 ? 'issue' : 'issues') : (!total ? 'no items' : counts.ok === total ? 'all OK' : 'OK');
|
|
167
|
+
this._sub.dataset.status = issues ? worst : (counts.ok ? 'ok' : 'off');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_itemAt(path) {
|
|
171
|
+
let list = this._cfg.items, item;
|
|
172
|
+
for (const index of String(path).split('.').map(Number)) { item = list[index]; if (!item) return null; list = item.children; }
|
|
173
|
+
return item;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_roving(seg) {
|
|
177
|
+
if (!seg) return;
|
|
178
|
+
this._focusPath = seg.dataset.path;
|
|
179
|
+
for (const other of this._segments || []) other.tabIndex = other === seg ? 0 : -1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_select(seg) {
|
|
183
|
+
const item = this._itemAt(seg.dataset.path);
|
|
184
|
+
if (!item) return;
|
|
185
|
+
for (const other of this._segments) other.classList.toggle('is-selected', other === seg);
|
|
186
|
+
const path = seg.dataset.path.split('.').map(Number);
|
|
187
|
+
this.dispatchEvent(new CustomEvent('select', { detail: { label: item.label, status: item.status, note: item.note, value: item.value, item, path }, bubbles: true, composed: true }));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_onKey(event) {
|
|
191
|
+
const current = event.target?.closest?.('.ok-status-seg');
|
|
192
|
+
if (!current) return;
|
|
193
|
+
const depth = current.dataset.path.split('.').length;
|
|
194
|
+
const same = this._segments.filter((s) => s.dataset.path.split('.').length === depth);
|
|
195
|
+
const i = same.indexOf(current);
|
|
196
|
+
let next = null;
|
|
197
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') next = same[(i + (event.key === 'ArrowRight' ? 1 : -1) + same.length) % same.length];
|
|
198
|
+
else if (event.key === 'Home') next = same[0];
|
|
199
|
+
else if (event.key === 'End') next = same[same.length - 1];
|
|
200
|
+
else if (event.key === 'ArrowUp') next = this._segments.find((s) => s.dataset.path.startsWith(`${current.dataset.path}.`));
|
|
201
|
+
else if (event.key === 'ArrowDown') next = depth > 1 ? this._segments.find((s) => s.dataset.path === current.dataset.path.split('.').slice(0, -1).join('.')) : null;
|
|
202
|
+
else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); this._select(current); return; }
|
|
203
|
+
else return;
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
if (next) { this._roving(next); next.focus(); }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
define('o-status', OrbitStatus);
|