node-red-contrib-senec-cloud-v2 0.2.0 → 0.2.1

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/assets/fonts/senec-sans.ttf +0 -0
  3. package/dist/lib/dashboard-html-renderer.d.ts +18 -0
  4. package/dist/lib/dashboard-html-renderer.d.ts.map +1 -0
  5. package/dist/lib/dashboard-html-renderer.js +604 -0
  6. package/dist/lib/dashboard-html-renderer.js.map +1 -0
  7. package/dist/lib/dashboard-layout.d.ts +152 -0
  8. package/dist/lib/dashboard-layout.d.ts.map +1 -0
  9. package/dist/lib/dashboard-layout.js +201 -0
  10. package/dist/lib/dashboard-layout.js.map +1 -0
  11. package/dist/lib/geocoding-client.d.ts +61 -0
  12. package/dist/lib/geocoding-client.d.ts.map +1 -0
  13. package/dist/lib/geocoding-client.js +77 -0
  14. package/dist/lib/geocoding-client.js.map +1 -0
  15. package/dist/lib/senec-image-renderer.d.ts +107 -0
  16. package/dist/lib/senec-image-renderer.d.ts.map +1 -0
  17. package/dist/lib/senec-image-renderer.js +872 -0
  18. package/dist/lib/senec-image-renderer.js.map +1 -0
  19. package/dist/lib/senec-layout.d.ts +212 -0
  20. package/dist/lib/senec-layout.d.ts.map +1 -0
  21. package/dist/lib/senec-layout.js +252 -0
  22. package/dist/lib/senec-layout.js.map +1 -0
  23. package/dist/lib/weather-client.d.ts +42 -0
  24. package/dist/lib/weather-client.d.ts.map +1 -0
  25. package/dist/lib/weather-client.js +193 -0
  26. package/dist/lib/weather-client.js.map +1 -0
  27. package/dist/nodes/senec-data.js +10 -2
  28. package/dist/nodes/senec-data.js.map +1 -1
  29. package/dist/nodes/senec-image.html +73 -53
  30. package/dist/nodes/senec-image.js +189 -14
  31. package/dist/nodes/senec-image.js.map +1 -1
  32. package/dist/nodes/weather.d.ts +2 -0
  33. package/dist/nodes/weather.d.ts.map +1 -0
  34. package/dist/nodes/weather.html +179 -0
  35. package/dist/nodes/weather.js +138 -0
  36. package/dist/nodes/weather.js.map +1 -0
  37. 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