node-red-contrib-senec-cloud-v2 0.2.0 → 0.2.2
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/CHANGELOG.md +22 -0
- package/dist/assets/fonts/senec-sans.ttf +0 -0
- package/dist/lib/dashboard-html-renderer.d.ts +18 -0
- package/dist/lib/dashboard-html-renderer.d.ts.map +1 -0
- package/dist/lib/dashboard-html-renderer.js +604 -0
- package/dist/lib/dashboard-html-renderer.js.map +1 -0
- package/dist/lib/dashboard-layout.d.ts +152 -0
- package/dist/lib/dashboard-layout.d.ts.map +1 -0
- package/dist/lib/dashboard-layout.js +201 -0
- package/dist/lib/dashboard-layout.js.map +1 -0
- package/dist/lib/geocoding-client.d.ts +61 -0
- package/dist/lib/geocoding-client.d.ts.map +1 -0
- package/dist/lib/geocoding-client.js +77 -0
- package/dist/lib/geocoding-client.js.map +1 -0
- package/dist/lib/senec-image-renderer.d.ts +107 -0
- package/dist/lib/senec-image-renderer.d.ts.map +1 -0
- package/dist/lib/senec-image-renderer.js +872 -0
- package/dist/lib/senec-image-renderer.js.map +1 -0
- package/dist/lib/senec-layout.d.ts +212 -0
- package/dist/lib/senec-layout.d.ts.map +1 -0
- package/dist/lib/senec-layout.js +252 -0
- package/dist/lib/senec-layout.js.map +1 -0
- package/dist/lib/weather-client.d.ts +62 -0
- package/dist/lib/weather-client.d.ts.map +1 -0
- package/dist/lib/weather-client.js +234 -0
- package/dist/lib/weather-client.js.map +1 -0
- package/dist/nodes/senec-data.js +10 -2
- package/dist/nodes/senec-data.js.map +1 -1
- package/dist/nodes/senec-image.html +73 -53
- package/dist/nodes/senec-image.js +189 -14
- package/dist/nodes/senec-image.js.map +1 -1
- package/dist/nodes/weather.d.ts +2 -0
- package/dist/nodes/weather.d.ts.map +1 -0
- package/dist/nodes/weather.html +179 -0
- package/dist/nodes/weather.js +138 -0
- package/dist/nodes/weather.js.map +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Energy Dashboard Image Renderer (PNG)
|
|
4
|
+
*
|
|
5
|
+
* Renders the 4-card, 16:9 energy dashboard (as specified in
|
|
6
|
+
* docs/00-input/ux/) to a PNG buffer using the pure-JavaScript
|
|
7
|
+
* `pureimage` library (with optional `canvas` acceleration when
|
|
8
|
+
* available).
|
|
9
|
+
*
|
|
10
|
+
* The layout mirrors the HTML renderer (src/lib/dashboard-html-renderer.ts)
|
|
11
|
+
* and consumes the same shared DashboardModel, which merges SENEC energy
|
|
12
|
+
* data (input 1) with Weather data (input 2).
|
|
13
|
+
*
|
|
14
|
+
* Because pureimage cannot render emoji glyphs, weather / autarky icons
|
|
15
|
+
* are drawn as simple vector shapes.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.SenecImageRenderer = void 0;
|
|
19
|
+
const dashboard_layout_1 = require("./dashboard-layout");
|
|
20
|
+
class SenecImageRenderer {
|
|
21
|
+
/**
|
|
22
|
+
* @param width Target width in pixels (default 1600, the reference width).
|
|
23
|
+
* @param height Target height in pixels. If omitted, it is derived from
|
|
24
|
+
* the width to preserve the 16:9 reference aspect ratio.
|
|
25
|
+
*/
|
|
26
|
+
constructor(width = 1600, height) {
|
|
27
|
+
/** Reference to the loaded pureimage module (when that engine is used). */
|
|
28
|
+
this.pureImage = null;
|
|
29
|
+
this.width = width;
|
|
30
|
+
this.height = height ?? Math.round(width / dashboard_layout_1.DASHBOARD_ASPECT_RATIO);
|
|
31
|
+
this.fontName = 'SenecSans';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Render the dashboard for the given SENEC + weather data.
|
|
35
|
+
*
|
|
36
|
+
* Accepts either a pre-built DashboardModel, or a SenecData object with
|
|
37
|
+
* an optional WeatherData argument (which are merged internally).
|
|
38
|
+
*
|
|
39
|
+
* @returns PNG image as a Buffer.
|
|
40
|
+
*/
|
|
41
|
+
async render(data, weather) {
|
|
42
|
+
const model = this.toModel(data, weather);
|
|
43
|
+
const { canvas, ctx, encode, engine } = this.createCanvas();
|
|
44
|
+
if (engine === 'pureimage') {
|
|
45
|
+
await this.ensurePureImageFont();
|
|
46
|
+
}
|
|
47
|
+
const restoreWarn = this.suppressPureImagePathWarnings();
|
|
48
|
+
try {
|
|
49
|
+
this.drawDashboard(ctx, model);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
restoreWarn();
|
|
53
|
+
}
|
|
54
|
+
return encode(canvas);
|
|
55
|
+
}
|
|
56
|
+
toModel(data, weather) {
|
|
57
|
+
if (this.isDashboardModel(data)) {
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
return (0, dashboard_layout_1.buildDashboardModel)(data, weather ?? null);
|
|
61
|
+
}
|
|
62
|
+
isDashboardModel(value) {
|
|
63
|
+
return (value &&
|
|
64
|
+
typeof value === 'object' &&
|
|
65
|
+
'current' in value &&
|
|
66
|
+
'today' in value &&
|
|
67
|
+
'autarky' in value);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Temporarily filter pureimage's "can't project the same paths" warnings
|
|
71
|
+
* from console.warn. Returns a function that restores the original.
|
|
72
|
+
*/
|
|
73
|
+
suppressPureImagePathWarnings() {
|
|
74
|
+
const original = console.warn;
|
|
75
|
+
console.warn = (...args) => {
|
|
76
|
+
if (typeof args[0] === 'string' && args[0].includes("can't project the same paths")) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
original.apply(console, args);
|
|
80
|
+
};
|
|
81
|
+
return () => {
|
|
82
|
+
console.warn = original;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// --------------------------------------------------------------------
|
|
86
|
+
// Top-level layout
|
|
87
|
+
// --------------------------------------------------------------------
|
|
88
|
+
drawDashboard(ctx, model) {
|
|
89
|
+
// Page + frame background.
|
|
90
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.page;
|
|
91
|
+
ctx.fillRect(0, 0, this.width, this.height);
|
|
92
|
+
const outer = this.clamp(10, this.u() * 1.5, 24);
|
|
93
|
+
const gap = outer;
|
|
94
|
+
// Reserve a slim status bar at the very bottom.
|
|
95
|
+
const statusH = this.clamp(14, this.u() * 1.8, 30);
|
|
96
|
+
const frame = {
|
|
97
|
+
x: outer,
|
|
98
|
+
y: outer,
|
|
99
|
+
w: this.width - 2 * outer,
|
|
100
|
+
h: this.height - 2 * outer - statusH,
|
|
101
|
+
};
|
|
102
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.white;
|
|
103
|
+
ctx.fillRect(frame.x, frame.y, frame.w, frame.h);
|
|
104
|
+
// 2x2 grid of cards inside the frame.
|
|
105
|
+
const cardW = (frame.w - gap) / 2;
|
|
106
|
+
const cardH = (frame.h - gap) / 2;
|
|
107
|
+
const cards = [
|
|
108
|
+
{ x: frame.x, y: frame.y, w: cardW, h: cardH }, // top-left Aktuell
|
|
109
|
+
{ x: frame.x + cardW + gap, y: frame.y, w: cardW, h: cardH }, // top-right Heute
|
|
110
|
+
{ x: frame.x, y: frame.y + cardH + gap, w: cardW, h: cardH }, // bot-left Autarkie
|
|
111
|
+
{ x: frame.x + cardW + gap, y: frame.y + cardH + gap, w: cardW, h: cardH }, // bot-right Wetter
|
|
112
|
+
];
|
|
113
|
+
this.drawFlowCard(ctx, cards[0], dashboard_layout_1.CARD_TITLES.current, model.current, false);
|
|
114
|
+
this.drawFlowCard(ctx, cards[1], dashboard_layout_1.CARD_TITLES.today, model.today, true);
|
|
115
|
+
this.drawAutarkyCard(ctx, cards[2], model);
|
|
116
|
+
this.drawWeatherCard(ctx, cards[3], model);
|
|
117
|
+
// Status line along the bottom.
|
|
118
|
+
this.drawStatusLine(ctx, { x: frame.x, y: frame.y + frame.h + gap * 0.2, w: frame.w, h: statusH }, model);
|
|
119
|
+
}
|
|
120
|
+
drawStatusLine(ctx, r, model) {
|
|
121
|
+
const fontSize = Math.max(9, r.h * 0.55);
|
|
122
|
+
const dotR = r.h * 0.16;
|
|
123
|
+
const cy = r.y + r.h / 2;
|
|
124
|
+
// Render right-aligned chips: SENEC then Wetter-API.
|
|
125
|
+
const chips = [
|
|
126
|
+
{ label: 'SENEC', status: model.status.senec },
|
|
127
|
+
{ label: 'Wetter-API', status: model.status.weather },
|
|
128
|
+
];
|
|
129
|
+
ctx.textBaseline = 'middle';
|
|
130
|
+
ctx.textAlign = 'left';
|
|
131
|
+
this.setFont(ctx, fontSize, false);
|
|
132
|
+
// Measure each chip so we can lay them out from the right edge.
|
|
133
|
+
const gap = r.h * 0.9;
|
|
134
|
+
const chipTexts = chips.map((c) => {
|
|
135
|
+
const state = c.status.ok ? 'OK' : c.status.fallback ? 'Fallback' : 'Fehler';
|
|
136
|
+
const time = this.shortTime(c.status.timestamp);
|
|
137
|
+
return { ...c, text: `${c.label}: ${state} ${time}` };
|
|
138
|
+
});
|
|
139
|
+
// Compute total width.
|
|
140
|
+
const widths = chipTexts.map((c) => dotR * 3 + this.textWidth(ctx, c.text));
|
|
141
|
+
const total = widths.reduce((a, b) => a + b, 0) + gap * (chipTexts.length - 1);
|
|
142
|
+
let x = r.x + r.w - total;
|
|
143
|
+
for (let i = 0; i < chipTexts.length; i++) {
|
|
144
|
+
const c = chipTexts[i];
|
|
145
|
+
const dotColor = c.status.ok
|
|
146
|
+
? dashboard_layout_1.DASHBOARD_PALETTE.green
|
|
147
|
+
: c.status.fallback
|
|
148
|
+
? dashboard_layout_1.DASHBOARD_PALETTE.orange
|
|
149
|
+
: '#e5484d';
|
|
150
|
+
ctx.beginPath();
|
|
151
|
+
ctx.arc(x + dotR, cy, dotR, 0, Math.PI * 2);
|
|
152
|
+
ctx.fillStyle = dotColor;
|
|
153
|
+
ctx.fill();
|
|
154
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.muted;
|
|
155
|
+
ctx.textAlign = 'left';
|
|
156
|
+
ctx.textBaseline = 'middle';
|
|
157
|
+
this.setFont(ctx, fontSize, false);
|
|
158
|
+
this.text(ctx, c.text, x + dotR * 3, cy);
|
|
159
|
+
x += widths[i] + gap;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
shortTime(iso) {
|
|
163
|
+
if (!iso) {
|
|
164
|
+
return '--:--';
|
|
165
|
+
}
|
|
166
|
+
const d = new Date(iso);
|
|
167
|
+
if (isNaN(d.getTime())) {
|
|
168
|
+
return '--:--';
|
|
169
|
+
}
|
|
170
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
171
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
172
|
+
return `${hh}:${mm}`;
|
|
173
|
+
}
|
|
174
|
+
drawCardShell(ctx, r, title) {
|
|
175
|
+
const radius = this.clamp(10, this.u() * 1.125, 18);
|
|
176
|
+
this.roundedRect(ctx, r.x, r.y, r.w, r.h, radius);
|
|
177
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.white;
|
|
178
|
+
ctx.fill();
|
|
179
|
+
ctx.strokeStyle = dashboard_layout_1.DASHBOARD_PALETTE.border;
|
|
180
|
+
ctx.lineWidth = this.scaleStroke(1);
|
|
181
|
+
ctx.stroke();
|
|
182
|
+
const headerH = this.clamp(30, this.u() * 2.875, 46);
|
|
183
|
+
const titleFont = this.clamp(16, this.u() * 1.875, 30);
|
|
184
|
+
ctx.textAlign = 'center';
|
|
185
|
+
ctx.textBaseline = 'middle';
|
|
186
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.blue;
|
|
187
|
+
this.setFont(ctx, titleFont, true);
|
|
188
|
+
this.text(ctx, title, r.x + r.w / 2, r.y + headerH / 2 + this.u() * 0.6);
|
|
189
|
+
const padX = this.clamp(10, this.u() * 1.25, 20);
|
|
190
|
+
const padTop = this.clamp(10, this.u() * 1.125, 18);
|
|
191
|
+
const padBottom = this.clamp(10, this.u() * 1.25, 20);
|
|
192
|
+
// Return the inner "flow-area" rect below the header.
|
|
193
|
+
return {
|
|
194
|
+
x: r.x + padX,
|
|
195
|
+
y: r.y + padTop + headerH,
|
|
196
|
+
w: r.w - 2 * padX,
|
|
197
|
+
h: r.h - padTop - headerH - padBottom,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
// --------------------------------------------------------------------
|
|
201
|
+
// Energy-flow cards (Aktuell / Heute)
|
|
202
|
+
// --------------------------------------------------------------------
|
|
203
|
+
drawFlowCard(ctx, card, title, values, isToday) {
|
|
204
|
+
const area = this.drawCardShell(ctx, card, title);
|
|
205
|
+
// Node diameter: 7% of dashboard width, clamped, mirrors --node-d.
|
|
206
|
+
const nodeD = this.clamp(58, this.u() * 7.0, 112);
|
|
207
|
+
const radius = nodeD / 2;
|
|
208
|
+
const center = this.flowPoint(area, 'hub');
|
|
209
|
+
const hubR = (nodeD * dashboard_layout_1.NODE_RATIOS.hubDiameter) / 2;
|
|
210
|
+
// 1) Connectors (behind nodes).
|
|
211
|
+
this.drawConnector(ctx, this.flowPoint(area, 'solar'), center, dashboard_layout_1.DASHBOARD_PALETTE.solar, nodeD);
|
|
212
|
+
this.drawConnector(ctx, this.flowPoint(area, 'grid'), center, dashboard_layout_1.DASHBOARD_PALETTE.gray, nodeD);
|
|
213
|
+
this.drawConnector(ctx, this.flowPoint(area, 'battery'), center, dashboard_layout_1.DASHBOARD_PALETTE.battery, nodeD);
|
|
214
|
+
this.drawConnector(ctx, this.flowPoint(area, 'wallbox'), center, dashboard_layout_1.DASHBOARD_PALETTE.orange, nodeD);
|
|
215
|
+
this.drawConnector(ctx, this.flowPoint(area, 'consumption'), center, dashboard_layout_1.DASHBOARD_PALETTE.orange, nodeD);
|
|
216
|
+
// 2) Energy nodes.
|
|
217
|
+
this.drawEnergyNode(ctx, this.flowPoint(area, 'solar'), radius, dashboard_layout_1.DASHBOARD_PALETTE.solar, dashboard_layout_1.DASHBOARD_PALETTE.solarHi, values.solar, isToday);
|
|
218
|
+
this.drawEnergyNode(ctx, this.flowPoint(area, 'grid'), radius, dashboard_layout_1.DASHBOARD_PALETTE.gray, dashboard_layout_1.DASHBOARD_PALETTE.grayHi, values.grid, isToday);
|
|
219
|
+
this.drawEnergyNode(ctx, this.flowPoint(area, 'battery'), radius, dashboard_layout_1.DASHBOARD_PALETTE.battery, dashboard_layout_1.DASHBOARD_PALETTE.batteryHi, values.battery, isToday);
|
|
220
|
+
this.drawEnergyNode(ctx, this.flowPoint(area, 'wallbox'), radius, dashboard_layout_1.DASHBOARD_PALETTE.orange, dashboard_layout_1.DASHBOARD_PALETTE.orangeHi, values.wallbox, isToday);
|
|
221
|
+
this.drawEnergyNode(ctx, this.flowPoint(area, 'consumption'), radius, dashboard_layout_1.DASHBOARD_PALETTE.orange, dashboard_layout_1.DASHBOARD_PALETTE.orangeHi, values.consumption, isToday);
|
|
222
|
+
// 3) Center hub on top.
|
|
223
|
+
ctx.beginPath();
|
|
224
|
+
ctx.arc(center.x, center.y, hubR, 0, Math.PI * 2);
|
|
225
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.white;
|
|
226
|
+
ctx.fill();
|
|
227
|
+
ctx.strokeStyle = dashboard_layout_1.DASHBOARD_PALETTE.border;
|
|
228
|
+
ctx.lineWidth = this.scaleStroke(1);
|
|
229
|
+
ctx.stroke();
|
|
230
|
+
}
|
|
231
|
+
flowPoint(area, id) {
|
|
232
|
+
const p = dashboard_layout_1.FLOW_POSITIONS[id];
|
|
233
|
+
return { x: area.x + p.x * area.w, y: area.y + p.y * area.h };
|
|
234
|
+
}
|
|
235
|
+
drawConnector(ctx, from, to, color, nodeD) {
|
|
236
|
+
ctx.lineCap = 'round';
|
|
237
|
+
ctx.lineWidth = nodeD * dashboard_layout_1.NODE_RATIOS.connectorThickness;
|
|
238
|
+
ctx.globalAlpha = 0.55;
|
|
239
|
+
ctx.strokeStyle = color;
|
|
240
|
+
ctx.beginPath();
|
|
241
|
+
ctx.moveTo(from.x, from.y);
|
|
242
|
+
ctx.lineTo(to.x, to.y);
|
|
243
|
+
ctx.stroke();
|
|
244
|
+
ctx.globalAlpha = 1;
|
|
245
|
+
ctx.lineCap = 'butt';
|
|
246
|
+
}
|
|
247
|
+
drawEnergyNode(ctx, c, radius, color, colorHi, v, isToday) {
|
|
248
|
+
// Approximate the radial-gradient highlight with a base fill + a small
|
|
249
|
+
// lighter accent circle offset toward the top-left.
|
|
250
|
+
ctx.beginPath();
|
|
251
|
+
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
|
|
252
|
+
ctx.fillStyle = color;
|
|
253
|
+
ctx.fill();
|
|
254
|
+
// Subtle top-left highlight approximating the CSS radial-gradient.
|
|
255
|
+
ctx.beginPath();
|
|
256
|
+
ctx.arc(c.x - radius * 0.32, c.y - radius * 0.38, radius * 0.5, 0, Math.PI * 2);
|
|
257
|
+
ctx.globalAlpha = 0.28;
|
|
258
|
+
ctx.fillStyle = colorHi;
|
|
259
|
+
ctx.fill();
|
|
260
|
+
ctx.globalAlpha = 1;
|
|
261
|
+
const nodeD = radius * 2;
|
|
262
|
+
const labelSize = nodeD * dashboard_layout_1.TEXT_RATIOS.label;
|
|
263
|
+
const unitSize = nodeD * dashboard_layout_1.TEXT_RATIOS.unit;
|
|
264
|
+
const hintSize = nodeD * dashboard_layout_1.TEXT_RATIOS.hint;
|
|
265
|
+
const dual = isToday && v.dual;
|
|
266
|
+
const valueText = dual ? v.dual : v.value;
|
|
267
|
+
// Slightly smaller value than the raw ratio so it clears the label above.
|
|
268
|
+
const valueSize = dual
|
|
269
|
+
? nodeD * dashboard_layout_1.TEXT_RATIOS.multiValue * 0.96
|
|
270
|
+
: nodeD * dashboard_layout_1.TEXT_RATIOS.value * 0.94;
|
|
271
|
+
ctx.textAlign = 'center';
|
|
272
|
+
ctx.textBaseline = 'middle';
|
|
273
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.white;
|
|
274
|
+
// Vertical stack: label, value, (hint), unit — centered on the circle.
|
|
275
|
+
//
|
|
276
|
+
// Text "line height" is smaller than the nominal font size (glyph cap
|
|
277
|
+
// height is roughly 70% of the em box). Using the raw font size as the
|
|
278
|
+
// per-line height overestimates the block and pushes the visual centre
|
|
279
|
+
// downward, so we use an effective line height and centre the block of
|
|
280
|
+
// line centres precisely on c.y.
|
|
281
|
+
const gap = radius * 0.12;
|
|
282
|
+
// A little extra breathing room specifically below the label so the large
|
|
283
|
+
// value does not visually touch it.
|
|
284
|
+
const labelGap = radius * 0.2;
|
|
285
|
+
const lineH = (px) => px * 0.72;
|
|
286
|
+
const lines = [labelSize, valueSize];
|
|
287
|
+
if (dual) {
|
|
288
|
+
lines.push(hintSize);
|
|
289
|
+
}
|
|
290
|
+
lines.push(unitSize);
|
|
291
|
+
// Gap that precedes each line (index 0 has none).
|
|
292
|
+
const gapBefore = (index) => index === 0 ? 0 : index === 1 ? labelGap : gap;
|
|
293
|
+
// Total height = sum of effective line heights + the gaps between them.
|
|
294
|
+
const blockH = lines.reduce((sum, px) => sum + lineH(px), 0) +
|
|
295
|
+
lines.reduce((sum, _px, i) => sum + gapBefore(i), 0);
|
|
296
|
+
// Centre the block on c.y, then nudge the whole stack slightly downward
|
|
297
|
+
// (~15% of a line) so the value sits clearly below the label.
|
|
298
|
+
let top = c.y - blockH / 2 + lineH(valueSize) * 0.15;
|
|
299
|
+
let idx = 0;
|
|
300
|
+
const centreOf = (px) => {
|
|
301
|
+
top += gapBefore(idx);
|
|
302
|
+
const cy = top + lineH(px) / 2;
|
|
303
|
+
top += lineH(px);
|
|
304
|
+
idx += 1;
|
|
305
|
+
return cy;
|
|
306
|
+
};
|
|
307
|
+
const labelY = centreOf(labelSize);
|
|
308
|
+
const valueY = centreOf(valueSize);
|
|
309
|
+
const hintY = dual ? centreOf(hintSize) : 0;
|
|
310
|
+
const unitY = centreOf(unitSize);
|
|
311
|
+
this.setFont(ctx, labelSize, false);
|
|
312
|
+
this.fitText(ctx, v.label, c.x, labelY, this.chordWidth(radius, labelY - c.y), labelSize);
|
|
313
|
+
this.setFont(ctx, valueSize, true);
|
|
314
|
+
this.fitText(ctx, valueText, c.x, valueY, this.chordWidth(radius, valueY - c.y), valueSize);
|
|
315
|
+
if (dual) {
|
|
316
|
+
this.setFont(ctx, hintSize, false);
|
|
317
|
+
this.fitText(ctx, v.hint || '', c.x, hintY, this.chordWidth(radius, hintY - c.y), hintSize);
|
|
318
|
+
}
|
|
319
|
+
this.setFont(ctx, unitSize, false);
|
|
320
|
+
this.fitText(ctx, v.unit, c.x, unitY, this.chordWidth(radius, unitY - c.y), unitSize);
|
|
321
|
+
}
|
|
322
|
+
// --------------------------------------------------------------------
|
|
323
|
+
// Autarky card
|
|
324
|
+
// --------------------------------------------------------------------
|
|
325
|
+
drawAutarkyCard(ctx, card, model) {
|
|
326
|
+
const area = this.drawCardShell(ctx, card, dashboard_layout_1.CARD_TITLES.autarky);
|
|
327
|
+
const a = model.autarky;
|
|
328
|
+
const kpis = [
|
|
329
|
+
{ label: 'Heute', percent: a.todayPercent, color: dashboard_layout_1.DASHBOARD_PALETTE.green, icon: 'leaf' },
|
|
330
|
+
{ label: 'Aktuell', percent: a.currentPercent, color: dashboard_layout_1.DASHBOARD_PALETTE.green, icon: 'bolt' },
|
|
331
|
+
{ label: 'Speicherstatus', percent: a.batterySocPercent, color: dashboard_layout_1.DASHBOARD_PALETTE.statusBlue, icon: 'battery' },
|
|
332
|
+
];
|
|
333
|
+
const colW = area.w / 3;
|
|
334
|
+
const ringD = this.clamp(78, this.u() * 8.0, 128);
|
|
335
|
+
const nodeD = this.clamp(58, this.u() * 7.0, 112);
|
|
336
|
+
const labelSize = nodeD * 0.168;
|
|
337
|
+
const percentSize = nodeD * 0.375;
|
|
338
|
+
kpis.forEach((kpi, i) => {
|
|
339
|
+
const cx = area.x + colW * (i + 0.5);
|
|
340
|
+
const topY = area.y + area.h * 0.06;
|
|
341
|
+
ctx.textAlign = 'center';
|
|
342
|
+
ctx.textBaseline = 'middle';
|
|
343
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.blue;
|
|
344
|
+
this.setFont(ctx, labelSize, true);
|
|
345
|
+
this.text(ctx, kpi.label, cx, topY + labelSize / 2);
|
|
346
|
+
const percentY = topY + labelSize + percentSize * 0.7;
|
|
347
|
+
ctx.fillStyle = kpi.color;
|
|
348
|
+
this.setFont(ctx, percentSize, true);
|
|
349
|
+
this.text(ctx, `${kpi.percent}%`, cx, percentY);
|
|
350
|
+
const ringCy = area.y + area.h - ringD / 2 - area.h * 0.04;
|
|
351
|
+
this.drawRing(ctx, cx, ringCy, ringD / 2, kpi.percent, kpi.color, kpi.icon);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
drawRing(ctx, cx, cy, radius, percent, color, icon) {
|
|
355
|
+
const stroke = radius * 0.21;
|
|
356
|
+
const start = -Math.PI / 2;
|
|
357
|
+
const end = start + (Math.min(100, Math.max(0, percent)) / 100) * Math.PI * 2;
|
|
358
|
+
// Background track.
|
|
359
|
+
ctx.beginPath();
|
|
360
|
+
ctx.arc(cx, cy, radius - stroke / 2, 0, Math.PI * 2);
|
|
361
|
+
ctx.strokeStyle = dashboard_layout_1.DASHBOARD_PALETTE.ringBg;
|
|
362
|
+
ctx.lineWidth = stroke;
|
|
363
|
+
ctx.lineCap = 'butt';
|
|
364
|
+
ctx.stroke();
|
|
365
|
+
// Progress arc.
|
|
366
|
+
ctx.beginPath();
|
|
367
|
+
ctx.arc(cx, cy, radius - stroke / 2, start, end);
|
|
368
|
+
ctx.strokeStyle = color;
|
|
369
|
+
ctx.lineWidth = stroke;
|
|
370
|
+
ctx.lineCap = 'round';
|
|
371
|
+
ctx.stroke();
|
|
372
|
+
ctx.lineCap = 'butt';
|
|
373
|
+
this.drawGlyph(ctx, icon, cx, cy, radius * 0.5, color);
|
|
374
|
+
}
|
|
375
|
+
// --------------------------------------------------------------------
|
|
376
|
+
// Weather card
|
|
377
|
+
// --------------------------------------------------------------------
|
|
378
|
+
drawWeatherCard(ctx, card, model) {
|
|
379
|
+
const area = this.drawCardShell(ctx, card, dashboard_layout_1.CARD_TITLES.weather);
|
|
380
|
+
const w = model.weather;
|
|
381
|
+
const nodeD = this.clamp(58, this.u() * 7.0, 112);
|
|
382
|
+
if (!w) {
|
|
383
|
+
ctx.textAlign = 'center';
|
|
384
|
+
ctx.textBaseline = 'middle';
|
|
385
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.muted;
|
|
386
|
+
this.setFont(ctx, nodeD * 0.22, false);
|
|
387
|
+
this.text(ctx, 'Keine Wetterdaten', area.x + area.w / 2, area.y + area.h / 2);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// Location label (top-right of the weather area). Rendered small and
|
|
391
|
+
// clear of the metrics block below.
|
|
392
|
+
if (w.location) {
|
|
393
|
+
ctx.textAlign = 'right';
|
|
394
|
+
ctx.textBaseline = 'top';
|
|
395
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.muted;
|
|
396
|
+
this.setFont(ctx, nodeD * 0.13, false);
|
|
397
|
+
this.text(ctx, w.location, area.x + area.w, area.y - nodeD * 0.02);
|
|
398
|
+
}
|
|
399
|
+
// Top-left: current condition icon + temperature.
|
|
400
|
+
const iconSize = nodeD * 0.72;
|
|
401
|
+
const iconCx = area.x + area.w * 0.13;
|
|
402
|
+
const iconCy = area.y + area.h * 0.24;
|
|
403
|
+
this.drawWeatherGlyph(ctx, w.icon, iconCx, iconCy, iconSize / 2);
|
|
404
|
+
const tempSize = nodeD * 0.5;
|
|
405
|
+
const condSize = nodeD * 0.18;
|
|
406
|
+
const tempX = area.x + area.w * 0.28;
|
|
407
|
+
ctx.textAlign = 'left';
|
|
408
|
+
ctx.textBaseline = 'middle';
|
|
409
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.main;
|
|
410
|
+
this.setFont(ctx, tempSize, false);
|
|
411
|
+
// Sit the temperature slightly above the icon centre.
|
|
412
|
+
this.text(ctx, `${w.temperature}°C`, tempX, iconCy - tempSize * 0.18);
|
|
413
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.muted;
|
|
414
|
+
this.setFont(ctx, condSize, false);
|
|
415
|
+
// Place condition clear of the temperature glyphs' descenders.
|
|
416
|
+
this.text(ctx, w.condition, tempX, iconCy + tempSize * 0.5 + condSize * 0.75);
|
|
417
|
+
// Top-right: metrics. Each row stacks value over sublabel with a
|
|
418
|
+
// dedicated gap; rows are spaced so sublabels never touch the next row.
|
|
419
|
+
const metricX = area.x + area.w * 0.6;
|
|
420
|
+
const metricLabelX = metricX + nodeD * 0.3;
|
|
421
|
+
const mvSize = nodeD * 0.21;
|
|
422
|
+
const mlSize = nodeD * 0.115;
|
|
423
|
+
const metrics = [
|
|
424
|
+
{ icon: 'temp', mv: `${w.tempMax}° / ${w.tempMin}°`, ml: 'max / min' },
|
|
425
|
+
{ icon: 'drop', mv: `${w.humidity}%`, ml: 'Luftfeuchte' },
|
|
426
|
+
{ icon: 'wind', mv: `${w.windSpeed} km/h`, ml: w.windDirection },
|
|
427
|
+
];
|
|
428
|
+
// Distribute three rows across the upper ~45% of the card.
|
|
429
|
+
const mTop = area.y + area.h * 0.09;
|
|
430
|
+
const mRowH = nodeD * 0.4;
|
|
431
|
+
for (let i = 0; i < metrics.length; i++) {
|
|
432
|
+
const m = metrics[i];
|
|
433
|
+
const rowCy = mTop + mRowH * i + mRowH / 2;
|
|
434
|
+
this.drawGlyph(ctx, m.icon, metricX, rowCy - mvSize * 0.15, nodeD * 0.1, dashboard_layout_1.DASHBOARD_PALETTE.blue);
|
|
435
|
+
ctx.textAlign = 'left';
|
|
436
|
+
ctx.textBaseline = 'middle';
|
|
437
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.main;
|
|
438
|
+
this.setFont(ctx, mvSize, true);
|
|
439
|
+
this.text(ctx, m.mv, metricLabelX, rowCy - mvSize * 0.42);
|
|
440
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.muted;
|
|
441
|
+
this.setFont(ctx, mlSize, false);
|
|
442
|
+
this.text(ctx, m.ml, metricLabelX, rowCy + mvSize * 0.5 + mlSize * 0.2);
|
|
443
|
+
}
|
|
444
|
+
// Bottom: 5-day forecast row.
|
|
445
|
+
const forecast = w.forecast.slice(0, 5);
|
|
446
|
+
if (forecast.length > 0) {
|
|
447
|
+
const fY = area.y + area.h * 0.6;
|
|
448
|
+
const fH = area.h * 0.4;
|
|
449
|
+
// Separator line.
|
|
450
|
+
ctx.strokeStyle = '#edf0f4';
|
|
451
|
+
ctx.lineWidth = this.scaleStroke(1);
|
|
452
|
+
ctx.beginPath();
|
|
453
|
+
ctx.moveTo(area.x, fY);
|
|
454
|
+
ctx.lineTo(area.x + area.w, fY);
|
|
455
|
+
ctx.stroke();
|
|
456
|
+
const colW = area.w / forecast.length;
|
|
457
|
+
const daySize = nodeD * 0.16;
|
|
458
|
+
const icoSize = nodeD * 0.3;
|
|
459
|
+
const tempSize = nodeD * 0.16;
|
|
460
|
+
forecast.forEach((f, i) => {
|
|
461
|
+
const cx = area.x + colW * (i + 0.5);
|
|
462
|
+
if (i > 0) {
|
|
463
|
+
ctx.strokeStyle = '#edf0f4';
|
|
464
|
+
ctx.lineWidth = this.scaleStroke(1);
|
|
465
|
+
ctx.beginPath();
|
|
466
|
+
ctx.moveTo(area.x + colW * i, fY + fH * 0.1);
|
|
467
|
+
ctx.lineTo(area.x + colW * i, fY + fH * 0.9);
|
|
468
|
+
ctx.stroke();
|
|
469
|
+
}
|
|
470
|
+
ctx.textAlign = 'center';
|
|
471
|
+
ctx.textBaseline = 'middle';
|
|
472
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.blue;
|
|
473
|
+
this.setFont(ctx, daySize, true);
|
|
474
|
+
this.text(ctx, f.day, cx, fY + fH * 0.22);
|
|
475
|
+
this.drawWeatherGlyph(ctx, f.icon, cx, fY + fH * 0.52, icoSize / 2);
|
|
476
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.muted;
|
|
477
|
+
this.setFont(ctx, tempSize, true);
|
|
478
|
+
this.text(ctx, `${f.temp}°`, cx, fY + fH * 0.82);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// --------------------------------------------------------------------
|
|
483
|
+
// Icon glyphs (drawn as vectors; pureimage cannot render emoji)
|
|
484
|
+
// --------------------------------------------------------------------
|
|
485
|
+
drawGlyph(ctx, kind, cx, cy, r, color) {
|
|
486
|
+
ctx.fillStyle = color;
|
|
487
|
+
ctx.strokeStyle = color;
|
|
488
|
+
ctx.lineWidth = Math.max(1, r * 0.22);
|
|
489
|
+
ctx.lineCap = 'round';
|
|
490
|
+
switch (kind) {
|
|
491
|
+
case 'leaf': {
|
|
492
|
+
ctx.beginPath();
|
|
493
|
+
ctx.moveTo(cx - r, cy + r);
|
|
494
|
+
ctx.quadraticCurveTo(cx - r, cy - r, cx + r, cy - r);
|
|
495
|
+
ctx.quadraticCurveTo(cx + r, cy + r, cx - r, cy + r);
|
|
496
|
+
ctx.closePath();
|
|
497
|
+
ctx.fill();
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
case 'bolt': {
|
|
501
|
+
ctx.beginPath();
|
|
502
|
+
ctx.moveTo(cx + r * 0.2, cy - r);
|
|
503
|
+
ctx.lineTo(cx - r * 0.5, cy + r * 0.1);
|
|
504
|
+
ctx.lineTo(cx, cy + r * 0.1);
|
|
505
|
+
ctx.lineTo(cx - r * 0.2, cy + r);
|
|
506
|
+
ctx.lineTo(cx + r * 0.5, cy - r * 0.1);
|
|
507
|
+
ctx.lineTo(cx, cy - r * 0.1);
|
|
508
|
+
ctx.closePath();
|
|
509
|
+
ctx.fill();
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
case 'battery': {
|
|
513
|
+
ctx.beginPath();
|
|
514
|
+
this.rectPath(ctx, cx - r * 0.8, cy - r * 0.5, r * 1.6, r);
|
|
515
|
+
ctx.fill();
|
|
516
|
+
ctx.beginPath();
|
|
517
|
+
this.rectPath(ctx, cx + r * 0.8, cy - r * 0.2, r * 0.2, r * 0.4);
|
|
518
|
+
ctx.fill();
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
case 'temp': {
|
|
522
|
+
ctx.beginPath();
|
|
523
|
+
ctx.arc(cx, cy + r * 0.5, r * 0.5, 0, Math.PI * 2);
|
|
524
|
+
ctx.fill();
|
|
525
|
+
ctx.beginPath();
|
|
526
|
+
ctx.moveTo(cx, cy - r);
|
|
527
|
+
ctx.lineTo(cx, cy + r * 0.3);
|
|
528
|
+
ctx.stroke();
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
case 'drop': {
|
|
532
|
+
ctx.beginPath();
|
|
533
|
+
ctx.moveTo(cx, cy - r);
|
|
534
|
+
ctx.quadraticCurveTo(cx + r, cy + r * 0.3, cx, cy + r);
|
|
535
|
+
ctx.quadraticCurveTo(cx - r, cy + r * 0.3, cx, cy - r);
|
|
536
|
+
ctx.closePath();
|
|
537
|
+
ctx.fill();
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case 'wind': {
|
|
541
|
+
ctx.beginPath();
|
|
542
|
+
ctx.moveTo(cx - r, cy - r * 0.4);
|
|
543
|
+
ctx.lineTo(cx + r * 0.6, cy - r * 0.4);
|
|
544
|
+
ctx.moveTo(cx - r, cy + r * 0.2);
|
|
545
|
+
ctx.lineTo(cx + r, cy + r * 0.2);
|
|
546
|
+
ctx.moveTo(cx - r, cy + r * 0.8);
|
|
547
|
+
ctx.lineTo(cx + r * 0.3, cy + r * 0.8);
|
|
548
|
+
ctx.stroke();
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
ctx.lineCap = 'butt';
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Map a weather emoji to a simple vector sun/cloud/rain glyph.
|
|
556
|
+
*/
|
|
557
|
+
drawWeatherGlyph(ctx, emoji, cx, cy, r) {
|
|
558
|
+
const isSun = ['☀️', '🌤️'].includes(emoji);
|
|
559
|
+
const isRain = ['🌧️', '🌦️', '⛈️'].includes(emoji);
|
|
560
|
+
const isSnow = ['🌨️', '❄️'].includes(emoji);
|
|
561
|
+
if (isSun) {
|
|
562
|
+
// Sun rays + disc.
|
|
563
|
+
ctx.strokeStyle = dashboard_layout_1.DASHBOARD_PALETTE.solar;
|
|
564
|
+
ctx.lineWidth = Math.max(1, r * 0.12);
|
|
565
|
+
ctx.lineCap = 'round';
|
|
566
|
+
for (let i = 0; i < 8; i++) {
|
|
567
|
+
const a = (i / 8) * Math.PI * 2;
|
|
568
|
+
ctx.beginPath();
|
|
569
|
+
ctx.moveTo(cx + Math.cos(a) * r * 0.7, cy + Math.sin(a) * r * 0.7);
|
|
570
|
+
ctx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
|
|
571
|
+
ctx.stroke();
|
|
572
|
+
}
|
|
573
|
+
ctx.lineCap = 'butt';
|
|
574
|
+
ctx.beginPath();
|
|
575
|
+
ctx.arc(cx, cy, r * 0.5, 0, Math.PI * 2);
|
|
576
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.solarHi;
|
|
577
|
+
ctx.fill();
|
|
578
|
+
}
|
|
579
|
+
// Cloud (drawn for most conditions except pure sun).
|
|
580
|
+
if (!isSun || emoji === '🌤️') {
|
|
581
|
+
const cloudColor = '#c7d0dc';
|
|
582
|
+
ctx.fillStyle = cloudColor;
|
|
583
|
+
ctx.beginPath();
|
|
584
|
+
ctx.arc(cx - r * 0.4, cy + r * 0.1, r * 0.45, 0, Math.PI * 2);
|
|
585
|
+
ctx.fill();
|
|
586
|
+
ctx.beginPath();
|
|
587
|
+
ctx.arc(cx + r * 0.2, cy - r * 0.1, r * 0.55, 0, Math.PI * 2);
|
|
588
|
+
ctx.fill();
|
|
589
|
+
ctx.beginPath();
|
|
590
|
+
ctx.arc(cx + r * 0.6, cy + r * 0.15, r * 0.4, 0, Math.PI * 2);
|
|
591
|
+
ctx.fill();
|
|
592
|
+
ctx.beginPath();
|
|
593
|
+
this.rectPath(ctx, cx - r * 0.8, cy + r * 0.1, r * 1.6, r * 0.4);
|
|
594
|
+
ctx.fill();
|
|
595
|
+
}
|
|
596
|
+
if (isRain) {
|
|
597
|
+
ctx.strokeStyle = dashboard_layout_1.DASHBOARD_PALETTE.statusBlue;
|
|
598
|
+
ctx.lineWidth = Math.max(1, r * 0.14);
|
|
599
|
+
ctx.lineCap = 'round';
|
|
600
|
+
for (let i = -1; i <= 1; i++) {
|
|
601
|
+
ctx.beginPath();
|
|
602
|
+
ctx.moveTo(cx + i * r * 0.4, cy + r * 0.5);
|
|
603
|
+
ctx.lineTo(cx + i * r * 0.4 - r * 0.15, cy + r * 0.9);
|
|
604
|
+
ctx.stroke();
|
|
605
|
+
}
|
|
606
|
+
ctx.lineCap = 'butt';
|
|
607
|
+
}
|
|
608
|
+
if (isSnow) {
|
|
609
|
+
ctx.fillStyle = dashboard_layout_1.DASHBOARD_PALETTE.statusBlue;
|
|
610
|
+
for (let i = -1; i <= 1; i++) {
|
|
611
|
+
ctx.beginPath();
|
|
612
|
+
ctx.arc(cx + i * r * 0.4, cy + r * 0.7, r * 0.1, 0, Math.PI * 2);
|
|
613
|
+
ctx.fill();
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
rectPath(ctx, x, y, w, h) {
|
|
618
|
+
ctx.moveTo(x, y);
|
|
619
|
+
ctx.lineTo(x + w, y);
|
|
620
|
+
ctx.lineTo(x + w, y + h);
|
|
621
|
+
ctx.lineTo(x, y + h);
|
|
622
|
+
ctx.closePath();
|
|
623
|
+
}
|
|
624
|
+
// --------------------------------------------------------------------
|
|
625
|
+
// Canvas / library abstraction
|
|
626
|
+
// --------------------------------------------------------------------
|
|
627
|
+
createCanvas() {
|
|
628
|
+
// Prefer native canvas (faster) when installed, otherwise pureimage.
|
|
629
|
+
try {
|
|
630
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
631
|
+
const Canvas = require('canvas');
|
|
632
|
+
const canvas = Canvas.createCanvas(this.width, this.height);
|
|
633
|
+
const ctx = canvas.getContext('2d');
|
|
634
|
+
this.enableAntialiasing(ctx);
|
|
635
|
+
const encode = async (c) => c.toBuffer('image/png');
|
|
636
|
+
return { canvas, ctx, encode, engine: 'canvas' };
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
640
|
+
const PureImage = require('pureimage');
|
|
641
|
+
this.pureImage = PureImage;
|
|
642
|
+
const canvas = PureImage.make(this.width, this.height);
|
|
643
|
+
const ctx = canvas.getContext('2d');
|
|
644
|
+
this.enableAntialiasing(ctx);
|
|
645
|
+
const encode = (c) => this.encodePureImage(PureImage, c);
|
|
646
|
+
return { canvas, ctx, encode, engine: 'pureimage' };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Turn on antialiasing / image smoothing for whichever engine is active.
|
|
651
|
+
* Both node-canvas and pureimage expose `imageSmoothingEnabled`; pureimage
|
|
652
|
+
* also honours an internal `_settings.antialias` flag for paths and text.
|
|
653
|
+
*/
|
|
654
|
+
enableAntialiasing(ctx) {
|
|
655
|
+
try {
|
|
656
|
+
ctx.imageSmoothingEnabled = true;
|
|
657
|
+
if ('imageSmoothingQuality' in ctx) {
|
|
658
|
+
ctx.imageSmoothingQuality = 'high';
|
|
659
|
+
}
|
|
660
|
+
// pureimage-specific antialias toggle (covers path + glyph edges).
|
|
661
|
+
if ('_settings' in ctx && ctx._settings) {
|
|
662
|
+
ctx._settings.antialias = true;
|
|
663
|
+
}
|
|
664
|
+
if ('antialias' in ctx) {
|
|
665
|
+
ctx.antialias = true;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
/* engine does not expose these knobs; ignore */
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* pureimage cannot render text without a registered TrueType font.
|
|
674
|
+
*/
|
|
675
|
+
async ensurePureImageFont() {
|
|
676
|
+
if (SenecImageRenderer.fontLoaded || !this.pureImage) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
680
|
+
const fs = require('fs');
|
|
681
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
682
|
+
const path = require('path');
|
|
683
|
+
const candidates = [
|
|
684
|
+
process.env.SENEC_FONT_PATH,
|
|
685
|
+
path.join(__dirname, '..', 'assets', 'fonts', 'senec-sans.ttf'),
|
|
686
|
+
path.join(__dirname, '..', '..', 'assets', 'fonts', 'senec-sans.ttf'),
|
|
687
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
|
688
|
+
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
|
|
689
|
+
'C:\\Windows\\Fonts\\arial.ttf',
|
|
690
|
+
].filter((p) => typeof p === 'string' && p.length > 0);
|
|
691
|
+
const fontPath = candidates.find((p) => {
|
|
692
|
+
try {
|
|
693
|
+
return fs.existsSync(p);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
if (!fontPath) {
|
|
700
|
+
SenecImageRenderer.fontLoaded = true;
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const font = this.pureImage.registerFont(fontPath, this.fontName);
|
|
704
|
+
if (typeof font.loadPromises === 'function') {
|
|
705
|
+
await font.loadPromises();
|
|
706
|
+
}
|
|
707
|
+
else if (typeof font.loadSync === 'function') {
|
|
708
|
+
font.loadSync();
|
|
709
|
+
}
|
|
710
|
+
else if (typeof font.load === 'function') {
|
|
711
|
+
try {
|
|
712
|
+
font.load(() => {
|
|
713
|
+
/* intentionally ignored */
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
/* ignore load errors; shapes still render */
|
|
718
|
+
}
|
|
719
|
+
await this.waitForFontLoaded(font);
|
|
720
|
+
}
|
|
721
|
+
SenecImageRenderer.fontLoaded = true;
|
|
722
|
+
}
|
|
723
|
+
async waitForFontLoaded(font) {
|
|
724
|
+
const maxTries = 50; // ~500ms max
|
|
725
|
+
for (let i = 0; i < maxTries; i++) {
|
|
726
|
+
if (font.loaded) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
encodePureImage(PureImage, bitmap) {
|
|
733
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
734
|
+
const { PassThrough } = require('stream');
|
|
735
|
+
return new Promise((resolve, reject) => {
|
|
736
|
+
const stream = new PassThrough();
|
|
737
|
+
const chunks = [];
|
|
738
|
+
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
739
|
+
stream.on('error', reject);
|
|
740
|
+
const finish = () => resolve(Buffer.concat(chunks));
|
|
741
|
+
stream.on('end', finish);
|
|
742
|
+
stream.on('close', finish);
|
|
743
|
+
Promise.resolve(PureImage.encodePNGToStream(bitmap, stream))
|
|
744
|
+
.then(() => {
|
|
745
|
+
stream.end();
|
|
746
|
+
setImmediate(finish);
|
|
747
|
+
})
|
|
748
|
+
.catch(reject);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
// --------------------------------------------------------------------
|
|
752
|
+
// Geometry helpers
|
|
753
|
+
// --------------------------------------------------------------------
|
|
754
|
+
/** The reference unit: ~1% of dashboard width (mirrors CSS --u/1cqw). */
|
|
755
|
+
u() {
|
|
756
|
+
return this.width / 100;
|
|
757
|
+
}
|
|
758
|
+
clamp(min, value, max) {
|
|
759
|
+
return Math.min(max, Math.max(min, value));
|
|
760
|
+
}
|
|
761
|
+
/** Scale a source-resolution stroke width to the target canvas width. */
|
|
762
|
+
scaleStroke(px) {
|
|
763
|
+
return Math.max(1, (px / 1600) * this.width);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Horizontal chord width available inside a circle at a given vertical
|
|
767
|
+
* offset from the center, minus a small inset so text stays clear of the
|
|
768
|
+
* edge.
|
|
769
|
+
*/
|
|
770
|
+
chordWidth(radius, verticalOffset) {
|
|
771
|
+
const inset = 0.9;
|
|
772
|
+
const half = Math.sqrt(Math.max(0, radius * radius - verticalOffset * verticalOffset));
|
|
773
|
+
return 2 * half * inset;
|
|
774
|
+
}
|
|
775
|
+
roundedRect(ctx, x, y, w, h, r) {
|
|
776
|
+
const radius = Math.min(r, w / 2, h / 2);
|
|
777
|
+
ctx.beginPath();
|
|
778
|
+
ctx.moveTo(x + radius, y);
|
|
779
|
+
ctx.lineTo(x + w - radius, y);
|
|
780
|
+
ctx.arc(x + w - radius, y + radius, radius, -Math.PI / 2, 0);
|
|
781
|
+
ctx.lineTo(x + w, y + h - radius);
|
|
782
|
+
ctx.arc(x + w - radius, y + h - radius, radius, 0, Math.PI / 2);
|
|
783
|
+
ctx.lineTo(x + radius, y + h);
|
|
784
|
+
ctx.arc(x + radius, y + h - radius, radius, Math.PI / 2, Math.PI);
|
|
785
|
+
ctx.lineTo(x, y + radius);
|
|
786
|
+
ctx.arc(x + radius, y + radius, radius, Math.PI, (3 * Math.PI) / 2);
|
|
787
|
+
ctx.closePath();
|
|
788
|
+
}
|
|
789
|
+
// --------------------------------------------------------------------
|
|
790
|
+
// Text helpers
|
|
791
|
+
// --------------------------------------------------------------------
|
|
792
|
+
text(ctx, value, x, y) {
|
|
793
|
+
let drawX = x;
|
|
794
|
+
let drawY = y;
|
|
795
|
+
const align = ctx.textAlign || 'left';
|
|
796
|
+
if (align === 'center' || align === 'right') {
|
|
797
|
+
const wdt = this.textWidth(ctx, value);
|
|
798
|
+
drawX = align === 'center' ? x - wdt / 2 : x - wdt;
|
|
799
|
+
}
|
|
800
|
+
const baseline = ctx.textBaseline || 'alphabetic';
|
|
801
|
+
const fontPx = this.currentFontSize(ctx);
|
|
802
|
+
if (baseline === 'middle') {
|
|
803
|
+
drawY = y + fontPx * 0.35;
|
|
804
|
+
}
|
|
805
|
+
else if (baseline === 'top') {
|
|
806
|
+
drawY = y + fontPx * 0.8;
|
|
807
|
+
}
|
|
808
|
+
// We compute alignment/baseline offsets ourselves (above), so the
|
|
809
|
+
// underlying engine must NOT apply its own. pureimage in particular
|
|
810
|
+
// mis-handles a non-default textAlign/textBaseline and drops the
|
|
811
|
+
// leading glyph(s) of the string (e.g. "Erzeugung" -> "eugung"), so we
|
|
812
|
+
// force the engine back to left/alphabetic before drawing and restore
|
|
813
|
+
// the caller-facing values afterwards.
|
|
814
|
+
const prevAlign = ctx.textAlign;
|
|
815
|
+
const prevBaseline = ctx.textBaseline;
|
|
816
|
+
ctx.textAlign = 'left';
|
|
817
|
+
ctx.textBaseline = 'alphabetic';
|
|
818
|
+
// pureimage can also leak the current path into fillText; resetting it
|
|
819
|
+
// immediately before drawing text avoids that.
|
|
820
|
+
ctx.beginPath();
|
|
821
|
+
ctx.fillText(value, drawX, drawY);
|
|
822
|
+
ctx.textAlign = prevAlign;
|
|
823
|
+
ctx.textBaseline = prevBaseline;
|
|
824
|
+
}
|
|
825
|
+
textWidth(ctx, value) {
|
|
826
|
+
try {
|
|
827
|
+
const m = ctx.measureText(value);
|
|
828
|
+
if (m && typeof m.width === 'number' && m.width > 0) {
|
|
829
|
+
return m.width;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
/* fall through to estimate */
|
|
834
|
+
}
|
|
835
|
+
return value.length * this.currentFontSize(ctx) * 0.55;
|
|
836
|
+
}
|
|
837
|
+
currentFontSize(ctx) {
|
|
838
|
+
const match = /(\d+(?:\.\d+)?)px/.exec(ctx.font || '');
|
|
839
|
+
return match ? parseFloat(match[1]) : this.u() * 1.6;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Set the drawing font.
|
|
843
|
+
*
|
|
844
|
+
* pureimage 0.4.x only supports the `<size>px <family>` form, so we never
|
|
845
|
+
* emit a weight prefix and instead convey emphasis through a slightly
|
|
846
|
+
* larger size.
|
|
847
|
+
*/
|
|
848
|
+
setFont(ctx, sizePx, emphasis = false) {
|
|
849
|
+
const size = Math.max(8, Math.round(emphasis ? sizePx * 1.06 : sizePx));
|
|
850
|
+
ctx.font = `${size}px ${this.fontName}`;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Draw text, automatically shrinking the font so it fits within
|
|
854
|
+
* `maxWidth`. Preserves the current textAlign/textBaseline.
|
|
855
|
+
*/
|
|
856
|
+
fitText(ctx, value, x, y, maxWidth, baseSize) {
|
|
857
|
+
const target = maxWidth * 0.86;
|
|
858
|
+
let size = Math.max(8, Math.round(baseSize));
|
|
859
|
+
while (size > 7) {
|
|
860
|
+
ctx.font = `${size}px ${this.fontName}`;
|
|
861
|
+
if (this.textWidth(ctx, value) <= target) {
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
size -= 1;
|
|
865
|
+
}
|
|
866
|
+
this.text(ctx, value, x, y);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
exports.SenecImageRenderer = SenecImageRenderer;
|
|
870
|
+
/** Cached across instances: registering a font is a process-wide action. */
|
|
871
|
+
SenecImageRenderer.fontLoaded = false;
|
|
872
|
+
//# sourceMappingURL=senec-image-renderer.js.map
|