@celestia-island/hikari 0.55.274 → 0.55.275
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 +1 -1
- package/src/components/HkBarChart.scss +28 -2
- package/src/components/HkBarChart.test.tsx +126 -0
- package/src/components/HkBarChart.tsx +119 -20
- package/src/components/HkTrendChart.test.tsx +307 -1
- package/src/components/HkTrendChart.tsx +58 -6
- package/src/components/barChart.ts +12 -3
- package/src/components/trendGeometry.ts +330 -12
package/package.json
CHANGED
|
@@ -40,15 +40,41 @@
|
|
|
40
40
|
font-variant-numeric: tabular-nums;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Above-bar value label (showValues) — the same 10-unit voice as the
|
|
44
|
+
// axis, a step stronger so the count reads first.
|
|
45
|
+
&__value {
|
|
46
|
+
fill: rgb(var(--color-text) / 0.75);
|
|
47
|
+
font-size: 10px;
|
|
48
|
+
text-anchor: middle;
|
|
49
|
+
font-variant-numeric: tabular-nums;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Slot mark (a bar's icon — the supership Star): a nested <svg> whose
|
|
53
|
+
// ink follows currentColor, so lucide's stroke picks up the axis voice
|
|
54
|
+
// without per-icon wiring.
|
|
55
|
+
&__xmark {
|
|
56
|
+
color: rgb(var(--color-text) / 0.55);
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
&__bar {
|
|
44
60
|
fill: rgb(var(--color-primary));
|
|
45
61
|
outline: none;
|
|
46
62
|
cursor: pointer;
|
|
47
|
-
transition: filter 0.16s ease;
|
|
63
|
+
transition: filter 0.16s ease, opacity 0.16s ease;
|
|
48
64
|
|
|
49
65
|
&:hover,
|
|
50
|
-
&:focus-visible
|
|
66
|
+
&:focus-visible,
|
|
67
|
+
// Dim-pair head (activeIndex): keeps the hover lift while the
|
|
68
|
+
// pointer may sit anywhere — the host pairs bars by wiring
|
|
69
|
+
// activeIndex from the hover events.
|
|
70
|
+
&[data-hot] {
|
|
51
71
|
filter: brightness(1.08) saturate(1.15);
|
|
52
72
|
}
|
|
73
|
+
|
|
74
|
+
// Dim-pair follower: every other bar recedes, same ease family as
|
|
75
|
+
// the lift so the pair breathes together.
|
|
76
|
+
&[data-dim] {
|
|
77
|
+
opacity: 0.45;
|
|
78
|
+
}
|
|
53
79
|
}
|
|
54
80
|
}
|
|
@@ -27,6 +27,10 @@ const BARS = [
|
|
|
27
27
|
{ label: "Wed", value: 0 },
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
+
/** A tiny stand-in for a lucide glyph: any icon FUNCTION draws the
|
|
31
|
+
* 24-unit box the slot-mark wrapper maps onto its 12-unit mark. */
|
|
32
|
+
const StarGlyph = () => <rect x="0" y="0" width="24" height="24" />;
|
|
33
|
+
|
|
30
34
|
describe("HkBarChart", () => {
|
|
31
35
|
it("renders one labeled bar per bucket and skips zero buckets' paths", () => {
|
|
32
36
|
const el = mount(<HkBarChart bars={BARS} />);
|
|
@@ -59,4 +63,126 @@ describe("HkBarChart", () => {
|
|
|
59
63
|
"weekly playtime",
|
|
60
64
|
);
|
|
61
65
|
});
|
|
66
|
+
|
|
67
|
+
// ── showValues ───────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
it("showValues renders formatValue above each bar and skips zero buckets", () => {
|
|
70
|
+
const el = mount(
|
|
71
|
+
<HkBarChart bars={BARS} showValues formatValue={(v) => `${Math.round(v / 3600)}h`} />,
|
|
72
|
+
);
|
|
73
|
+
const values = [...el.querySelectorAll(".hk-bar-chart__value")];
|
|
74
|
+
// The zero bucket (Wed) carries no label — the wowsp tier histogram's
|
|
75
|
+
// old labelY-null behavior: an empty slot never shows a stray "0".
|
|
76
|
+
expect(values.map((t) => t.textContent)).toEqual(["1h", "2h"]);
|
|
77
|
+
// Each label's baseline sits exactly 5 units above its bar's top —
|
|
78
|
+
// the path's y coordinates sit at odd indices ("M x0 y L x0 y… Q x y
|
|
79
|
+
// x y …"), and their minimum IS the top.
|
|
80
|
+
const topOf = (d: string) => {
|
|
81
|
+
const nums = d.split(/[\sMLQAZ]+/).filter(Boolean).map(Number);
|
|
82
|
+
return Math.min(...nums.filter((_, i) => i % 2 === 1));
|
|
83
|
+
};
|
|
84
|
+
const tops = [...el.querySelectorAll(".hk-bar-chart__bar")].map((p) =>
|
|
85
|
+
topOf(p.getAttribute("d") ?? ""),
|
|
86
|
+
);
|
|
87
|
+
expect(Number(values[0].getAttribute("y"))).toBeCloseTo(tops[0] - 5, 6);
|
|
88
|
+
expect(Number(values[1].getAttribute("y"))).toBeCloseTo(tops[1] - 5, 6);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("showValues off by default — old usage renders no value labels", () => {
|
|
92
|
+
const el = mount(<HkBarChart bars={BARS} />);
|
|
93
|
+
expect(el.querySelectorAll(".hk-bar-chart__value")).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── hover event + activeIndex dim-pair ───────────────────────────
|
|
97
|
+
|
|
98
|
+
it("emits hover on bar mouseenter / mouseleave with focus / blur parity", () => {
|
|
99
|
+
const hovers: (number | null)[] = [];
|
|
100
|
+
const el = mount(<HkBarChart bars={BARS} onHover={(i) => hovers.push(i)} />);
|
|
101
|
+
const bars = [...el.querySelectorAll(".hk-bar-chart__bar")];
|
|
102
|
+
bars[0].dispatchEvent(new Event("mouseenter"));
|
|
103
|
+
bars[1].dispatchEvent(new Event("focus"));
|
|
104
|
+
bars[1].dispatchEvent(new Event("blur"));
|
|
105
|
+
bars[0].dispatchEvent(new Event("mouseleave"));
|
|
106
|
+
expect(hovers).toEqual([0, 1, null, null]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("activeIndex carries data-hot on the paired bar and data-dim on the others", () => {
|
|
110
|
+
const el = mount(<HkBarChart bars={BARS} activeIndex={0} />);
|
|
111
|
+
const bars = [...el.querySelectorAll(".hk-bar-chart__bar")];
|
|
112
|
+
expect(bars[0].getAttribute("data-hot")).toBe("true");
|
|
113
|
+
expect(bars[0].hasAttribute("data-dim")).toBe(false);
|
|
114
|
+
expect(bars[1].getAttribute("data-dim")).toBe("true");
|
|
115
|
+
expect(bars[1].hasAttribute("data-hot")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("defaults to no pair — no data-hot / data-dim anywhere", () => {
|
|
119
|
+
const el = mount(<HkBarChart bars={BARS} />);
|
|
120
|
+
expect(el.querySelector("[data-hot], [data-dim]")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── icon slot mark (the supership Star) ──────────────────────────
|
|
124
|
+
|
|
125
|
+
it("renders a resolvable icon in place of the x-label text, sized and centered", () => {
|
|
126
|
+
const el = mount(
|
|
127
|
+
<HkBarChart
|
|
128
|
+
bars={[
|
|
129
|
+
{ label: "10", value: 5, icon: StarGlyph },
|
|
130
|
+
{ label: "11", value: 7 },
|
|
131
|
+
]}
|
|
132
|
+
/>,
|
|
133
|
+
);
|
|
134
|
+
const marks = [...el.querySelectorAll(".hk-bar-chart__xmark")];
|
|
135
|
+
expect(marks).toHaveLength(1);
|
|
136
|
+
// The nested-svg wrapper maps the 24-unit lucide box onto a 12-unit
|
|
137
|
+
// mark centered on slot 0 (centerX 182) at the x-label's optical
|
|
138
|
+
// center (baselineY 196 + 4).
|
|
139
|
+
expect(marks[0].getAttribute("viewBox")).toBe("0 0 24 24");
|
|
140
|
+
expect(marks[0].getAttribute("width")).toBe("12");
|
|
141
|
+
expect(marks[0].getAttribute("height")).toBe("12");
|
|
142
|
+
expect(marks[0].getAttribute("x")).toBe("176");
|
|
143
|
+
expect(marks[0].getAttribute("y")).toBe("200");
|
|
144
|
+
// The icon swapped out that slot's text; the other slot keeps its text.
|
|
145
|
+
expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual([
|
|
146
|
+
"11",
|
|
147
|
+
]);
|
|
148
|
+
// The bar's aria string keeps the TEXT label, icon or not.
|
|
149
|
+
expect(el.querySelector(".hk-bar-chart__bar")?.getAttribute("aria-label")).toContain("10");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("normalizes icons like HkPillToggleGroup — vnode passes through, junk falls back to text", () => {
|
|
153
|
+
const el = mount(
|
|
154
|
+
<HkBarChart
|
|
155
|
+
bars={[
|
|
156
|
+
{ label: "a", value: 3, icon: <rect x="4" y="4" width="16" height="16" /> },
|
|
157
|
+
{ label: "b", value: 4, icon: "not-an-icon" },
|
|
158
|
+
]}
|
|
159
|
+
/>,
|
|
160
|
+
);
|
|
161
|
+
// The prebuilt vnode rides the mark wrapper; the junk string does not
|
|
162
|
+
// blank the slot — it falls back to the text label.
|
|
163
|
+
expect(el.querySelectorAll(".hk-bar-chart__xmark")).toHaveLength(1);
|
|
164
|
+
expect(el.querySelectorAll(".hk-bar-chart__xmark rect")).toHaveLength(1);
|
|
165
|
+
expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual(["b"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── back-compat: the wowsp PlaytimeView usage surface ────────────
|
|
169
|
+
|
|
170
|
+
it("old-props usage renders exactly the old surface", () => {
|
|
171
|
+
const el = mount(
|
|
172
|
+
<HkBarChart
|
|
173
|
+
bars={BARS.map((b) => ({ label: b.label, value: b.value, hint: b.hint }))}
|
|
174
|
+
width={480}
|
|
175
|
+
formatValue={(v) => `${Math.round(v / 3600)}h`}
|
|
176
|
+
/>,
|
|
177
|
+
);
|
|
178
|
+
expect(el.querySelectorAll(".hk-bar-chart__bar")).toHaveLength(2);
|
|
179
|
+
expect(el.querySelectorAll(".hk-bar-chart__value")).toHaveLength(0);
|
|
180
|
+
expect(el.querySelectorAll(".hk-bar-chart__xmark")).toHaveLength(0);
|
|
181
|
+
expect(el.querySelector("[data-hot], [data-dim]")).toBeNull();
|
|
182
|
+
expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual([
|
|
183
|
+
"Mon",
|
|
184
|
+
"Tue",
|
|
185
|
+
"Wed",
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
62
188
|
});
|
|
@@ -1,8 +1,43 @@
|
|
|
1
|
-
import { computed, defineComponent, type PropType } from "vue";
|
|
1
|
+
import { computed, defineComponent, h, type PropType, type VNode } from "vue";
|
|
2
2
|
|
|
3
3
|
import { barLayout, type BarSlot } from "./barChart";
|
|
4
4
|
import "./HkBarChart.scss";
|
|
5
5
|
|
|
6
|
+
/** One bar entry. */
|
|
7
|
+
export interface HkBarChartBar {
|
|
8
|
+
/** Text label under the slot — also the bar's aria string, even when
|
|
9
|
+
* an `icon` replaces the visible text. */
|
|
10
|
+
label: string;
|
|
11
|
+
value: number;
|
|
12
|
+
/** Hover/focus hint; defaults to `label: formatValue(value)`. */
|
|
13
|
+
hint?: string;
|
|
14
|
+
/** Optional slot mark rendered IN PLACE of the x-label text (the tier
|
|
15
|
+
* histogram's supership Star). Accepts EITHER a component (a lucide
|
|
16
|
+
* icon function, a defineComponent options object) or a prebuilt
|
|
17
|
+
* vnode — normalized exactly like HkPillToggleGroup's icon handling;
|
|
18
|
+
* anything else falls back to the text label (never a blank slot). */
|
|
19
|
+
icon?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Normalize a bar icon to a renderable vnode — the HkPillToggleGroup
|
|
23
|
+
* convention verbatim: a component (function or options object) is
|
|
24
|
+
* instantiated with `h`; a prebuilt vnode passes through; anything
|
|
25
|
+
* else (string, number, null) renders nothing, and the caller falls
|
|
26
|
+
* back to the text label. */
|
|
27
|
+
function resolveIcon(icon: unknown): VNode | null {
|
|
28
|
+
if (icon == null) return null;
|
|
29
|
+
if (typeof icon === "object") return icon as VNode;
|
|
30
|
+
if (typeof icon === "function") return h(icon as Parameters<typeof h>[0]);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The slot-mark box (wowsp's tier-star convention): lucide glyphs draw
|
|
35
|
+
* in a 24×24 box, so the wrapper nested <svg> maps that box onto a
|
|
36
|
+
* 12-unit mark centered on the x-label's optical center — the 10-unit
|
|
37
|
+
* text's baseline is baselineY + 14 and its glyph box spans
|
|
38
|
+
* ≈ baselineY + 6 … + 14, so a mark from +4 to +16 shares its center. */
|
|
39
|
+
const BAR_MARK = { glyphBox: 24, size: 12, top: 4 } as const;
|
|
40
|
+
|
|
6
41
|
/**
|
|
7
42
|
* HkBarChart — one bar per bucket over a hand-drawn declarative SVG (no
|
|
8
43
|
* chart library; the HkTreemap/HkCalendarHeatmap dependency-free line).
|
|
@@ -16,6 +51,15 @@ import "./HkBarChart.scss";
|
|
|
16
51
|
* wowsp delegated-tooltip hook reads — hikari itself renders no native
|
|
17
52
|
* title bubble).
|
|
18
53
|
*
|
|
54
|
+
* The tier histogram's needs ride along as OPTIONAL props (wowsp
|
|
55
|
+
* ShipDistCharts): `showValues` paints formatValue(value) above each
|
|
56
|
+
* bar (empty slots carry no label), `hover` + `activeIndex` implement a
|
|
57
|
+
* dim-pair — the HOST wires activeIndex from the hover events, the bar
|
|
58
|
+
* at activeIndex carries `data-hot` and every other bar `data-dim` —
|
|
59
|
+
* and a bar's `icon` replaces its x-label text (the supership slot's
|
|
60
|
+
* nested-svg Star, positioned/sized here from the 24-unit lucide box).
|
|
61
|
+
* Defaults reproduce the old output exactly.
|
|
62
|
+
*
|
|
19
63
|
* Dumb by design: the caller owns aggregation and passes the settled
|
|
20
64
|
* buckets in. All geometry lives in ./barChart (pure, pinned by its own
|
|
21
65
|
* tests).
|
|
@@ -27,7 +71,7 @@ export default defineComponent({
|
|
|
27
71
|
props: {
|
|
28
72
|
/** The bars, in axis order (oldest → newest for trend charts). */
|
|
29
73
|
bars: {
|
|
30
|
-
type: Array as PropType<
|
|
74
|
+
type: Array as PropType<HkBarChartBar[]>,
|
|
31
75
|
required: true,
|
|
32
76
|
},
|
|
33
77
|
/** Frame size (the SVG viewBox; CSS scales it). */
|
|
@@ -42,10 +86,25 @@ export default defineComponent({
|
|
|
42
86
|
ariaLabel: { type: String, default: null },
|
|
43
87
|
/** Rendered when every bucket is zero / the array is empty. */
|
|
44
88
|
emptyText: { type: String, default: "No data" },
|
|
89
|
+
/** Paint formatValue(value) as a small label ABOVE each bar. Skip
|
|
90
|
+
* rule: bars with value <= 0 carry NO label (their slot stays an
|
|
91
|
+
* empty slot) — the wowsp tier histogram's old labelY-null behavior,
|
|
92
|
+
* so an unplayed bucket never shows a stray "0". */
|
|
93
|
+
showValues: { type: Boolean, default: false },
|
|
94
|
+
/** The dim-pair head: when set, the bar at this index carries
|
|
95
|
+
* `data-hot` (kept hover lift) and every other bar carries
|
|
96
|
+
* `data-dim` (≈0.45 opacity). The HOST implements the pair by
|
|
97
|
+
* wiring this from the `hover` events; null (the default) draws no
|
|
98
|
+
* pair at all. */
|
|
99
|
+
activeIndex: { type: Number as PropType<number | null>, default: null },
|
|
45
100
|
},
|
|
46
101
|
emits: {
|
|
47
102
|
/** A bar was clicked (its bucket index). */
|
|
48
103
|
select: (index: number) => typeof index === "number",
|
|
104
|
+
/** The pointer entered a bar (its bucket index) or left it / the
|
|
105
|
+
* keyboard focus moved onto or off one (focus/blur parity — null
|
|
106
|
+
* when the hover ended). */
|
|
107
|
+
hover: (index: number | null) => typeof index === "number" || index === null,
|
|
49
108
|
},
|
|
50
109
|
setup(props, { emit }) {
|
|
51
110
|
const layout = computed(() =>
|
|
@@ -60,7 +119,12 @@ export default defineComponent({
|
|
|
60
119
|
props.ariaLabel ??
|
|
61
120
|
props.bars.map((b) => `${b.label} ${props.formatValue(b.value)}`).join(", "),
|
|
62
121
|
);
|
|
63
|
-
|
|
122
|
+
/** The dim-pair head, normalized: anything but a number (undefined,
|
|
123
|
+
* null, garbage) means "no pair". */
|
|
124
|
+
const active = computed(() =>
|
|
125
|
+
typeof props.activeIndex === "number" ? props.activeIndex : null,
|
|
126
|
+
);
|
|
127
|
+
const hintOf = (b: HkBarChartBar, i: number): string =>
|
|
64
128
|
b.hint ?? `${b.label}: ${props.formatValue(b.value)}`;
|
|
65
129
|
|
|
66
130
|
return () => {
|
|
@@ -90,23 +154,58 @@ export default defineComponent({
|
|
|
90
154
|
</text>
|
|
91
155
|
</g>
|
|
92
156
|
))}
|
|
93
|
-
{l.bars.map((s: BarSlot, i: number) =>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
157
|
+
{l.bars.map((s: BarSlot, i: number) => {
|
|
158
|
+
const bar = props.bars[i];
|
|
159
|
+
const mark = resolveIcon(bar.icon);
|
|
160
|
+
return (
|
|
161
|
+
<g key={i}>
|
|
162
|
+
{s.path ? (
|
|
163
|
+
<path
|
|
164
|
+
class="hk-bar-chart__bar"
|
|
165
|
+
d={s.path}
|
|
166
|
+
data-hint={hintOf(bar, i)}
|
|
167
|
+
aria-label={hintOf(bar, i)}
|
|
168
|
+
tabindex={0}
|
|
169
|
+
data-hot={active.value === i || undefined}
|
|
170
|
+
data-dim={(active.value !== null && active.value !== i) || undefined}
|
|
171
|
+
onClick={() => emit("select", i)}
|
|
172
|
+
onMouseenter={() => emit("hover", i)}
|
|
173
|
+
onMouseleave={() => emit("hover", null)}
|
|
174
|
+
onFocus={() => emit("hover", i)}
|
|
175
|
+
onBlur={() => emit("hover", null)}
|
|
176
|
+
/>
|
|
177
|
+
) : null}
|
|
178
|
+
{props.showValues && s.labelY != null ? (
|
|
179
|
+
<text class="hk-bar-chart__value" x={s.centerX} y={s.labelY}>
|
|
180
|
+
{props.formatValue(bar.value)}
|
|
181
|
+
</text>
|
|
182
|
+
) : null}
|
|
183
|
+
{mark != null ? (
|
|
184
|
+
// The slot mark (the supership's Star): a nested <svg>
|
|
185
|
+
// wrapper maps the icon's 24-unit glyph box onto a
|
|
186
|
+
// 12-unit mark centered on the x-label's optical
|
|
187
|
+
// center — positioned HERE so the consumer passes a
|
|
188
|
+
// bare lucide component and knows no geometry. Ink
|
|
189
|
+
// (currentColor) lives in SCSS.
|
|
190
|
+
<svg
|
|
191
|
+
class="hk-bar-chart__xmark"
|
|
192
|
+
x={s.centerX - BAR_MARK.size / 2}
|
|
193
|
+
y={l.baselineY + BAR_MARK.top}
|
|
194
|
+
width={BAR_MARK.size}
|
|
195
|
+
height={BAR_MARK.size}
|
|
196
|
+
viewBox={`0 0 ${BAR_MARK.glyphBox} ${BAR_MARK.glyphBox}`}
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
>
|
|
199
|
+
{mark}
|
|
200
|
+
</svg>
|
|
201
|
+
) : (
|
|
202
|
+
<text class="hk-bar-chart__x" x={s.centerX} y={l.baselineY + 14}>
|
|
203
|
+
{bar.label}
|
|
204
|
+
</text>
|
|
205
|
+
)}
|
|
206
|
+
</g>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
110
209
|
</svg>
|
|
111
210
|
);
|
|
112
211
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Component-side cases for the SVG trend chart (the mirror's render-layer
|
|
2
2
|
// cases, mounted for real through happy-dom). The pure geometry cases live
|
|
3
3
|
// in ./trendGeometry.test.ts, ported from the Rust mirror's cfg(test).
|
|
4
|
+
// The wowsp-migration cases (named guides, dot ink, axis text, yDomain)
|
|
5
|
+
// pin the optional extensions and the byte-identical legacy default.
|
|
4
6
|
//
|
|
5
7
|
// AI-generated (glm-5.3-flash via DeepSeek Harness), 2026-11-03 — SySL-1.0 §2.3.
|
|
6
8
|
|
|
@@ -12,6 +14,7 @@ import { createApp, h, nextTick, ref } from "vue";
|
|
|
12
14
|
import { afterEach, describe, expect, it } from "vitest";
|
|
13
15
|
|
|
14
16
|
import HkTrendChart from "./HkTrendChart";
|
|
17
|
+
import { renderTrendChartSvg } from "./trendGeometry";
|
|
15
18
|
|
|
16
19
|
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
17
20
|
|
|
@@ -32,6 +35,15 @@ afterEach(() => {
|
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
const CPU = { label: "cpu", data: [{ time: 0, value: 1 }, { time: 5000, value: 4 }] };
|
|
38
|
+
/** A [0, 100] series: y(v) = 200 - 1.7v, so tier values land on clean pins. */
|
|
39
|
+
const TIER = { label: "tier", data: [{ time: 0, value: 0 }, { time: 5000, value: 100 }] };
|
|
40
|
+
/** A [62, 78] series: the stepped-domain fixture (a padded PR percentage). */
|
|
41
|
+
const PR = { label: "pr", data: [{ time: 0, value: 62 }, { time: 5000, value: 78 }] };
|
|
42
|
+
/** Three samples: enough for three unclamped x ticks. */
|
|
43
|
+
const TRIPLE = {
|
|
44
|
+
label: "cpu",
|
|
45
|
+
data: [{ time: 0, value: 1 }, { time: 2500, value: 2.5 }, { time: 5000, value: 4 }],
|
|
46
|
+
};
|
|
35
47
|
|
|
36
48
|
describe("HkTrendChart", () => {
|
|
37
49
|
it("renders the mirror's DOM contract on its own contents host", () => {
|
|
@@ -140,6 +152,300 @@ describe("HkTrendChart", () => {
|
|
|
140
152
|
);
|
|
141
153
|
});
|
|
142
154
|
|
|
155
|
+
it("keeps the legacy output byte-identical when every new prop is absent", () => {
|
|
156
|
+
// The exact legacy frame, pinned end to end: no guides, no dots, no
|
|
157
|
+
// axis text — the mirror's markup, byte for byte.
|
|
158
|
+
const svg = renderTrendChartSvg([CPU], { now: 5000, timeWindowMs: 5000 });
|
|
159
|
+
expect(svg).toBe(
|
|
160
|
+
`<svg class="hk-trend-chart__svg" viewBox="0 0 640 240" preserveAspectRatio="none" style="display:block;width:100%;height:100%">` +
|
|
161
|
+
`<path class="hk-trend-chart__area" d="M 50 200 L 620 30 L 620 200 L 50 200 Z" fill="rgb(var(--color-primary))" fill-opacity="0.12" stroke="none"/>` +
|
|
162
|
+
`<polyline class="hk-trend-chart__line" points="50,200 620,30" fill="none" stroke="rgb(var(--color-primary))" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>` +
|
|
163
|
+
`</svg>`,
|
|
164
|
+
);
|
|
165
|
+
// The mounted component injects exactly that string — every new prop's
|
|
166
|
+
// default is a no-op. happy-dom re-serializes SVG self-closing tags, so
|
|
167
|
+
// the mount is compared against the pinned string parsed through the
|
|
168
|
+
// same DOM lens (the pure-string equality above already pins the exact
|
|
169
|
+
// bytes).
|
|
170
|
+
const el = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
|
|
171
|
+
const parsed = document.createElement("div");
|
|
172
|
+
parsed.innerHTML = svg;
|
|
173
|
+
expect(el.querySelector("svg.hk-trend-chart__svg")!.outerHTML).toBe(
|
|
174
|
+
parsed.querySelector("svg.hk-trend-chart__svg")!.outerHTML,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("renders chart-level named guides with labels, color and hint", () => {
|
|
179
|
+
const el = mount({
|
|
180
|
+
pens: [TIER],
|
|
181
|
+
now: 5000,
|
|
182
|
+
timeWindowMs: 5000,
|
|
183
|
+
guides: [{ value: 60, label: "S", color: "#00AA00", dash: "2 2", hint: "s-tier" }],
|
|
184
|
+
});
|
|
185
|
+
// y(60) on the [0, 100] domain: 200 - 0.6 * 170 = 98.
|
|
186
|
+
const guide = el.querySelector("line.hk-trend-chart__guide")!;
|
|
187
|
+
expect(guide).not.toBeNull();
|
|
188
|
+
expect(guide.getAttribute("x1")).toBe("50");
|
|
189
|
+
expect(guide.getAttribute("x2")).toBe("620");
|
|
190
|
+
expect(guide.getAttribute("y1")).toBe("98");
|
|
191
|
+
expect(guide.getAttribute("y2")).toBe("98");
|
|
192
|
+
expect(guide.getAttribute("stroke")).toBe("#00AA00");
|
|
193
|
+
expect(guide.getAttribute("stroke-width")).toBe("1");
|
|
194
|
+
expect(guide.getAttribute("stroke-dasharray")).toBe("2 2");
|
|
195
|
+
expect(guide.getAttribute("data-hint")).toBe("s-tier");
|
|
196
|
+
expect(guide.hasAttribute("stroke-opacity")).toBe(false);
|
|
197
|
+
// The tier label sits inside the plot near the left edge, one step
|
|
198
|
+
// above its line, in the guide's color.
|
|
199
|
+
const label = el.querySelector("text.hk-trend-chart__guide-label")!;
|
|
200
|
+
expect(label.textContent).toBe("S");
|
|
201
|
+
expect(label.getAttribute("x")).toBe("56");
|
|
202
|
+
expect(label.getAttribute("y")).toBe("94");
|
|
203
|
+
expect(label.getAttribute("fill")).toBe("#00AA00");
|
|
204
|
+
expect(label.getAttribute("font-size")).toBe("10");
|
|
205
|
+
expect(label.getAttribute("text-anchor")).toBe("start");
|
|
206
|
+
|
|
207
|
+
// Defaults: a quiet currentColor line with the alarm dashed flavor,
|
|
208
|
+
// no label, no hint.
|
|
209
|
+
const quiet = mount({ pens: [TIER], now: 5000, timeWindowMs: 5000, guides: [{ value: 40 }] });
|
|
210
|
+
const line = quiet.querySelector("line.hk-trend-chart__guide")!;
|
|
211
|
+
expect(line.getAttribute("y1")).toBe("132");
|
|
212
|
+
expect(line.getAttribute("stroke")).toBe("currentColor");
|
|
213
|
+
expect(line.getAttribute("stroke-opacity")).toBe("0.35");
|
|
214
|
+
expect(line.getAttribute("stroke-dasharray")).toBe("6 4");
|
|
215
|
+
expect(line.hasAttribute("data-hint")).toBe(false);
|
|
216
|
+
expect(quiet.querySelectorAll("text.hk-trend-chart__guide-label")).toHaveLength(0);
|
|
217
|
+
|
|
218
|
+
// Out-of-range guides clip like the alarm lines, labels included.
|
|
219
|
+
const clipped = mount({
|
|
220
|
+
pens: [TIER],
|
|
221
|
+
now: 5000,
|
|
222
|
+
timeWindowMs: 5000,
|
|
223
|
+
guides: [{ value: 150, label: "ghost" }, { value: -20 }],
|
|
224
|
+
});
|
|
225
|
+
expect(clipped.querySelectorAll("line.hk-trend-chart__guide")).toHaveLength(0);
|
|
226
|
+
expect(clipped.querySelectorAll("text.hk-trend-chart__guide-label")).toHaveLength(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("draws per-sample dots with tier ink and hints", () => {
|
|
230
|
+
const el = mount({
|
|
231
|
+
pens: [
|
|
232
|
+
{
|
|
233
|
+
label: "cpu",
|
|
234
|
+
data: [
|
|
235
|
+
{ time: 0, value: 1, fill: "#00FF88" },
|
|
236
|
+
{ time: 2500, value: 2.5, fill: "#FF1744", hint: "spike" },
|
|
237
|
+
{ time: 5000, value: 4 },
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
now: 5000,
|
|
242
|
+
timeWindowMs: 5000,
|
|
243
|
+
});
|
|
244
|
+
const dots = el.querySelectorAll("circle.hk-trend-chart__dot");
|
|
245
|
+
expect(dots).toHaveLength(2);
|
|
246
|
+
expect(dots[0].getAttribute("cx")).toBe("50");
|
|
247
|
+
expect(dots[0].getAttribute("cy")).toBe("200");
|
|
248
|
+
expect(dots[0].getAttribute("r")).toBe("3.5");
|
|
249
|
+
expect(dots[0].getAttribute("fill")).toBe("#00FF88");
|
|
250
|
+
expect(dots[0].hasAttribute("data-hint")).toBe(false);
|
|
251
|
+
expect(dots[1].getAttribute("cx")).toBe("335");
|
|
252
|
+
expect(dots[1].getAttribute("cy")).toBe("115");
|
|
253
|
+
expect(dots[1].getAttribute("fill")).toBe("#FF1744");
|
|
254
|
+
expect(dots[1].getAttribute("data-hint")).toBe("spike");
|
|
255
|
+
// Untagged samples stay dot-free.
|
|
256
|
+
const bare = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
|
|
257
|
+
expect(bare.querySelectorAll("circle.hk-trend-chart__dot")).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("renders y-axis numbers and applies the guide clearance rule", () => {
|
|
261
|
+
const fmt = (value: number) => value.toFixed(1);
|
|
262
|
+
const el = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000, yAxisFormat: fmt });
|
|
263
|
+
// Floor / mid / ceiling of the shown [1, 4] domain, in the left grid
|
|
264
|
+
// gutter, right-anchored at plotLeft - 6.
|
|
265
|
+
const ticks = el.querySelectorAll("text.hk-trend-chart__y-tick");
|
|
266
|
+
expect(ticks).toHaveLength(3);
|
|
267
|
+
expect(ticks[0].getAttribute("x")).toBe("44");
|
|
268
|
+
expect(ticks[0].getAttribute("y")).toBe("203");
|
|
269
|
+
expect(ticks[0].getAttribute("text-anchor")).toBe("end");
|
|
270
|
+
expect(ticks[0].getAttribute("font-size")).toBe("10");
|
|
271
|
+
expect(ticks[0].textContent).toBe("1.0");
|
|
272
|
+
expect(ticks[1].getAttribute("y")).toBe("118");
|
|
273
|
+
expect(ticks[1].textContent).toBe("2.5");
|
|
274
|
+
expect(ticks[2].getAttribute("y")).toBe("33");
|
|
275
|
+
expect(ticks[2].textContent).toBe("4.0");
|
|
276
|
+
|
|
277
|
+
// The floor number always renders; mid/ceiling drop when their
|
|
278
|
+
// gridline sits within yAxisClearancePx of a guide's line. Guide at
|
|
279
|
+
// 2.5 → y 115 (mid), guide at 4 → y 30 (ceiling).
|
|
280
|
+
const dropped = mount({
|
|
281
|
+
pens: [CPU],
|
|
282
|
+
now: 5000,
|
|
283
|
+
timeWindowMs: 5000,
|
|
284
|
+
yAxisFormat: fmt,
|
|
285
|
+
yAxisClearancePx: 9,
|
|
286
|
+
guides: [{ value: 2.5 }, { value: 4 }],
|
|
287
|
+
});
|
|
288
|
+
const kept = dropped.querySelectorAll("text.hk-trend-chart__y-tick");
|
|
289
|
+
expect(kept).toHaveLength(1);
|
|
290
|
+
expect(kept[0].textContent).toBe("1.0");
|
|
291
|
+
// The guides themselves are untouched by the clearance rule.
|
|
292
|
+
expect(dropped.querySelectorAll("line.hk-trend-chart__guide")).toHaveLength(2);
|
|
293
|
+
|
|
294
|
+
// Default clearance is 0: even a coincident guide drops nothing.
|
|
295
|
+
const defaultClearance = mount({
|
|
296
|
+
pens: [CPU],
|
|
297
|
+
now: 5000,
|
|
298
|
+
timeWindowMs: 5000,
|
|
299
|
+
yAxisFormat: fmt,
|
|
300
|
+
guides: [{ value: 2.5 }],
|
|
301
|
+
});
|
|
302
|
+
expect(defaultClearance.querySelectorAll("text.hk-trend-chart__y-tick")).toHaveLength(3);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("renders baseline x ticks when both count and format are given", () => {
|
|
306
|
+
const at = (timeMs: number) => `@${timeMs}`;
|
|
307
|
+
const el = mount({
|
|
308
|
+
pens: [TRIPLE],
|
|
309
|
+
now: 5000,
|
|
310
|
+
timeWindowMs: 5000,
|
|
311
|
+
xTickCount: 3,
|
|
312
|
+
xTickFormat: at,
|
|
313
|
+
});
|
|
314
|
+
// Three evenly spaced ticks across the shown data's [0, 5000] span,
|
|
315
|
+
// under the baseline (plotBottom + 14).
|
|
316
|
+
const ticks = el.querySelectorAll("text.hk-trend-chart__x-tick");
|
|
317
|
+
expect(ticks).toHaveLength(3);
|
|
318
|
+
expect(ticks[0].getAttribute("x")).toBe("50");
|
|
319
|
+
expect(ticks[0].getAttribute("y")).toBe("214");
|
|
320
|
+
expect(ticks[0].getAttribute("text-anchor")).toBe("middle");
|
|
321
|
+
expect(ticks[0].textContent).toBe("@0");
|
|
322
|
+
expect(ticks[1].getAttribute("x")).toBe("335");
|
|
323
|
+
expect(ticks[1].textContent).toBe("@2500");
|
|
324
|
+
expect(ticks[2].getAttribute("x")).toBe("620");
|
|
325
|
+
expect(ticks[2].textContent).toBe("@5000");
|
|
326
|
+
|
|
327
|
+
// The count clamps to the shown sample count.
|
|
328
|
+
const clamped = mount({
|
|
329
|
+
pens: [CPU],
|
|
330
|
+
now: 5000,
|
|
331
|
+
timeWindowMs: 5000,
|
|
332
|
+
xTickCount: 5,
|
|
333
|
+
xTickFormat: at,
|
|
334
|
+
});
|
|
335
|
+
const two = clamped.querySelectorAll("text.hk-trend-chart__x-tick");
|
|
336
|
+
expect(two).toHaveLength(2);
|
|
337
|
+
expect(two[0].textContent).toBe("@0");
|
|
338
|
+
expect(two[1].textContent).toBe("@5000");
|
|
339
|
+
|
|
340
|
+
// Half the pair means no ticks at all.
|
|
341
|
+
const onlyCount = mount({ pens: [CPU], xTickCount: 3 });
|
|
342
|
+
const onlyFormat = mount({ pens: [CPU], xTickFormat: at });
|
|
343
|
+
expect(onlyCount.querySelectorAll("text.hk-trend-chart__x-tick")).toHaveLength(0);
|
|
344
|
+
expect(onlyFormat.querySelectorAll("text.hk-trend-chart__x-tick")).toHaveLength(0);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("steps the y domain with yDomain (pad / minSpan / floorAtZero)", () => {
|
|
348
|
+
// Absent: today's raw extent [62, 78].
|
|
349
|
+
const raw = mount({ pens: [PR], now: 5000, timeWindowMs: 5000 });
|
|
350
|
+
expect(raw.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
|
|
351
|
+
"50,200 620,30",
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// padToMultiple + floorAtZero: span 16 → padded to 20 → the floor
|
|
355
|
+
// anchors at zero and the elevated ceiling re-pads → [0, 80].
|
|
356
|
+
const stepped = mount({
|
|
357
|
+
pens: [PR],
|
|
358
|
+
now: 5000,
|
|
359
|
+
timeWindowMs: 5000,
|
|
360
|
+
yDomain: { padToMultiple: 10, floorAtZero: true },
|
|
361
|
+
});
|
|
362
|
+
expect(stepped.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
|
|
363
|
+
"50,68.25 620,34.25",
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// padToMultiple alone pads the span up from the raw floor: [62, 82].
|
|
367
|
+
const padded = mount({
|
|
368
|
+
pens: [PR],
|
|
369
|
+
now: 5000,
|
|
370
|
+
timeWindowMs: 5000,
|
|
371
|
+
yDomain: { padToMultiple: 10 },
|
|
372
|
+
});
|
|
373
|
+
expect(padded.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
|
|
374
|
+
"50,200 620,64",
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// minSpan widens a narrow extent: [62, 102] — read back through the
|
|
378
|
+
// y-axis numbers so the domain pins without float needles.
|
|
379
|
+
const widened = mount({
|
|
380
|
+
pens: [PR],
|
|
381
|
+
now: 5000,
|
|
382
|
+
timeWindowMs: 5000,
|
|
383
|
+
yDomain: { minSpan: 40 },
|
|
384
|
+
yAxisFormat: (value: number) => String(value),
|
|
385
|
+
});
|
|
386
|
+
expect(widened.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
|
|
387
|
+
"50,200 620,132",
|
|
388
|
+
);
|
|
389
|
+
const widenedTicks = widened.querySelectorAll("text.hk-trend-chart__y-tick");
|
|
390
|
+
expect(widenedTicks).toHaveLength(3);
|
|
391
|
+
expect(widenedTicks[0].textContent).toBe("62");
|
|
392
|
+
expect(widenedTicks[1].textContent).toBe("82");
|
|
393
|
+
expect(widenedTicks[2].textContent).toBe("102");
|
|
394
|
+
|
|
395
|
+
// floorAtZero alone anchors at zero and keeps the raw ceiling: [0, 78].
|
|
396
|
+
const floored = mount({
|
|
397
|
+
pens: [PR],
|
|
398
|
+
now: 5000,
|
|
399
|
+
timeWindowMs: 5000,
|
|
400
|
+
yDomain: { floorAtZero: true },
|
|
401
|
+
yAxisFormat: (value: number) => String(value),
|
|
402
|
+
});
|
|
403
|
+
const flooredTicks = floored.querySelectorAll("text.hk-trend-chart__y-tick");
|
|
404
|
+
expect(flooredTicks).toHaveLength(3);
|
|
405
|
+
expect(flooredTicks[0].textContent).toBe("0");
|
|
406
|
+
expect(flooredTicks[1].textContent).toBe("39");
|
|
407
|
+
expect(flooredTicks[2].textContent).toBe("78");
|
|
408
|
+
|
|
409
|
+
// A flat series under a stepped domain pads up to one step instead of
|
|
410
|
+
// the flat ±1: [0, 10] keeps the line mid-plot.
|
|
411
|
+
const flat = mount({
|
|
412
|
+
pens: [{ label: "cpu", data: [{ time: 0, value: 5 }, { time: 5000, value: 5 }] }],
|
|
413
|
+
now: 5000,
|
|
414
|
+
timeWindowMs: 5000,
|
|
415
|
+
yDomain: { padToMultiple: 10, floorAtZero: true },
|
|
416
|
+
});
|
|
417
|
+
expect(flat.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
|
|
418
|
+
"50,115 620,115",
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("passes preserveAspectRatio through (the default stays none)", () => {
|
|
423
|
+
const meet = mount({ pens: [CPU], preserveAspectRatio: "xMidYMid meet" });
|
|
424
|
+
expect(
|
|
425
|
+
meet.querySelector("svg.hk-trend-chart__svg")!.getAttribute("preserveAspectRatio"),
|
|
426
|
+
).toBe("xMidYMid meet");
|
|
427
|
+
const stretch = mount({ pens: [CPU] });
|
|
428
|
+
expect(
|
|
429
|
+
stretch.querySelector("svg.hk-trend-chart__svg")!.getAttribute("preserveAspectRatio"),
|
|
430
|
+
).toBe("none");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("escapes the consumer strings the new props render into the SVG", () => {
|
|
434
|
+
const svg = renderTrendChartSvg([TIER], {
|
|
435
|
+
now: 5000,
|
|
436
|
+
timeWindowMs: 5000,
|
|
437
|
+
guides: [{ value: 60, label: `<b class="x">&</b>`, hint: `"q"` }],
|
|
438
|
+
yAxisFormat: (value: number) => `<${value}>`,
|
|
439
|
+
xTickCount: 1,
|
|
440
|
+
xTickFormat: (timeMs: number) => "t&t",
|
|
441
|
+
});
|
|
442
|
+
expect(svg).not.toContain(`<b class="x">`);
|
|
443
|
+
expect(svg).toContain("<b class="x">&</b>");
|
|
444
|
+
expect(svg).toContain(`data-hint=""q""`);
|
|
445
|
+
expect(svg).toContain("<0>");
|
|
446
|
+
expect(svg).toContain("t&t");
|
|
447
|
+
});
|
|
448
|
+
|
|
143
449
|
it("stays dependency-free (no echarts import, no echarts dep)", () => {
|
|
144
450
|
// The old build lazily imported echarts/core; the rewrite must not let
|
|
145
451
|
// it back in — pinned at the source and manifest level, the same way
|
|
@@ -160,4 +466,4 @@ describe("HkTrendChart", () => {
|
|
|
160
466
|
) as { dependencies?: Record<string, string> };
|
|
161
467
|
expect(Object.keys(pkg.dependencies ?? {})).not.toContain("echarts");
|
|
162
468
|
});
|
|
163
|
-
});
|
|
469
|
+
});
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { computed, defineComponent, type PropType } from "vue";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
renderTrendChartSvg,
|
|
5
|
+
trendHeightPx,
|
|
6
|
+
type AlarmThresholds,
|
|
7
|
+
type TrendGuide,
|
|
8
|
+
type TrendPen,
|
|
9
|
+
type TrendPoint,
|
|
10
|
+
type TrendYDomain,
|
|
11
|
+
} from "./trendGeometry";
|
|
4
12
|
|
|
5
13
|
// The pen/threshold data types live in the pure geometry module (the Rust
|
|
6
14
|
// mirror's trend_chart.rs surface) and re-export here so direct-file
|
|
7
|
-
// importers keep their established paths.
|
|
8
|
-
|
|
15
|
+
// importers keep their established paths. TrendGuide / TrendYDomain are the
|
|
16
|
+
// wowsp-migration extensions (documented in ./trendGeometry).
|
|
17
|
+
export type { AlarmThresholds, TrendGuide, TrendPen, TrendPoint, TrendYDomain };
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* HkTrendChart — labelled time-series ("pens") drawn over a rolling time
|
|
@@ -22,9 +31,13 @@ export type { AlarmThresholds, TrendPen, TrendPoint };
|
|
|
22
31
|
* div.hk-trend-chart style="width:100%;height:{height}"
|
|
23
32
|
* div[style="display:contents"]
|
|
24
33
|
* svg.hk-trend-chart__svg viewBox + preserveAspectRatio="none"
|
|
25
|
-
* line.hk-trend-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
34
|
+
* line.hk-trend-chart__guide (optional chart-level guides)
|
|
35
|
+
* line.hk-trend-chart__threshold (one per present limit)
|
|
36
|
+
* text.hk-trend-chart__y-tick / __x-tick (optional axis text)
|
|
37
|
+
* path.hk-trend-chart__area (one per pen)
|
|
38
|
+
* polyline.hk-trend-chart__line (one per pen)
|
|
39
|
+
* circle.hk-trend-chart__dot (optional, per inked sample)
|
|
40
|
+
* text.hk-trend-chart__guide-label (optional tier names, on top)
|
|
28
41
|
*
|
|
29
42
|
* The `hk-trend-chart*` classes are INERT hooks (no sheet defines them);
|
|
30
43
|
* the component ships NO stylesheet and no canvas. Pen ink rides the theme
|
|
@@ -32,6 +45,15 @@ export type { AlarmThresholds, TrendPen, TrendPoint };
|
|
|
32
45
|
* a re-render, and threshold guides keep the old markLine colors and dash
|
|
33
46
|
* flavors.
|
|
34
47
|
*
|
|
48
|
+
* WOWSP-MIGRATION EXTRAS (all optional, all defaulting to today's frame):
|
|
49
|
+
* chart-level named `guides` (dashed tier lines with left-edge tier labels
|
|
50
|
+
* and hints), per-sample dot ink (`TrendPoint.fill`/`hint`), y-axis
|
|
51
|
+
* numbers (`yAxisFormat` plus the `yAxisClearancePx` collision rule),
|
|
52
|
+
* baseline x ticks (`xTickCount` + `xTickFormat`), a stepped `yDomain`,
|
|
53
|
+
* and a `preserveAspectRatio` override. The exact rules live in
|
|
54
|
+
* ./trendGeometry; with the extras absent the markup is byte-identical to
|
|
55
|
+
* the mirror's.
|
|
56
|
+
*
|
|
35
57
|
* OFFLINE PRE-RENDER SEMANTICS: no animation exists at all — the first
|
|
36
58
|
* render IS the finished frame. Streaming updates re-render the same
|
|
37
59
|
* geometry synchronously when `pens` is reactive state. The identical
|
|
@@ -63,6 +85,29 @@ export default defineComponent({
|
|
|
63
85
|
now: { type: Number, default: undefined },
|
|
64
86
|
/** The viewBox width (default 640 — the mirror's frame). */
|
|
65
87
|
width: { type: Number, default: 640 },
|
|
88
|
+
/** Chart-level horizontal guides rendered with the alarms — the named
|
|
89
|
+
* tier lines. Default: none. */
|
|
90
|
+
guides: { type: Array as PropType<TrendGuide[]>, default: undefined },
|
|
91
|
+
/** Formats the y-axis gridline numbers (floor/mid/ceiling of the shown
|
|
92
|
+
* domain); absent → no y-axis text. */
|
|
93
|
+
yAxisFormat: { type: Function as PropType<(value: number) => string>, default: undefined },
|
|
94
|
+
/** A mid/ceiling number whose gridline sits within this many viewBox px
|
|
95
|
+
* of a guide's line is dropped (the floor number always renders).
|
|
96
|
+
* Default 0 — nothing is dropped. */
|
|
97
|
+
yAxisClearancePx: { type: Number, default: 0 },
|
|
98
|
+
/** Baseline tick count, clamped to the shown sample count; needs
|
|
99
|
+
* `xTickFormat`. Default: no x ticks. */
|
|
100
|
+
xTickCount: { type: Number, default: undefined },
|
|
101
|
+
/** Formats each tick's epoch ms; needs `xTickCount`. */
|
|
102
|
+
xTickFormat: { type: Function as PropType<(timeMs: number) => string>, default: undefined },
|
|
103
|
+
/** The stepped y extent rule; absent → the raw extent (flat-pad ±1). */
|
|
104
|
+
yDomain: { type: Object as PropType<TrendYDomain>, default: undefined },
|
|
105
|
+
/** The SVG fit (default "none" — stretch to the box, the mirror's
|
|
106
|
+
* rule; "xMidYMid meet" keeps the frame's aspect). */
|
|
107
|
+
preserveAspectRatio: {
|
|
108
|
+
type: String as PropType<"none" | "xMidYMid meet">,
|
|
109
|
+
default: "none",
|
|
110
|
+
},
|
|
66
111
|
},
|
|
67
112
|
setup(props) {
|
|
68
113
|
const svg = computed(() =>
|
|
@@ -72,6 +117,13 @@ export default defineComponent({
|
|
|
72
117
|
width: props.width,
|
|
73
118
|
height: props.height,
|
|
74
119
|
showThresholds: props.showThresholds,
|
|
120
|
+
guides: props.guides,
|
|
121
|
+
yAxisFormat: props.yAxisFormat,
|
|
122
|
+
yAxisClearancePx: props.yAxisClearancePx,
|
|
123
|
+
xTickCount: props.xTickCount,
|
|
124
|
+
xTickFormat: props.xTickFormat,
|
|
125
|
+
yDomain: props.yDomain,
|
|
126
|
+
preserveAspectRatio: props.preserveAspectRatio,
|
|
75
127
|
}),
|
|
76
128
|
);
|
|
77
129
|
// An empty height keeps the viewBox-derived px (the mirror's rule).
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// the two apps draw the identical bar shape: equal slots, each bar
|
|
8
8
|
// centered with 62%-of-slot width capped at 36px, top-only corner
|
|
9
9
|
// rounding clamped so a squat bar never inverts its corners, and three
|
|
10
|
-
// gridline guides (0 / half / the nice max).
|
|
10
|
+
// gridline guides (0 / half / the nice max). Each slot also carries the
|
|
11
|
+
// baseline for an optional value label above the bar (top − 5, null for
|
|
12
|
+
// an empty slot) — the wowsp tier histogram's labelY convention, which
|
|
13
|
+
// HkBarChart renders behind its showValues prop.
|
|
11
14
|
//
|
|
12
15
|
// AI-generated (GLM-5.3 via ZCode), 2026-10-31 — SySL-1.0 §2.3.
|
|
13
16
|
|
|
@@ -18,6 +21,12 @@ export interface BarSlot {
|
|
|
18
21
|
/** Empty string for a zero bucket — no invisible sliver to hover. */
|
|
19
22
|
path: string;
|
|
20
23
|
value: number;
|
|
24
|
+
/** Text baseline for a value label ABOVE the bar (top − 5, wowsp's
|
|
25
|
+
* tierBars convention — the 10-unit label clears the bar with a
|
|
26
|
+
* readable gap); null for a zero bucket, so an empty slot carries no
|
|
27
|
+
* above-bar label either (HkBarChart's showValues renders labelY
|
|
28
|
+
* non-null slots only). */
|
|
29
|
+
labelY: number | null;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
/** The full layout HkBarChart renders. */
|
|
@@ -79,7 +88,7 @@ export function barLayout(
|
|
|
79
88
|
const bars: BarSlot[] = values.map((value, i) => {
|
|
80
89
|
const centerX = sidePad + slotW * (i + 0.5);
|
|
81
90
|
if (!(value > 0)) {
|
|
82
|
-
return { centerX, barWidth: barW, path: "", value };
|
|
91
|
+
return { centerX, barWidth: barW, path: "", value, labelY: null };
|
|
83
92
|
}
|
|
84
93
|
const h = (value / scale) * plotH;
|
|
85
94
|
const top = baselineY - h;
|
|
@@ -94,7 +103,7 @@ export function barLayout(
|
|
|
94
103
|
`L ${fmtBar(x1 - rx)} ${fmtBar(top)} ` +
|
|
95
104
|
`Q ${fmtBar(x1)} ${fmtBar(top)} ${fmtBar(x1)} ${fmtBar(top + rx)} ` +
|
|
96
105
|
`L ${fmtBar(x1)} ${fmtBar(baselineY)} Z`;
|
|
97
|
-
return { centerX, barWidth: barW, path, value };
|
|
106
|
+
return { centerX, barWidth: barW, path, value, labelY: fmtBar(top - 5) };
|
|
98
107
|
});
|
|
99
108
|
const gridlines = [0, 0.5, 1].map((f) => ({
|
|
100
109
|
y: fmtBar(baselineY - f * plotH),
|
|
@@ -10,12 +10,20 @@
|
|
|
10
10
|
// contract the render layer mounts and the delta history against the old
|
|
11
11
|
// echarts build.
|
|
12
12
|
//
|
|
13
|
+
// WOWSP-MIGRATION SUPERSET: HkTrendChart grew optional capabilities for
|
|
14
|
+
// wowsp's PrTrendChart — chart-level named guides, per-sample dot ink,
|
|
15
|
+
// y-axis numbers with a clearance rule, baseline x ticks, a stepped yDomain
|
|
16
|
+
// and a preserveAspectRatio override. Every extension is optional and absent
|
|
17
|
+
// means the mirror's exact math and markup (the ported cfg(test) cases stay
|
|
18
|
+
// verbatim); the extra types live TS-side until the mirror grows them.
|
|
19
|
+
//
|
|
13
20
|
// Dependency-free (no Vue, no chart library): everything here is pure and
|
|
14
21
|
// unit-testable. `renderTrendChartSvg` is the OFFLINE pre-render entry — its
|
|
15
22
|
// string is the finished frame (nothing animates, anywhere), and consumers
|
|
16
23
|
// embed it directly (inline or as a data URL; html-to-image and static
|
|
17
|
-
// exports just work). Pen labels are not rendered into the markup
|
|
18
|
-
// consumer
|
|
24
|
+
// exports just work). Pen labels are still not rendered into the markup; the
|
|
25
|
+
// superset's consumer strings (guide labels/hints, dot ink, formatted axis
|
|
26
|
+
// text) go through `escapeSvgText` before they enter the SVG.
|
|
19
27
|
//
|
|
20
28
|
// AI-generated (glm-5.3-flash via DeepSeek Harness), 2026-11-03 — SySL-1.0 §2.3.
|
|
21
29
|
|
|
@@ -42,16 +50,40 @@ export const TREND_THRESHOLD_LL_COLOR = "#FF1744";
|
|
|
42
50
|
export const TREND_THRESHOLD_DASHED = "6 4";
|
|
43
51
|
export const TREND_THRESHOLD_DOTTED = "1 3";
|
|
44
52
|
|
|
53
|
+
/** Chart-level guide defaults: a quiet `currentColor` line (consumers name
|
|
54
|
+
* tier colors) with the alarm `dashed` flavor. */
|
|
55
|
+
export const TREND_GUIDE_DEFAULT_COLOR = "currentColor";
|
|
56
|
+
export const TREND_GUIDE_DEFAULT_DASH = TREND_THRESHOLD_DASHED;
|
|
57
|
+
export const TREND_GUIDE_DEFAULT_OPACITY = 0.35;
|
|
58
|
+
/** A named guide's label sits inside the plot near the left edge, a step
|
|
59
|
+
* above its line (viewBox px). */
|
|
60
|
+
export const TREND_GUIDE_LABEL_INSET = 6;
|
|
61
|
+
export const TREND_GUIDE_LABEL_LIFT = 4;
|
|
62
|
+
/** Axis text: one small size; y numbers sit in the left grid gutter, x ticks
|
|
63
|
+
* drop under the baseline (viewBox px). */
|
|
64
|
+
export const TREND_CHART_AXIS_FONT_SIZE = 10;
|
|
65
|
+
export const TREND_Y_TICK_INSET = 6;
|
|
66
|
+
export const TREND_Y_TICK_BASELINE_SHIFT = 3;
|
|
67
|
+
export const TREND_X_TICK_DROP = 14;
|
|
68
|
+
/** Per-sample dot radius (viewBox px). */
|
|
69
|
+
export const TREND_CHART_DOT_RADIUS = 3.5;
|
|
70
|
+
|
|
45
71
|
/** The y axis collapses when every shown value is equal; the range pads by
|
|
46
72
|
* this much on each side so the line lands mid-plot. */
|
|
47
73
|
const TREND_FLAT_RANGE_PAD = 1;
|
|
48
74
|
const TREND_RANGE_EPSILON = 1e-9;
|
|
49
75
|
|
|
50
|
-
/** One sample (the old echarts option tree's `[time, value]` entries).
|
|
76
|
+
/** One sample (the old echarts option tree's `[time, value]` entries). The
|
|
77
|
+
* wowsp migration adds per-sample dot ink: a sample that names `fill`
|
|
78
|
+
* renders a dot at its coordinate, optionally carrying a `hint`. */
|
|
51
79
|
export interface TrendPoint {
|
|
52
80
|
/** Epoch milliseconds. */
|
|
53
81
|
time: number;
|
|
54
82
|
value: number;
|
|
83
|
+
/** Dot ink; a dot is drawn at the sample only when present. */
|
|
84
|
+
fill?: string;
|
|
85
|
+
/** The dot's data-hint payload; rendered only alongside `fill`. */
|
|
86
|
+
hint?: string;
|
|
55
87
|
}
|
|
56
88
|
|
|
57
89
|
/** Per-pen alarm limits. */
|
|
@@ -62,6 +94,35 @@ export interface AlarmThresholds {
|
|
|
62
94
|
ll?: number;
|
|
63
95
|
}
|
|
64
96
|
|
|
97
|
+
/** One chart-level horizontal guide (the wowsp tier chart's named tier
|
|
98
|
+
* lines). Clipped to the shown y range like the alarm thresholds. */
|
|
99
|
+
export interface TrendGuide {
|
|
100
|
+
/** The value the line sits at. */
|
|
101
|
+
value: number;
|
|
102
|
+
/** Tier name rendered as text inside the plot near the left edge. */
|
|
103
|
+
label?: string;
|
|
104
|
+
/** Line/label ink; default a quiet `currentColor`. */
|
|
105
|
+
color?: string;
|
|
106
|
+
/** Dash array; default `"6 4"` (the alarm `dashed` flavor). */
|
|
107
|
+
dash?: string;
|
|
108
|
+
/** The line's data-hint payload. */
|
|
109
|
+
hint?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The stepped y extent rule (absent → the raw extent with the flat pad).
|
|
113
|
+
* The span widens to `minSpan`, is padded UP to the next `padToMultiple`,
|
|
114
|
+
* and `floorAtZero` anchors the floor at 0 when the shown extent is
|
|
115
|
+
* non-negative (re-padding the elevated ceiling so the top lands on a step
|
|
116
|
+
* and no shown sample escapes the domain). */
|
|
117
|
+
export interface TrendYDomain {
|
|
118
|
+
/** Pad the extent's span UP to the next multiple of this. */
|
|
119
|
+
padToMultiple?: number;
|
|
120
|
+
/** Never show a span narrower than this (value units). */
|
|
121
|
+
minSpan?: number;
|
|
122
|
+
/** Anchor the floor at 0 when the shown extent is non-negative. */
|
|
123
|
+
floorAtZero?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
65
126
|
/** One labelled series. */
|
|
66
127
|
export interface TrendPen {
|
|
67
128
|
label: string;
|
|
@@ -92,6 +153,14 @@ export interface TrendFrame {
|
|
|
92
153
|
valueMax: number;
|
|
93
154
|
}
|
|
94
155
|
|
|
156
|
+
/** One projected per-sample dot. */
|
|
157
|
+
export interface TrendDot {
|
|
158
|
+
x: number;
|
|
159
|
+
y: number;
|
|
160
|
+
fill: string;
|
|
161
|
+
hint?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
95
164
|
/** One pen's projected geometry. */
|
|
96
165
|
export interface TrendPenGeometry {
|
|
97
166
|
label: string;
|
|
@@ -101,6 +170,8 @@ export interface TrendPenGeometry {
|
|
|
101
170
|
line: string;
|
|
102
171
|
/** The area `d` attribute (the line closed down to the baseline). */
|
|
103
172
|
area: string;
|
|
173
|
+
/** Per-sample dots in draw order (the samples naming `fill`). */
|
|
174
|
+
dots: TrendDot[];
|
|
104
175
|
}
|
|
105
176
|
|
|
106
177
|
/** One horizontal alarm guide (one old `markLine` entry). */
|
|
@@ -111,11 +182,61 @@ export interface TrendThresholdLine {
|
|
|
111
182
|
dash: string;
|
|
112
183
|
}
|
|
113
184
|
|
|
185
|
+
/** One projected chart-level guide. */
|
|
186
|
+
export interface TrendGuideLine {
|
|
187
|
+
value: number;
|
|
188
|
+
y: number;
|
|
189
|
+
/** Resolved ink (`TREND_GUIDE_DEFAULT_COLOR` when unnamed). */
|
|
190
|
+
color: string;
|
|
191
|
+
dash: string;
|
|
192
|
+
/** `stroke-opacity`; set only for the default color's quiet line. */
|
|
193
|
+
opacity?: number;
|
|
194
|
+
hint?: string;
|
|
195
|
+
label?: string;
|
|
196
|
+
labelX: number;
|
|
197
|
+
labelY: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One y-axis gridline number (floor/mid/ceiling of the shown domain). */
|
|
201
|
+
export interface TrendYTick {
|
|
202
|
+
value: number;
|
|
203
|
+
/** The text's baseline y (the gridline y shifted for optical centering). */
|
|
204
|
+
y: number;
|
|
205
|
+
text: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** One baseline x tick. */
|
|
209
|
+
export interface TrendXTick {
|
|
210
|
+
/** The tick's time (interpolated across the shown data's time span). */
|
|
211
|
+
time: number;
|
|
212
|
+
x: number;
|
|
213
|
+
y: number;
|
|
214
|
+
text: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
114
217
|
/** Everything the render layer needs. */
|
|
115
218
|
export interface TrendGeometry {
|
|
116
219
|
frame: TrendFrame;
|
|
117
220
|
pens: TrendPenGeometry[];
|
|
118
221
|
thresholds: TrendThresholdLine[];
|
|
222
|
+
/** Chart-level named guides (empty without the `guides` extra). */
|
|
223
|
+
guides: TrendGuideLine[];
|
|
224
|
+
/** Gridline numbers (empty without `yAxisFormat`). */
|
|
225
|
+
yTicks: TrendYTick[];
|
|
226
|
+
/** Baseline ticks (empty without `xTickCount` + `xTickFormat`). */
|
|
227
|
+
xTicks: TrendXTick[];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The wowsp-migration extensions, passed to `trendGeometry` after the
|
|
231
|
+
* mirror's six pinned arguments. Every field is optional; absent means the
|
|
232
|
+
* mirror's exact behavior. */
|
|
233
|
+
export interface TrendGeometryExtras {
|
|
234
|
+
guides?: readonly TrendGuide[];
|
|
235
|
+
yDomain?: TrendYDomain;
|
|
236
|
+
yAxisFormat?: (value: number) => string;
|
|
237
|
+
yAxisClearancePx?: number;
|
|
238
|
+
xTickCount?: number;
|
|
239
|
+
xTickFormat?: (timeMs: number) => string;
|
|
119
240
|
}
|
|
120
241
|
|
|
121
242
|
/**
|
|
@@ -195,9 +316,67 @@ export function trendAreaPath(points: readonly TrendCoord[], baselineY: number):
|
|
|
195
316
|
return path;
|
|
196
317
|
}
|
|
197
318
|
|
|
319
|
+
/** The next multiple of `step` at or above `span` (a zero span pads up to
|
|
320
|
+
* one step; sub-epsilon float noise does not trigger an extra step). */
|
|
321
|
+
function padUpToMultiple(span: number, step: number): number {
|
|
322
|
+
const stepped = Math.ceil((span - TREND_RANGE_EPSILON) / step) * step;
|
|
323
|
+
return stepped > 0 ? stepped : step;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A usable `padToMultiple`: finite and positive. */
|
|
327
|
+
function isUsableStep(value: number | undefined): value is number {
|
|
328
|
+
return value !== undefined && Number.isFinite(value) && value > 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The `yDomain` extent rule (the wowsp tier chart's stepped axis): the raw
|
|
333
|
+
* span widens to `minSpan`, is padded UP to the next `padToMultiple`, and
|
|
334
|
+
* `floorAtZero` anchors the floor at 0 when the shown extent is
|
|
335
|
+
* non-negative — re-padding the elevated ceiling so the top lands on a step
|
|
336
|
+
* and no shown sample escapes the domain. Negative extents keep their floor
|
|
337
|
+
* (zero-anchoring would hide the data) and only widen by the span.
|
|
338
|
+
*/
|
|
339
|
+
function applyTrendYDomain(
|
|
340
|
+
rawMin: number,
|
|
341
|
+
rawMax: number,
|
|
342
|
+
domain: TrendYDomain,
|
|
343
|
+
): { min: number; max: number } {
|
|
344
|
+
const rawSpan = rawMax - rawMin;
|
|
345
|
+
const minSpan = domain.minSpan;
|
|
346
|
+
let span =
|
|
347
|
+
minSpan !== undefined && Number.isFinite(minSpan) && minSpan >= 0
|
|
348
|
+
? Math.max(rawSpan, minSpan)
|
|
349
|
+
: rawSpan;
|
|
350
|
+
if (isUsableStep(domain.padToMultiple)) span = padUpToMultiple(span, domain.padToMultiple);
|
|
351
|
+
if (domain.floorAtZero === true && rawMin >= 0) {
|
|
352
|
+
const ceiling = isUsableStep(domain.padToMultiple)
|
|
353
|
+
? padUpToMultiple(rawMax, domain.padToMultiple)
|
|
354
|
+
: Math.max(rawMax, rawMin + span);
|
|
355
|
+
return { min: 0, max: ceiling };
|
|
356
|
+
}
|
|
357
|
+
return { min: rawMin, max: rawMin + span };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* XML-escape a consumer-authored string before it enters the SVG markup
|
|
362
|
+
* (guide labels/hints, dot ink, formatted axis text). Attribute values and
|
|
363
|
+
* text content share one escape — the extra entities are valid in both
|
|
364
|
+
* positions, so there is no second code path to forget.
|
|
365
|
+
*/
|
|
366
|
+
function escapeSvgText(value: string): string {
|
|
367
|
+
return value
|
|
368
|
+
.replace(/&/g, "&")
|
|
369
|
+
.replace(/</g, "<")
|
|
370
|
+
.replace(/>/g, ">")
|
|
371
|
+
.replace(/"/g, """)
|
|
372
|
+
.replace(/'/g, "'");
|
|
373
|
+
}
|
|
374
|
+
|
|
198
375
|
/**
|
|
199
|
-
* Pens → projected geometry. Pure: same pens + clock + window + frame
|
|
200
|
-
* the same geometry.
|
|
376
|
+
* Pens → projected geometry. Pure: same pens + clock + window + frame +
|
|
377
|
+
* extras → the same geometry. The first six arguments are the mirror's
|
|
378
|
+
* pinned surface; `extras` carries the wowsp-migration extensions and
|
|
379
|
+
* defaults to the mirror's exact behavior.
|
|
201
380
|
*/
|
|
202
381
|
export function trendGeometry(
|
|
203
382
|
pens: readonly TrendPen[],
|
|
@@ -206,6 +385,7 @@ export function trendGeometry(
|
|
|
206
385
|
width: number,
|
|
207
386
|
height: number,
|
|
208
387
|
showThresholds: boolean,
|
|
388
|
+
extras: TrendGeometryExtras = {},
|
|
209
389
|
): TrendGeometry {
|
|
210
390
|
const windowEnd = trendWindowEnd(pens, now);
|
|
211
391
|
const windowSpan = Math.max(timeWindowMs, 0);
|
|
@@ -225,9 +405,11 @@ export function trendGeometry(
|
|
|
225
405
|
// fits the data extent and a threshold value does not extend the axis.
|
|
226
406
|
let valueMin = Infinity;
|
|
227
407
|
let valueMax = -Infinity;
|
|
408
|
+
const shownTimes: number[] = [];
|
|
228
409
|
for (const pen of pens) {
|
|
229
410
|
for (const point of pen.data) {
|
|
230
411
|
if (point.time >= windowStart && point.time <= windowEnd) {
|
|
412
|
+
shownTimes.push(point.time);
|
|
231
413
|
if (point.value < valueMin) valueMin = point.value;
|
|
232
414
|
if (point.value > valueMax) valueMax = point.value;
|
|
233
415
|
}
|
|
@@ -237,6 +419,13 @@ export function trendGeometry(
|
|
|
237
419
|
valueMin = 0;
|
|
238
420
|
valueMax = 1;
|
|
239
421
|
}
|
|
422
|
+
// The stepped-domain extra (absent → the raw extent below, the mirror's
|
|
423
|
+
// rule). The trailing flat-pad still catches a degenerate result.
|
|
424
|
+
if (extras.yDomain !== undefined) {
|
|
425
|
+
const stepped = applyTrendYDomain(valueMin, valueMax, extras.yDomain);
|
|
426
|
+
valueMin = stepped.min;
|
|
427
|
+
valueMax = stepped.max;
|
|
428
|
+
}
|
|
240
429
|
if (valueMax - valueMin < TREND_RANGE_EPSILON) {
|
|
241
430
|
// A flat (or single-sample) series collapses the axis; pad it so the
|
|
242
431
|
// line lands mid-plot instead of dividing by zero.
|
|
@@ -252,9 +441,15 @@ export function trendGeometry(
|
|
|
252
441
|
for (let index = 0; index < pens.length; index += 1) {
|
|
253
442
|
const pen = pens[index];
|
|
254
443
|
const points: TrendCoord[] = [];
|
|
444
|
+
const dots: TrendDot[] = [];
|
|
255
445
|
for (const point of pen.data) {
|
|
256
446
|
if (point.time >= windowStart && point.time <= windowEnd) {
|
|
257
|
-
|
|
447
|
+
const coord = { x: xOf(point.time), y: yOf(point.value) };
|
|
448
|
+
points.push(coord);
|
|
449
|
+
// A sample carries a dot only when it names its own ink.
|
|
450
|
+
if (point.fill !== undefined) {
|
|
451
|
+
dots.push({ x: coord.x, y: coord.y, fill: point.fill, hint: point.hint });
|
|
452
|
+
}
|
|
258
453
|
}
|
|
259
454
|
}
|
|
260
455
|
projected.push({
|
|
@@ -263,6 +458,7 @@ export function trendGeometry(
|
|
|
263
458
|
line: trendPolyline(points),
|
|
264
459
|
area: trendAreaPath(points, plotBottom),
|
|
265
460
|
points,
|
|
461
|
+
dots,
|
|
266
462
|
});
|
|
267
463
|
}
|
|
268
464
|
|
|
@@ -287,6 +483,81 @@ export function trendGeometry(
|
|
|
287
483
|
}
|
|
288
484
|
}
|
|
289
485
|
|
|
486
|
+
const guides: TrendGuideLine[] = [];
|
|
487
|
+
for (const guide of extras.guides ?? []) {
|
|
488
|
+
// The alarm guides clip outside the axis range; the named guides — and
|
|
489
|
+
// their labels with them — clip the same way.
|
|
490
|
+
if (guide.value < valueMin || guide.value > valueMax) continue;
|
|
491
|
+
const y = yOf(guide.value);
|
|
492
|
+
guides.push({
|
|
493
|
+
value: guide.value,
|
|
494
|
+
y,
|
|
495
|
+
color: guide.color ?? TREND_GUIDE_DEFAULT_COLOR,
|
|
496
|
+
dash: guide.dash ?? TREND_GUIDE_DEFAULT_DASH,
|
|
497
|
+
// The default ink stays quiet; a named color draws at full strength.
|
|
498
|
+
opacity: guide.color === undefined ? TREND_GUIDE_DEFAULT_OPACITY : undefined,
|
|
499
|
+
hint: guide.hint,
|
|
500
|
+
label: guide.label,
|
|
501
|
+
labelX: plotLeft + TREND_GUIDE_LABEL_INSET,
|
|
502
|
+
labelY: y - TREND_GUIDE_LABEL_LIFT,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const yTicks: TrendYTick[] = [];
|
|
507
|
+
if (extras.yAxisFormat !== undefined) {
|
|
508
|
+
// Gridline numbers: floor / mid / ceiling of the shown domain. A
|
|
509
|
+
// mid/ceiling number whose gridline sits within `yAxisClearancePx` of a
|
|
510
|
+
// rendered guide's line is dropped (the floor number always renders);
|
|
511
|
+
// viewBox px, like the clearance prop says.
|
|
512
|
+
const guideYs = guides.map((guide) => guide.y);
|
|
513
|
+
const clearance = extras.yAxisClearancePx ?? 0;
|
|
514
|
+
const collides = (y: number): boolean =>
|
|
515
|
+
clearance > 0 && guideYs.some((guideY) => Math.abs(guideY - y) <= clearance);
|
|
516
|
+
const mid = valueMin + valueSpan / 2;
|
|
517
|
+
yTicks.push({
|
|
518
|
+
value: valueMin,
|
|
519
|
+
y: yOf(valueMin) + TREND_Y_TICK_BASELINE_SHIFT,
|
|
520
|
+
text: extras.yAxisFormat(valueMin),
|
|
521
|
+
});
|
|
522
|
+
const midY = yOf(mid);
|
|
523
|
+
if (!collides(midY)) {
|
|
524
|
+
yTicks.push({ value: mid, y: midY + TREND_Y_TICK_BASELINE_SHIFT, text: extras.yAxisFormat(mid) });
|
|
525
|
+
}
|
|
526
|
+
const ceilingY = yOf(valueMax);
|
|
527
|
+
if (!collides(ceilingY)) {
|
|
528
|
+
yTicks.push({
|
|
529
|
+
value: valueMax,
|
|
530
|
+
y: ceilingY + TREND_Y_TICK_BASELINE_SHIFT,
|
|
531
|
+
text: extras.yAxisFormat(valueMax),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const xTicks: TrendXTick[] = [];
|
|
537
|
+
if (extras.xTickFormat !== undefined && extras.xTickCount !== undefined) {
|
|
538
|
+
// Evenly spaced ticks across the SHOWN data's time span; the count
|
|
539
|
+
// clamps to the sample count (a lone tick sits at the span's start).
|
|
540
|
+
const count = Math.min(Math.max(0, Math.floor(extras.xTickCount)), shownTimes.length);
|
|
541
|
+
let timeMin = Infinity;
|
|
542
|
+
let timeMax = -Infinity;
|
|
543
|
+
for (const time of shownTimes) {
|
|
544
|
+
if (time < timeMin) timeMin = time;
|
|
545
|
+
if (time > timeMax) timeMax = time;
|
|
546
|
+
}
|
|
547
|
+
if (count >= 1 && Number.isFinite(timeMin)) {
|
|
548
|
+
for (let index = 0; index < count; index += 1) {
|
|
549
|
+
const time =
|
|
550
|
+
count === 1 ? timeMin : timeMin + ((timeMax - timeMin) * index) / (count - 1);
|
|
551
|
+
xTicks.push({
|
|
552
|
+
time,
|
|
553
|
+
x: xOf(time),
|
|
554
|
+
y: plotBottom + TREND_X_TICK_DROP,
|
|
555
|
+
text: extras.xTickFormat(time),
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
290
561
|
return {
|
|
291
562
|
frame: {
|
|
292
563
|
width,
|
|
@@ -302,20 +573,34 @@ export function trendGeometry(
|
|
|
302
573
|
},
|
|
303
574
|
pens: projected,
|
|
304
575
|
thresholds,
|
|
576
|
+
guides,
|
|
577
|
+
yTicks,
|
|
578
|
+
xTicks,
|
|
305
579
|
};
|
|
306
580
|
}
|
|
307
581
|
|
|
308
582
|
/**
|
|
309
|
-
* The chart SVG markup:
|
|
310
|
-
*
|
|
311
|
-
*
|
|
583
|
+
* The chart SVG markup: named guides, then the threshold guides, then the
|
|
584
|
+
* axis text, then each pen's area + line + dots, and the named guides'
|
|
585
|
+
* labels on top. With every wowsp extension absent this is byte-for-byte the
|
|
586
|
+
* Rust mirror's `trend_chart_svg` (private there; the public offline entry
|
|
587
|
+
* below wraps it).
|
|
312
588
|
*/
|
|
313
|
-
function trendChartSvg(geometry: TrendGeometry): string {
|
|
589
|
+
function trendChartSvg(geometry: TrendGeometry, preserveAspectRatio: string): string {
|
|
314
590
|
const frame = geometry.frame;
|
|
315
|
-
let svg = `<svg class="hk-trend-chart__svg" viewBox="0 0 ${frame.width} ${frame.height}" preserveAspectRatio="
|
|
591
|
+
let svg = `<svg class="hk-trend-chart__svg" viewBox="0 0 ${frame.width} ${frame.height}" preserveAspectRatio="${preserveAspectRatio}" style="display:block;width:100%;height:100%">`;
|
|
592
|
+
for (const guide of geometry.guides) {
|
|
593
|
+
svg += `<line class="hk-trend-chart__guide" x1="${frame.plotLeft}" y1="${guide.y}" x2="${frame.plotRight}" y2="${guide.y}" stroke="${escapeSvgText(guide.color)}"${guide.opacity !== undefined ? ` stroke-opacity="${guide.opacity}"` : ""} stroke-width="1" stroke-dasharray="${escapeSvgText(guide.dash)}"${guide.hint !== undefined ? ` data-hint="${escapeSvgText(guide.hint)}"` : ""}/>`;
|
|
594
|
+
}
|
|
316
595
|
for (const threshold of geometry.thresholds) {
|
|
317
596
|
svg += `<line class="hk-trend-chart__threshold" x1="${frame.plotLeft}" y1="${threshold.y}" x2="${frame.plotRight}" y2="${threshold.y}" stroke="${threshold.color}" stroke-width="1" stroke-dasharray="${threshold.dash}"/>`;
|
|
318
597
|
}
|
|
598
|
+
for (const tick of geometry.yTicks) {
|
|
599
|
+
svg += `<text class="hk-trend-chart__y-tick" x="${frame.plotLeft - TREND_Y_TICK_INSET}" y="${tick.y}" fill="currentColor" font-size="${TREND_CHART_AXIS_FONT_SIZE}" text-anchor="end">${escapeSvgText(tick.text)}</text>`;
|
|
600
|
+
}
|
|
601
|
+
for (const tick of geometry.xTicks) {
|
|
602
|
+
svg += `<text class="hk-trend-chart__x-tick" x="${tick.x}" y="${tick.y}" fill="currentColor" font-size="${TREND_CHART_AXIS_FONT_SIZE}" text-anchor="middle">${escapeSvgText(tick.text)}</text>`;
|
|
603
|
+
}
|
|
319
604
|
for (const pen of geometry.pens) {
|
|
320
605
|
if (pen.area !== "") {
|
|
321
606
|
svg += `<path class="hk-trend-chart__area" d="${pen.area}" fill="${pen.color}" fill-opacity="${TREND_CHART_AREA_OPACITY}" stroke="none"/>`;
|
|
@@ -323,6 +608,14 @@ function trendChartSvg(geometry: TrendGeometry): string {
|
|
|
323
608
|
if (pen.line !== "") {
|
|
324
609
|
svg += `<polyline class="hk-trend-chart__line" points="${pen.line}" fill="none" stroke="${pen.color}" stroke-width="${TREND_CHART_LINE_WIDTH}" stroke-linejoin="round" stroke-linecap="round"/>`;
|
|
325
610
|
}
|
|
611
|
+
for (const dot of pen.dots) {
|
|
612
|
+
svg += `<circle class="hk-trend-chart__dot" cx="${dot.x}" cy="${dot.y}" r="${TREND_CHART_DOT_RADIUS}" fill="${escapeSvgText(dot.fill)}"${dot.hint !== undefined ? ` data-hint="${escapeSvgText(dot.hint)}"` : ""}/>`;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const guide of geometry.guides) {
|
|
616
|
+
if (guide.label !== undefined) {
|
|
617
|
+
svg += `<text class="hk-trend-chart__guide-label" x="${guide.labelX}" y="${guide.labelY}" fill="${escapeSvgText(guide.color)}" font-size="${TREND_CHART_AXIS_FONT_SIZE}" text-anchor="start">${escapeSvgText(guide.label)}</text>`;
|
|
618
|
+
}
|
|
326
619
|
}
|
|
327
620
|
svg += "</svg>";
|
|
328
621
|
return svg;
|
|
@@ -341,6 +634,23 @@ export interface RenderTrendChartOptions {
|
|
|
341
634
|
height?: number | string;
|
|
342
635
|
/** Draw the alarm threshold guides; default true. */
|
|
343
636
|
showThresholds?: boolean;
|
|
637
|
+
/** Chart-level horizontal guides (the named tier lines); default none. */
|
|
638
|
+
guides?: readonly TrendGuide[];
|
|
639
|
+
/** Formats the y-axis gridline numbers (floor/mid/ceiling of the shown
|
|
640
|
+
* domain); absent → no y-axis text. */
|
|
641
|
+
yAxisFormat?: (value: number) => string;
|
|
642
|
+
/** A mid/ceiling number whose gridline sits within this many viewBox px
|
|
643
|
+
* of a guide's line is dropped; default 0 (nothing dropped). */
|
|
644
|
+
yAxisClearancePx?: number;
|
|
645
|
+
/** Baseline tick count, clamped to the shown sample count; needs
|
|
646
|
+
* `xTickFormat`. Default: no x ticks. */
|
|
647
|
+
xTickCount?: number;
|
|
648
|
+
/** Formats each tick's epoch ms; needs `xTickCount`. */
|
|
649
|
+
xTickFormat?: (timeMs: number) => string;
|
|
650
|
+
/** The stepped y extent rule; absent → the raw extent flat-pad. */
|
|
651
|
+
yDomain?: TrendYDomain;
|
|
652
|
+
/** The SVG fit; default "none" (the mirror's stretch-to-box). */
|
|
653
|
+
preserveAspectRatio?: "none" | "xMidYMid meet";
|
|
344
654
|
}
|
|
345
655
|
|
|
346
656
|
/**
|
|
@@ -367,6 +677,14 @@ export function renderTrendChartSvg(
|
|
|
367
677
|
options.width ?? TREND_CHART_WIDTH,
|
|
368
678
|
height,
|
|
369
679
|
options.showThresholds ?? true,
|
|
680
|
+
{
|
|
681
|
+
guides: options.guides,
|
|
682
|
+
yDomain: options.yDomain,
|
|
683
|
+
yAxisFormat: options.yAxisFormat,
|
|
684
|
+
yAxisClearancePx: options.yAxisClearancePx,
|
|
685
|
+
xTickCount: options.xTickCount,
|
|
686
|
+
xTickFormat: options.xTickFormat,
|
|
687
|
+
},
|
|
370
688
|
);
|
|
371
|
-
return trendChartSvg(geometry);
|
|
689
|
+
return trendChartSvg(geometry, options.preserveAspectRatio ?? "none");
|
|
372
690
|
}
|