@sierragridteam/design-system 0.4.1 → 0.5.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sierragridteam/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -34,5 +34,5 @@
|
|
|
34
34
|
"access": "public",
|
|
35
35
|
"registry": "https://registry.npmjs.org/"
|
|
36
36
|
},
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "f3ec7cb9ab2125b517575b324401e942828092c1"
|
|
38
38
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { buildTimeSeries, formatChartTime, type TimeSeriesChartOptions } from '../lib/time-series';
|
|
3
|
+
interface Props extends TimeSeriesChartOptions { id?: string }
|
|
4
|
+
const { id, ...options } = Astro.props;
|
|
5
|
+
const model = buildTimeSeries(options);
|
|
6
|
+
---
|
|
7
|
+
<sierra-time-series id={id} data-options={JSON.stringify(options)}>
|
|
8
|
+
<figure class="ds-time-series" aria-label={options.label}>
|
|
9
|
+
<figcaption><strong>{options.label}</strong>{options.description && <p>{options.description}</p>}</figcaption>
|
|
10
|
+
<p>Time: UTC · Values: {options.unit || 'unitless'}</p>
|
|
11
|
+
<details>
|
|
12
|
+
<summary>View data table</summary>
|
|
13
|
+
<div class="table-wrap" tabindex="0" role="region" aria-label={`${options.label} values`}>
|
|
14
|
+
<table class="table">
|
|
15
|
+
<caption>{options.label} — {options.unit || 'unitless'}, UTC</caption>
|
|
16
|
+
<thead><tr><th scope="col">Series</th><th scope="col">Time (UTC)</th><th scope="col">Value</th></tr></thead>
|
|
17
|
+
<tbody>{model.series.flatMap(series => series.points.map(point => <tr><td>{series.label}</td><td>{formatChartTime(point.time)}</td><td>{point.value === null ? 'Not reported' : point.value}</td></tr>))}</tbody>
|
|
18
|
+
</table>
|
|
19
|
+
</div>
|
|
20
|
+
</details>
|
|
21
|
+
{model.empty && <p>No numeric samples. Data availability is determined by the source.</p>}
|
|
22
|
+
</figure>
|
|
23
|
+
</sierra-time-series>
|
|
24
|
+
<script>
|
|
25
|
+
import { renderTimeSeries } from '../lib/time-series-render';
|
|
26
|
+
import type { TimeSeriesChartOptions, TimeSeries } from '../lib/time-series';
|
|
27
|
+
class SierraTimeSeries extends HTMLElement {
|
|
28
|
+
private current?: TimeSeriesChartOptions;
|
|
29
|
+
connectedCallback() { this.options = this.current ?? JSON.parse(this.dataset.options!); }
|
|
30
|
+
get options(): TimeSeriesChartOptions { return structuredClone(this.current!); }
|
|
31
|
+
set options(value: TimeSeriesChartOptions) { renderTimeSeries(this,value); this.current=structuredClone(value); }
|
|
32
|
+
get series(): TimeSeries[] { return this.options.series; }
|
|
33
|
+
set series(value: TimeSeries[]) { this.options = { ...this.options, series:value }; }
|
|
34
|
+
}
|
|
35
|
+
if (!customElements.get('sierra-time-series')) customElements.define('sierra-time-series',SierraTimeSeries);
|
|
36
|
+
</script>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildTimeSeries,
|
|
3
|
+
formatChartTime,
|
|
4
|
+
type TimeSeriesChartOptions,
|
|
5
|
+
} from "./time-series";
|
|
6
|
+
const patterns = ["", "6 3", "2 3", "8 3 2 3"];
|
|
7
|
+
/** Replaces this chart only; creates text nodes rather than interpolating HTML. */
|
|
8
|
+
export function renderTimeSeries(
|
|
9
|
+
root: HTMLElement,
|
|
10
|
+
options: TimeSeriesChartOptions,
|
|
11
|
+
) {
|
|
12
|
+
const model = buildTimeSeries(options);
|
|
13
|
+
const tableWasOpen = root.querySelector("details")?.open ?? false;
|
|
14
|
+
const node = (tag: string, text?: string) => {
|
|
15
|
+
const n = document.createElement(tag);
|
|
16
|
+
if (text !== undefined) n.textContent = text;
|
|
17
|
+
return n;
|
|
18
|
+
};
|
|
19
|
+
const figure = node("figure");
|
|
20
|
+
figure.className = "ds-time-series";
|
|
21
|
+
figure.setAttribute("aria-label", options.label);
|
|
22
|
+
const caption = node("figcaption");
|
|
23
|
+
caption.append(node("strong", options.label));
|
|
24
|
+
if (options.description) caption.append(node("p", options.description));
|
|
25
|
+
figure.append(caption);
|
|
26
|
+
if (model.empty)
|
|
27
|
+
figure.append(
|
|
28
|
+
node(
|
|
29
|
+
"p",
|
|
30
|
+
"No numeric samples. Data availability is determined by the source.",
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
else {
|
|
34
|
+
const svgNode = (tag: string, attrs: Record<string, string>) => {
|
|
35
|
+
const n = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
36
|
+
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
|
|
37
|
+
return n;
|
|
38
|
+
};
|
|
39
|
+
const svg = svgNode("svg", {
|
|
40
|
+
viewBox: "0 0 640 240",
|
|
41
|
+
role: "img",
|
|
42
|
+
"aria-label": `${options.label}. Time in UTC; values ${options.unit || "unitless"}. Full values in the table below.`,
|
|
43
|
+
});
|
|
44
|
+
const plot = svgNode("svg", {
|
|
45
|
+
x: "65",
|
|
46
|
+
y: "15",
|
|
47
|
+
width: "550",
|
|
48
|
+
height: "180",
|
|
49
|
+
viewBox: "0 0 100 100",
|
|
50
|
+
preserveAspectRatio: "none",
|
|
51
|
+
overflow: "hidden",
|
|
52
|
+
});
|
|
53
|
+
plot.append(
|
|
54
|
+
svgNode("path", {
|
|
55
|
+
d: "M0 0 V100 H100",
|
|
56
|
+
class: "ds-chart-axis",
|
|
57
|
+
"vector-effect": "non-scaling-stroke",
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
for (let i = 0; i < model.series.length; i++)
|
|
61
|
+
for (const segment of model.series[i].segments) {
|
|
62
|
+
const attrs = {
|
|
63
|
+
class: `ds-chart-series ds-chart-series-${i % 4}`,
|
|
64
|
+
"vector-effect": "non-scaling-stroke",
|
|
65
|
+
"stroke-dasharray": patterns[i % 4],
|
|
66
|
+
};
|
|
67
|
+
plot.append(
|
|
68
|
+
svgNode("path", {
|
|
69
|
+
...attrs,
|
|
70
|
+
d: segment.map((p, j) => `${j ? "L" : "M"}${p.x},${p.y}`).join(" "),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
for (const p of segment)
|
|
74
|
+
plot.append(
|
|
75
|
+
svgNode("ellipse", {
|
|
76
|
+
cx: String(p.x),
|
|
77
|
+
cy: String(p.y),
|
|
78
|
+
rx: "0.55",
|
|
79
|
+
ry: "1.6",
|
|
80
|
+
class: `ds-chart-dot ds-chart-series-${i % 4}`,
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
svg.append(plot);
|
|
85
|
+
const label = (x: number, y: number, text: string, anchor = "start") => {
|
|
86
|
+
const n = svgNode("text", {
|
|
87
|
+
x: String(x),
|
|
88
|
+
y: String(y),
|
|
89
|
+
"text-anchor": anchor,
|
|
90
|
+
});
|
|
91
|
+
n.textContent = text;
|
|
92
|
+
svg.append(n);
|
|
93
|
+
};
|
|
94
|
+
label(58, 22, `${Number(model.valueDomain[1].toPrecision(5))}`, "end");
|
|
95
|
+
label(58, 195, `${Number(model.valueDomain[0].toPrecision(5))}`, "end");
|
|
96
|
+
label(65, 215, formatChartTime(model.timeDomain[0]));
|
|
97
|
+
label(615, 234, formatChartTime(model.timeDomain[1]), "end");
|
|
98
|
+
figure.append(
|
|
99
|
+
svg,
|
|
100
|
+
node("p", `Values: ${options.unit || "unitless"} · Time: UTC`),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const legend = node("ul");
|
|
104
|
+
legend.className = "ds-chart-legend";
|
|
105
|
+
model.series.forEach((s, i) => {
|
|
106
|
+
const li = node("li");
|
|
107
|
+
const swatch = node("span");
|
|
108
|
+
swatch.className = `ds-chart-swatch ds-chart-series-${i % 4}`;
|
|
109
|
+
swatch.setAttribute("aria-hidden", "true");
|
|
110
|
+
li.append(swatch, document.createTextNode(s.label));
|
|
111
|
+
legend.append(li);
|
|
112
|
+
});
|
|
113
|
+
figure.append(legend);
|
|
114
|
+
const details = node("details") as HTMLDetailsElement;
|
|
115
|
+
details.open = tableWasOpen;
|
|
116
|
+
details.append(node("summary", "View data table"));
|
|
117
|
+
const region = node("div");
|
|
118
|
+
region.className = "table-wrap";
|
|
119
|
+
region.tabIndex = 0;
|
|
120
|
+
region.setAttribute("role", "region");
|
|
121
|
+
region.setAttribute("aria-label", `${options.label} values`);
|
|
122
|
+
const table = node("table");
|
|
123
|
+
table.className = "table";
|
|
124
|
+
table.append(
|
|
125
|
+
node("caption", `${options.label} — ${options.unit || "unitless"}, UTC`),
|
|
126
|
+
);
|
|
127
|
+
const head = node("thead"),
|
|
128
|
+
row = node("tr");
|
|
129
|
+
for (const title of [
|
|
130
|
+
"Series",
|
|
131
|
+
"Time (UTC)",
|
|
132
|
+
`Value${options.unit ? ` (${options.unit})` : ""}`,
|
|
133
|
+
]) {
|
|
134
|
+
const th = node("th", title);
|
|
135
|
+
th.setAttribute("scope", "col");
|
|
136
|
+
row.append(th);
|
|
137
|
+
}
|
|
138
|
+
head.append(row);
|
|
139
|
+
table.append(head);
|
|
140
|
+
const body = node("tbody");
|
|
141
|
+
for (const s of model.series)
|
|
142
|
+
for (const p of s.points) {
|
|
143
|
+
const tr = node("tr");
|
|
144
|
+
tr.append(
|
|
145
|
+
node("td", s.label),
|
|
146
|
+
node("td", formatChartTime(p.time)),
|
|
147
|
+
node("td", p.value === null ? "Not reported" : String(p.value)),
|
|
148
|
+
);
|
|
149
|
+
body.append(tr);
|
|
150
|
+
}
|
|
151
|
+
table.append(body);
|
|
152
|
+
region.append(table);
|
|
153
|
+
details.append(region);
|
|
154
|
+
figure.append(details);
|
|
155
|
+
root.replaceChildren(figure);
|
|
156
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export interface TimeSeriesPoint {
|
|
2
|
+
time: number;
|
|
3
|
+
value: number | null;
|
|
4
|
+
}
|
|
5
|
+
export interface TimeSeries {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
points: TimeSeriesPoint[];
|
|
9
|
+
}
|
|
10
|
+
export interface TimeSeriesChartOptions {
|
|
11
|
+
label: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
series: TimeSeries[];
|
|
14
|
+
unit?: string;
|
|
15
|
+
timeDomain?: [number, number];
|
|
16
|
+
valueDomain?: [number, number];
|
|
17
|
+
maxGapMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface TimeSeriesChartElement extends HTMLElement {
|
|
20
|
+
options: TimeSeriesChartOptions;
|
|
21
|
+
series: TimeSeries[];
|
|
22
|
+
}
|
|
23
|
+
export function formatChartTime(time: number): string {
|
|
24
|
+
return new Date(time)
|
|
25
|
+
.toISOString()
|
|
26
|
+
.replace("T", " ")
|
|
27
|
+
.replace(".000Z", " UTC")
|
|
28
|
+
.replace("Z", " UTC");
|
|
29
|
+
}
|
|
30
|
+
function domain(
|
|
31
|
+
explicit: [number, number] | undefined,
|
|
32
|
+
values: number[],
|
|
33
|
+
fallback: [number, number],
|
|
34
|
+
constantPadding?: number,
|
|
35
|
+
): [number, number] {
|
|
36
|
+
if (explicit) {
|
|
37
|
+
if (!explicit.every(Number.isFinite) || explicit[0] >= explicit[1])
|
|
38
|
+
throw new RangeError("Chart domains must be finite and increasing");
|
|
39
|
+
return [...explicit];
|
|
40
|
+
}
|
|
41
|
+
if (!values.length) return fallback;
|
|
42
|
+
let lo = Infinity,
|
|
43
|
+
hi = -Infinity;
|
|
44
|
+
for (const value of values) {
|
|
45
|
+
lo = Math.min(lo, value);
|
|
46
|
+
hi = Math.max(hi, value);
|
|
47
|
+
}
|
|
48
|
+
if (lo === hi) {
|
|
49
|
+
const padding = constantPadding ?? Math.max(Math.abs(lo) * 0.05, 1);
|
|
50
|
+
return [lo - padding, hi + padding];
|
|
51
|
+
}
|
|
52
|
+
return [lo, hi];
|
|
53
|
+
}
|
|
54
|
+
/** Coordinates use a normalized 0–100 plot. Null/nonfinite values break lines. */
|
|
55
|
+
export function buildTimeSeries(options: TimeSeriesChartOptions) {
|
|
56
|
+
if (
|
|
57
|
+
options.maxGapMs !== undefined &&
|
|
58
|
+
(!Number.isFinite(options.maxGapMs) || options.maxGapMs <= 0)
|
|
59
|
+
)
|
|
60
|
+
throw new RangeError("maxGapMs must be positive and finite");
|
|
61
|
+
const ids = new Set<string>();
|
|
62
|
+
const series = options.series.map((series) => {
|
|
63
|
+
if (ids.has(series.id)) throw new Error("Series IDs must be unique");
|
|
64
|
+
ids.add(series.id);
|
|
65
|
+
return {
|
|
66
|
+
...series,
|
|
67
|
+
points: series.points
|
|
68
|
+
.filter((p) => Number.isFinite(p.time) && Math.abs(p.time) <= 8.64e15)
|
|
69
|
+
.map((p) => ({
|
|
70
|
+
...p,
|
|
71
|
+
value: p.value !== null && Number.isFinite(p.value) ? p.value : null,
|
|
72
|
+
}))
|
|
73
|
+
.sort((a, b) => a.time - b.time),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
const times = series.flatMap((s) => s.points.map((p) => p.time));
|
|
77
|
+
const values = series.flatMap((s) =>
|
|
78
|
+
s.points.filter((p) => p.value !== null).map((p) => p.value!),
|
|
79
|
+
);
|
|
80
|
+
const timeDomain = domain(options.timeDomain, times, [0, 1], 1000);
|
|
81
|
+
if (timeDomain.some((value) => Math.abs(value) > 8.64e15))
|
|
82
|
+
throw new RangeError("Time domain must contain representable dates");
|
|
83
|
+
const valueDomain = domain(options.valueDomain, values, [0, 1]);
|
|
84
|
+
const scale = (n: number, d: [number, number]) =>
|
|
85
|
+
(100 * (n - d[0])) / (d[1] - d[0]);
|
|
86
|
+
return {
|
|
87
|
+
timeDomain,
|
|
88
|
+
valueDomain,
|
|
89
|
+
empty: !values.length,
|
|
90
|
+
series: series.map((s) => {
|
|
91
|
+
const segments: { x: number; y: number }[][] = [];
|
|
92
|
+
let segment: { x: number; y: number }[] = [];
|
|
93
|
+
let previous: number | undefined;
|
|
94
|
+
for (const p of s.points) {
|
|
95
|
+
if (
|
|
96
|
+
p.value === null ||
|
|
97
|
+
(previous !== undefined &&
|
|
98
|
+
options.maxGapMs !== undefined &&
|
|
99
|
+
p.time - previous > options.maxGapMs)
|
|
100
|
+
) {
|
|
101
|
+
if (segment.length) segments.push(segment);
|
|
102
|
+
segment = [];
|
|
103
|
+
}
|
|
104
|
+
if (p.value !== null)
|
|
105
|
+
segment.push({
|
|
106
|
+
x: scale(p.time, timeDomain),
|
|
107
|
+
y: 100 - scale(p.value, valueDomain),
|
|
108
|
+
});
|
|
109
|
+
previous = p.time;
|
|
110
|
+
}
|
|
111
|
+
if (segment.length) segments.push(segment);
|
|
112
|
+
return { ...s, segments };
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -2233,3 +2233,24 @@ sierra-copy [data-copy-check] {
|
|
|
2233
2233
|
.ds-values tr:last-child > :is(th, td) {
|
|
2234
2234
|
border-bottom: 0;
|
|
2235
2235
|
}
|
|
2236
|
+
|
|
2237
|
+
/* Time series: colors identify series, never source health or severity. */
|
|
2238
|
+
.ds-time-series { margin: 0; min-width: 0; }
|
|
2239
|
+
.ds-time-series svg { display: block; width: 100%; height: auto; overflow: hidden; }
|
|
2240
|
+
.ds-time-series text { fill: var(--ink-secondary); font-family: var(--font-mono); font-size: var(--fs-small); }
|
|
2241
|
+
.ds-time-series figcaption p, .ds-time-series > p { color: var(--ink-secondary); font-size: var(--fs-small); }
|
|
2242
|
+
.ds-chart-axis { fill: none; stroke: var(--line-control); }
|
|
2243
|
+
.ds-chart-series { fill: none; stroke: currentColor; stroke-width: 2; }
|
|
2244
|
+
.ds-chart-dot { fill: currentColor; }
|
|
2245
|
+
.ds-chart-series-0 { color: var(--brand-green); }
|
|
2246
|
+
.ds-chart-series-1 { color: var(--brand-brass-text); }
|
|
2247
|
+
.ds-chart-series-2 { color: var(--ink-secondary); }
|
|
2248
|
+
.ds-chart-series-3 { color: var(--ink-strong); }
|
|
2249
|
+
.ds-chart-legend { display: flex; flex-wrap: wrap; gap: var(--space-4); list-style: none; padding: 0; }
|
|
2250
|
+
.ds-chart-legend li { overflow-wrap: anywhere; }
|
|
2251
|
+
.ds-chart-swatch { display: inline-block; width: var(--space-6); border-top: 2px solid currentColor; margin-right: var(--space-2); vertical-align: middle; }
|
|
2252
|
+
.ds-chart-swatch.ds-chart-series-1 { border-top-style: dashed; }
|
|
2253
|
+
.ds-chart-swatch.ds-chart-series-2 { border-top-style: dotted; }
|
|
2254
|
+
.ds-chart-swatch.ds-chart-series-3 { border-top-style: double; border-top-width: 3px; }
|
|
2255
|
+
.ds-time-series summary { cursor: pointer; }
|
|
2256
|
+
.ds-time-series td { overflow-wrap: anywhere; }
|