@rhizomatics/signalk-einklabel-plugin 0.8.0 → 0.8.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 +10 -1
- package/README.md +32 -28
- package/dist/cli/liveContext.js +24 -8
- package/dist/config.js +1 -1
- package/dist/devices/zhsunyco/index.js +1 -1
- package/dist/render/assets.d.ts +6 -0
- package/dist/render/assets.js +14 -0
- package/dist/render/binding.d.ts +17 -0
- package/dist/render/binding.js +19 -3
- package/dist/render/svgRenderer.js +1 -1
- package/dist/repaintScheduler.js +44 -17
- package/package.json +1 -1
- package/templates/tide.svg +20 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
# 0.8.2
|
|
2
|
+
|
|
3
|
+
- Work around a SignalK bug in Server API where `ResourcesApi.listResources()` merges provider values in undetermined order and ignores specified provider.
|
|
4
|
+
|
|
5
|
+
# 0.8.1
|
|
6
|
+
|
|
7
|
+
- A preferred `provider` can now be set for `resources`.
|
|
8
|
+
- The example `tide.svg` sets this to `provider=tides` to ignore other providers, (such as `mareas-ihm`)
|
|
9
|
+
- Devices will repaint at startup even if no schedule slot has been missed prior to startup, if the template itself has subsequently changed
|
|
10
|
+
|
|
1
11
|
# 0.8.0
|
|
2
12
|
|
|
3
13
|
- New `settle` time, configurable and defaulting to 120 seconds, to wait after plugin startup before attempting to access paths
|
|
@@ -6,7 +16,6 @@
|
|
|
6
16
|
- Path based subscriptions ignore temporarily missing paths, so displays don't waste their tiny batteries on flapping values
|
|
7
17
|
- Documentation now has a FAQ plus lots of other ESL/eInk hacking links.
|
|
8
18
|
|
|
9
|
-
|
|
10
19
|
# 0.7.1
|
|
11
20
|
|
|
12
21
|
- Fix path value retrieved for Image Fields when running in live plugin
|
package/README.md
CHANGED
|
@@ -13,11 +13,11 @@ A SignalK plugin to display data from SignalK paths, APIs and plugins on Electro
|
|
|
13
13
|
|
|
14
14
|
## What is an ESL?
|
|
15
15
|
|
|
16
|
-
Electronic Shelf Labels are [eInk](https://en.wikipedia.org/wiki/E_Ink) devices that consume very little battery energy, presuming they are not constantly updated - the battery is used only when the display changes
|
|
16
|
+
Electronic Shelf Labels are [eInk](https://en.wikipedia.org/wiki/E_Ink) devices that consume very little battery energy, presuming they are not constantly updated - the battery is used only when the display changes (which can take 5-10 seconds) and a periodic BLE check for incoming changes. Perfect for info that changes only once or twice a day, like tidal information.
|
|
17
17
|
|
|
18
18
|
Since they are designed to be used in large quantity in small shops, they are cheap and simple devices. Earlier models required dedicated controllers, or updates over Wifi or NFC, whereas many modern ones are standalone BLE devices that can be updated from a phone or server.
|
|
19
19
|
|
|
20
|
-
Being battery operated, they can be stuck on anywhere without wiring - the only constraints are bluetooth range, visibility (they need ambient light since the display is more like paper than a traditional lit-up electronic display) and out of the weather since the devices are intended for indoor use.
|
|
20
|
+
Being battery operated, they can be stuck on anywhere without wiring - the only location constraints are bluetooth range, visibility (they need ambient light since the display is more like paper than a traditional lit-up electronic display) and out of the weather since the devices are intended for indoor use.
|
|
21
21
|
|
|
22
22
|
## Pre-requisites
|
|
23
23
|
|
|
@@ -72,7 +72,7 @@ npm install @rhizomatics/signalk-einklabel-plugin
|
|
|
72
72
|
|
|
73
73
|

|
|
74
74
|
|
|
75
|
-
The tide clock needs the [signalk-tides](https://github.com/openwatersio/signalk-tides) plugin to be installed and publishing tides to the Resources API. The [tide.svg](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/templates/tide.svg) can be customized to run with other APIs or take data only from SignalK data paths.
|
|
75
|
+
The tide clock needs the [signalk-tides](https://github.com/openwatersio/signalk-tides) plugin to be installed and publishing tides to the Resources API. The [tide.svg](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/templates/tide.svg) can be customized to run with other APIs or take data only from SignalK data paths. For example, `source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time` gets the first tide time, ensures its the preferred `signalk-tides` provider and makes it a simple local time rather than a UTC date time.
|
|
76
76
|
|
|
77
77
|
To show the lunar phase, the `environment.moon.phaseName` path is required, which can
|
|
78
78
|
be easily achieved by installing and configuring the `derived-data` plugin.
|
|
@@ -117,7 +117,9 @@ For example, `path=environment.forecast.description` uses the default data sourc
|
|
|
117
117
|
|
|
118
118
|
#### SignalK REST APIs
|
|
119
119
|
|
|
120
|
-
The source can be overridden to use the SignalK server's Resources API instead.
|
|
120
|
+
The source can be overridden to use the SignalK server's Resources API instead. Change `source` to `resources` and specify which resource with `resource`. If there are multiple providers for the same resource, and they're not equally useful, then either set a default provider in SignalK, or use the `provider` tag to set the name.
|
|
121
|
+
|
|
122
|
+
For example, `source=resources,resource=tides,provider=tides,path=station.name` picks the `tides` resource and pulls the `station.name` path out of the JSON response - this works for any resource type (`tides`, `waypoints`, `routes`, ...), and needs nothing configured: the plugin reaches the Resources API directly. Where a resource is specified, it will be fetched once for that render, and subsequent fields sourced from the same resource use that cached response. `provider` is optional, the default provider will be used if not specified.
|
|
121
123
|
|
|
122
124
|
#### Plugin Data
|
|
123
125
|
|
|
@@ -150,7 +152,7 @@ Common categories:
|
|
|
150
152
|
|
|
151
153
|
Additionally, `round=n` can be used to round to limited decimal places.
|
|
152
154
|
|
|
153
|
-
These can all be combined as in `source=resources,resource=tides,path=extremes[2].level,category=depth,round=2`
|
|
155
|
+
These can all be combined as in `source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth,round=2`
|
|
154
156
|
|
|
155
157
|
### Non-Textual Fields (Images)
|
|
156
158
|
|
|
@@ -174,7 +176,6 @@ Three font types are loaded by default, use the generic font family, or exact fo
|
|
|
174
176
|
- `sans-serif` - `Roboto`
|
|
175
177
|
- `monospace` - `Roboto Mono`
|
|
176
178
|
|
|
177
|
-
|
|
178
179
|
## Vendors
|
|
179
180
|
|
|
180
181
|
### Zhsunyco
|
|
@@ -253,7 +254,6 @@ Inkscape adds its own metadata to images, which can be stripped off by exporting
|
|
|
253
254
|
|
|
254
255
|
Due to a limitation in the `resvg-wasm` library used to turn SVGs into images, the `font-family` is limited to `serif`,`sans-serif`,`monospace` or the exact name of one of the installed fonts - `Roboto` (sans serif), `Roboto Serif` or `Roboto Mono`. Inkscape has its own fonts, which won't match what's available in the SignalK plugin, so for more precise design, install [Roboto from Google](https://fonts.google.com/specimen/Roboto) via the web page, `brew` on MacOS or similar.
|
|
255
256
|
|
|
256
|
-
|
|
257
257
|
### Debugging Templates
|
|
258
258
|
|
|
259
259
|
The `esl-cli` can be used to debug and validate templates quickly:
|
|
@@ -272,25 +272,25 @@ npx esl-cli fields -t templates/tide.svg -u http://localhost
|
|
|
272
272
|
```
|
|
273
273
|
|
|
274
274
|
```
|
|
275
|
-
id
|
|
276
|
-
station.name
|
|
277
|
-
source.name.
|
|
278
|
-
last_repaint
|
|
279
|
-
extremes.0
|
|
280
|
-
extremes.1
|
|
281
|
-
extremes.2
|
|
282
|
-
timezoneRegion
|
|
283
|
-
lat
|
|
284
|
-
hat
|
|
285
|
-
extremes.2.level
|
|
286
|
-
extremes.2.time
|
|
287
|
-
extremes.1.level
|
|
288
|
-
extremes.1.time
|
|
289
|
-
extremes.0.time
|
|
290
|
-
extremes.0.time-8
|
|
291
|
-
extremes.0.time-8-5
|
|
292
|
-
extremes.0.time-8-9
|
|
293
|
-
extremes.0.level
|
|
275
|
+
id spec value
|
|
276
|
+
station.name source=resources,resource=tides,provider=tides,path=station.name Tobermory
|
|
277
|
+
source.name. source=resources,resource=tides,provider=tides,path=station.source.name TICON-4
|
|
278
|
+
last_repaint source=einklabel,path=repainted,format=local_datetime_short 30 Jun 26 00:08
|
|
279
|
+
extremes.0 source=resources,resource=tides,provider=tides,path=extremes[0].label Low
|
|
280
|
+
extremes.1 source=resources,resource=tides,provider=tides,path=extremes[1].label High
|
|
281
|
+
extremes.2 source=resources,resource=tides,provider=tides,path=extremes[2].label Low
|
|
282
|
+
timezoneRegion source=einklabel,path=local_zone BST
|
|
283
|
+
lat source=resources,resource=tides,provider=tides,path=station.datums.LAT,category=depth 0.2m
|
|
284
|
+
hat source=resources,resource=tides,provider=tides,path=station.datums.HAT,category=depth 5.2m
|
|
285
|
+
extremes.2.level source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth 1.1m
|
|
286
|
+
extremes.2.time source=resources,resource=tides,provider=tides,path=extremes[2].time,format=local_time 13:15
|
|
287
|
+
extremes.1.level source=resources,resource=tides,provider=tides,path=extremes[1].level,category=depth 3.8m
|
|
288
|
+
extremes.1.time source=resources,resource=tides,provider=tides,path=extremes[1].time,format=local_time 07:05
|
|
289
|
+
extremes.0.time source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time 01:21
|
|
290
|
+
extremes.0.time-8 source=resources,resource=tides,provider=tides,path=extremes[0].time,format=day_mon 30 Jun
|
|
291
|
+
extremes.0.time-8-5 source=resources,resource=tides,provider=tides,path=extremes[1].time,format=day_mon 30 Jun
|
|
292
|
+
extremes.0.time-8-9 source=resources,resource=tides,provider=tides,path=extremes[2].time,format=day_mon 30 Jun
|
|
293
|
+
extremes.0.level source=resources,resource=tides,provider=tides,path=extremes[0].level,category=depth 1.3m
|
|
294
294
|
```
|
|
295
295
|
|
|
296
296
|
### Offline Working
|
|
@@ -309,7 +309,7 @@ For example, `npx esl-cli fields -t templates/tide.svg -e examples` will show al
|
|
|
309
309
|
|
|
310
310
|
If the plugin repaints a display at server startup, then the plugin that provides the data may not have started ( or in the case of `derived-data` the plugin that the plugin depends on! ) and unlike Home Assistant, there's no good way of sequencing the start of plugins.
|
|
311
311
|
|
|
312
|
-
Use the
|
|
312
|
+
Use the _settle_ time, to impose a minimum wait between the eInk Label plugin being initialized, and it attempting to paint any displays, and increase this value if its still missing data.
|
|
313
313
|
|
|
314
314
|
### Times are showing incorrectly {#faq-timezone}
|
|
315
315
|
|
|
@@ -322,6 +322,10 @@ If you're a global cruiser, then use something like [signalk-set-gps-timezone](h
|
|
|
322
322
|
|
|
323
323
|
Try a BLE proxy device, ESP32 is popular for this.
|
|
324
324
|
|
|
325
|
+
### Can't edit the text contents of SVG template in VSCode
|
|
326
|
+
|
|
327
|
+
If you have an SVG viewer extension, this wll show the image rather than allowing editing of text. To solve, right click on the file in VSCode _Explorer_ view and choose to edit with _Text Editor_.
|
|
328
|
+
|
|
325
329
|
## Other ESL and General eInk Resources
|
|
326
330
|
|
|
327
331
|
- [Open ePaper Link](https://openepaperlink.de) - Alternative open source firmware to flash onto eInk shelf labels, with Home Assistant integration.
|
|
@@ -329,7 +333,7 @@ Try a BLE proxy device, ESP32 is popular for this.
|
|
|
329
333
|
- [eInk Dashboard Modern SK](https://github.com/VladimirKalachikhin/e-inkDashboardModernSK) - SignalK dashboard for non-ESL eInk display.
|
|
330
334
|
- [esp32-esl-system](https://github.com/giobauermeister/esp32-esl-system) - Docker and ESP32 based system for updating ESLs.
|
|
331
335
|
- [hass-gicisky](https://github.com/eigger/hass-gicisky) - Home Assistant integration for Gicisky ESLs ( a similar vendor to Zhsunyco). Uses [imagespec](https://github.com/eigger/imagespec) for templating.
|
|
332
|
-
- [ha-panda](https://github.com/moryoav/ha-panda) -
|
|
336
|
+
- [ha-panda](https://github.com/moryoav/ha-panda) - Home Assistant integration for Panda ESLs ( a similar vendor to Zhsunyco).
|
|
333
337
|
- [Dmitry.gr](https://dmitry.gr/?r=05.Projects&proj=29.%20eInk%20Price%20Tags) - Personal site of an ESL hacker
|
|
334
338
|
- [Aaron Christobel](https://www.youtube.com/@atc1441) - YouTube channel of an ESL hacker.
|
|
335
339
|
- [rbaron.net](https://rbaron.net/blog/2022/07/29/Daisy-chaining-multiple-electronic-shelf-labels) - Blog of an early ESL hacker.
|
package/dist/cli/liveContext.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.assembleLiveContext = assembleLiveContext;
|
|
|
7
7
|
exports.assembleExampleContext = assembleExampleContext;
|
|
8
8
|
const promises_1 = require("fs/promises");
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const binding_1 = require("../render/binding");
|
|
10
11
|
const formatters_1 = require("../render/formatters");
|
|
11
12
|
const unwrapSignalkTree_1 = require("../render/unwrapSignalkTree");
|
|
12
13
|
const httpJson_1 = require("../httpJson");
|
|
@@ -47,11 +48,19 @@ async function assembleLiveContext(signalkUrl, bindings) {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
const resources = {};
|
|
50
|
-
const
|
|
51
|
-
for (const
|
|
52
|
-
|
|
51
|
+
const resourceBindings = new Map();
|
|
52
|
+
for (const binding of bindings) {
|
|
53
|
+
if (binding.source !== "resources")
|
|
54
|
+
continue;
|
|
55
|
+
resourceBindings.set((0, binding_1.resourceContextKey)(binding), binding);
|
|
56
|
+
}
|
|
57
|
+
for (const [key, binding] of resourceBindings) {
|
|
58
|
+
// A `provider=` binding pins which registered provider plugin answers the request (matches
|
|
59
|
+
// `checkForProvider`'s `?provider=` query param in signalk-server's resources REST API) rather
|
|
60
|
+
// than leaving it to whichever provider the server considers the default for that resource type.
|
|
61
|
+
const url = `${signalkUrl}${RESOURCES_API_PATH}/${binding.resource}${binding.provider ? `?provider=${encodeURIComponent(binding.provider)}` : ""}`;
|
|
53
62
|
(0, log_1.logDebug)(`GET ${url}`);
|
|
54
|
-
resources[
|
|
63
|
+
resources[key] = await (0, httpJson_1.fetchJson)(url);
|
|
55
64
|
}
|
|
56
65
|
const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
|
|
57
66
|
const categories = await (0, unitCategories_1.fetchCategoryDisplayUnits)(signalkUrl, categoryNames);
|
|
@@ -112,11 +121,18 @@ async function assembleExampleContext(examplesDir, bindings) {
|
|
|
112
121
|
}
|
|
113
122
|
}
|
|
114
123
|
const resources = {};
|
|
115
|
-
const
|
|
116
|
-
for (const
|
|
117
|
-
|
|
124
|
+
const resourceBindings = new Map();
|
|
125
|
+
for (const binding of bindings) {
|
|
126
|
+
if (binding.source !== "resources")
|
|
127
|
+
continue;
|
|
128
|
+
resourceBindings.set((0, binding_1.resourceContextKey)(binding), binding);
|
|
129
|
+
}
|
|
130
|
+
for (const [key, binding] of resourceBindings) {
|
|
131
|
+
// Example data has no notion of multiple providers for the same resource type - the file is
|
|
132
|
+
// still named after the bare `resource=` type regardless of any `provider=` a binding pins.
|
|
133
|
+
const resourcePath = path_1.default.join(examplesDir, "resources", `${binding.resource}.json`);
|
|
118
134
|
(0, log_1.logDebug)(`reading ${resourcePath}`);
|
|
119
|
-
resources[
|
|
135
|
+
resources[key] = await readJsonFile(resourcePath);
|
|
120
136
|
}
|
|
121
137
|
const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
|
|
122
138
|
let categories = {};
|
package/dist/config.js
CHANGED
|
@@ -158,7 +158,7 @@ function configSchema(app, discovered = []) {
|
|
|
158
158
|
type: "number",
|
|
159
159
|
title: "Settle time after plugin start (seconds)",
|
|
160
160
|
description: "Holds off every repaint (startup check, interval, and subscription alike) until this long after the plugin starts - " +
|
|
161
|
-
"the first minute or two of SignalK startup
|
|
161
|
+
"the first minute or two of SignalK startup often has many errors as plugins start before the paths they depend on are ready, so data a template needs " +
|
|
162
162
|
"(e.g. from the derived-data plugin) may not be published yet.",
|
|
163
163
|
minimum: 0,
|
|
164
164
|
default: defaults.settleSeconds,
|
|
@@ -150,7 +150,7 @@ async function readDeviceDetails(device, address, advertisedInfo) {
|
|
|
150
150
|
// which otherwise stays set forever and silently skips every later scan).
|
|
151
151
|
return Promise.race([
|
|
152
152
|
read(),
|
|
153
|
-
(0, bleDiscovery_1.sleep)(SCAN_CONNECT_TIMEOUT_MS *
|
|
153
|
+
(0, bleDiscovery_1.sleep)(SCAN_CONNECT_TIMEOUT_MS * 3).then(() => {
|
|
154
154
|
console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read timed out after ${SCAN_CONNECT_TIMEOUT_MS * 2}ms`);
|
|
155
155
|
return fallback;
|
|
156
156
|
}),
|
package/dist/render/assets.d.ts
CHANGED
|
@@ -21,3 +21,9 @@ export declare function resolveAssetPath(templatesDir: string, bundledTemplatesD
|
|
|
21
21
|
* directory, is readable) - the miss is then just this value's.
|
|
22
22
|
*/
|
|
23
23
|
export declare function describeAssetsDirProblem(templatesDir: string, bundledTemplatesDir: string, assetsName: string): string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Counts the `.svg` files in whichever `assets/<assetsName>` directory `selectAssetsDir` would read
|
|
26
|
+
* from, for a caller to report alongside a resolved-but-empty-key miss - confirms which directory is in
|
|
27
|
+
* play and how populated it is, which "the directory exists" alone doesn't convey.
|
|
28
|
+
*/
|
|
29
|
+
export declare function countAssets(templatesDir: string, bundledTemplatesDir: string, assetsName: string): number;
|
package/dist/render/assets.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.normalizeAssetKey = normalizeAssetKey;
|
|
4
4
|
exports.resolveAssetPath = resolveAssetPath;
|
|
5
5
|
exports.describeAssetsDirProblem = describeAssetsDirProblem;
|
|
6
|
+
exports.countAssets = countAssets;
|
|
6
7
|
const fs_1 = require("fs");
|
|
7
8
|
const path_1 = require("path");
|
|
8
9
|
/**
|
|
@@ -86,3 +87,16 @@ function describeAssetsDirProblem(templatesDir, bundledTemplatesDir, assetsName)
|
|
|
86
87
|
const problem = describeDir(selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName));
|
|
87
88
|
return problem && `assets directory ${problem}`;
|
|
88
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Counts the `.svg` files in whichever `assets/<assetsName>` directory `selectAssetsDir` would read
|
|
92
|
+
* from, for a caller to report alongside a resolved-but-empty-key miss - confirms which directory is in
|
|
93
|
+
* play and how populated it is, which "the directory exists" alone doesn't convey.
|
|
94
|
+
*/
|
|
95
|
+
function countAssets(templatesDir, bundledTemplatesDir, assetsName) {
|
|
96
|
+
try {
|
|
97
|
+
return (0, fs_1.readdirSync)(selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName)).filter((name) => name.endsWith(".svg")).length;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/dist/render/binding.d.ts
CHANGED
|
@@ -10,6 +10,15 @@ export interface Binding {
|
|
|
10
10
|
context: string;
|
|
11
11
|
/** Required when `source === 'resources'` - the Resources API resource type, e.g. `tides`, `waypoints`. */
|
|
12
12
|
resource?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Only meaningful when `source === 'resources'` - the plugin id of the Resource Provider to query
|
|
15
|
+
* (SignalK's `listResources(resType, params, providerId)`), e.g. `signalk-tides`. Optional - when
|
|
16
|
+
* omitted, the server picks whichever provider is registered for `resource` (its "default"), which is
|
|
17
|
+
* ambiguous/unpredictable when more than one plugin provides the same resource type (e.g. installing
|
|
18
|
+
* a second tides plugin can silently make it the one used instead of the one the template was authored
|
|
19
|
+
* for). Set this to pin a template to one provider regardless of what else is installed.
|
|
20
|
+
*/
|
|
21
|
+
provider?: string;
|
|
13
22
|
/** For `source === 'einklabel'`, a dotted path into the plugin's own injected `meta` (e.g. `repainted`), rather than into vessel/resource data. */
|
|
14
23
|
path: string;
|
|
15
24
|
/** A named formatter (see `./formatters.ts`), or `'raw'` to suppress automatic unit conversion (see `renderBinding`). */
|
|
@@ -37,6 +46,14 @@ export interface Binding {
|
|
|
37
46
|
* unambiguously signals this shorthand.
|
|
38
47
|
*/
|
|
39
48
|
export declare function parseBinding(desc: string): Binding;
|
|
49
|
+
/**
|
|
50
|
+
* Key under which a `source=resources` binding's data is stored in `context.resources` (see
|
|
51
|
+
* `assembleRawContext` in repaintScheduler.ts and `cli/liveContext.ts`'s equivalent) - incorporates
|
|
52
|
+
* `provider` so two bindings naming the same `resource` type but different providers (e.g. pinning one
|
|
53
|
+
* template's `tides` binding to `provider=signalk-tides` while another is left to the server's default)
|
|
54
|
+
* don't collide on a single cache entry.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resourceContextKey(binding: Pick<Binding, "resource" | "provider">): string;
|
|
40
57
|
/**
|
|
41
58
|
* Parses every `<text>` and `<image>` element's `<desc>` binding out of raw SVG source - lets a caller
|
|
42
59
|
* discover what data a template needs before fetching anything, with no separate config declaring it
|
package/dist/render/binding.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.parseBinding = parseBinding;
|
|
4
|
+
exports.resourceContextKey = resourceContextKey;
|
|
4
5
|
exports.findBindings = findBindings;
|
|
5
6
|
exports.resolveBinding = resolveBinding;
|
|
6
7
|
exports.renderBinding = renderBinding;
|
|
7
8
|
const xmldom_1 = require("@xmldom/xmldom");
|
|
8
9
|
const formatters_1 = require("./formatters");
|
|
9
10
|
const SOURCES = ["signalk", "resources", "einklabel"];
|
|
10
|
-
const KNOWN_KEYS = new Set(["source", "context", "resource", "path", "format", "category", "round", "assets"]);
|
|
11
|
+
const KNOWN_KEYS = new Set(["source", "context", "resource", "provider", "path", "format", "category", "round", "assets"]);
|
|
11
12
|
/**
|
|
12
13
|
* Parses a `<desc>` element's text content into a `Binding`, e.g.
|
|
13
14
|
* `source=resources,resource=tides,path=extremes[0].level,category=depth,round=2` or, using the
|
|
@@ -45,6 +46,9 @@ function parseBinding(desc) {
|
|
|
45
46
|
if (source === "resources" && !fields.resource) {
|
|
46
47
|
throw new Error(`invalid binding "${desc}" - source=resources requires a "resource" key`);
|
|
47
48
|
}
|
|
49
|
+
if (fields.provider && source !== "resources") {
|
|
50
|
+
throw new Error(`invalid binding "${desc}" - "provider" is only valid with source=resources`);
|
|
51
|
+
}
|
|
48
52
|
if (!fields.path) {
|
|
49
53
|
throw new Error(`invalid binding "${desc}" - missing required "path" key`);
|
|
50
54
|
}
|
|
@@ -52,6 +56,7 @@ function parseBinding(desc) {
|
|
|
52
56
|
source,
|
|
53
57
|
context,
|
|
54
58
|
resource: fields.resource,
|
|
59
|
+
provider: fields.provider,
|
|
55
60
|
path: fields.path,
|
|
56
61
|
format: fields.format,
|
|
57
62
|
category: fields.category,
|
|
@@ -59,6 +64,16 @@ function parseBinding(desc) {
|
|
|
59
64
|
assets: fields.assets,
|
|
60
65
|
};
|
|
61
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Key under which a `source=resources` binding's data is stored in `context.resources` (see
|
|
69
|
+
* `assembleRawContext` in repaintScheduler.ts and `cli/liveContext.ts`'s equivalent) - incorporates
|
|
70
|
+
* `provider` so two bindings naming the same `resource` type but different providers (e.g. pinning one
|
|
71
|
+
* template's `tides` binding to `provider=signalk-tides` while another is left to the server's default)
|
|
72
|
+
* don't collide on a single cache entry.
|
|
73
|
+
*/
|
|
74
|
+
function resourceContextKey(binding) {
|
|
75
|
+
return binding.provider ? `${binding.resource}@${binding.provider}` : binding.resource;
|
|
76
|
+
}
|
|
62
77
|
/**
|
|
63
78
|
* Parses every `<text>` and `<image>` element's `<desc>` binding out of raw SVG source - lets a caller
|
|
64
79
|
* discover what data a template needs before fetching anything, with no separate config declaring it
|
|
@@ -112,9 +127,10 @@ function resolveBinding(binding, context) {
|
|
|
112
127
|
return getAtPath(meta, binding.path);
|
|
113
128
|
}
|
|
114
129
|
const resources = context.resources;
|
|
115
|
-
const
|
|
130
|
+
const resourceKey = resourceContextKey(binding);
|
|
131
|
+
const resource = resources?.[resourceKey];
|
|
116
132
|
if (resource === undefined) {
|
|
117
|
-
throw new Error(`binding references resource "${
|
|
133
|
+
throw new Error(`binding references resource "${resourceKey}" which is not present in the render context`);
|
|
118
134
|
}
|
|
119
135
|
return getAtPath(resource, binding.path);
|
|
120
136
|
}
|
|
@@ -136,7 +136,7 @@ class SvgRenderer {
|
|
|
136
136
|
console.error(key
|
|
137
137
|
? `${pluginVersion_1.PLUGIN_NAME}: image "${descElement.textContent}" has no asset file for value "${key}" in "${binding.assets}"`
|
|
138
138
|
: `${pluginVersion_1.PLUGIN_NAME}: image "${descElement.textContent}" resolved to empty value to select asset`);
|
|
139
|
-
console.error(`${pluginVersion_1.PLUGIN_NAME}: ${dirProblem ?? `
|
|
139
|
+
console.error(`${pluginVersion_1.PLUGIN_NAME}: ${dirProblem ?? `directory "${binding.assets}" found with ${(0, assets_1.countAssets)(templatesDir, bundledTemplatesDir, binding.assets)} assets`}`);
|
|
140
140
|
element.parentNode?.removeChild(element);
|
|
141
141
|
continue;
|
|
142
142
|
}
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -16,8 +16,10 @@ const unitCategories_1 = require("./unitCategories");
|
|
|
16
16
|
const pathMeta_1 = require("./pathMeta");
|
|
17
17
|
const resolveApiUrl_1 = require("./resolveApiUrl");
|
|
18
18
|
const pluginVersion_1 = require("./pluginVersion");
|
|
19
|
+
const httpJson_1 = require("./httpJson");
|
|
19
20
|
const INTERVAL_POLL_MS = 60000;
|
|
20
21
|
const SUBSCRIPTION_DEBOUNCE_MS = 2000;
|
|
22
|
+
const RESOURCES_API_PATH = "/signalk/v2/api/resources";
|
|
21
23
|
/**
|
|
22
24
|
* The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
|
|
23
25
|
* periodic check (below, in `startRepaintScheduler`) would fire at - every `intervalHours` hours,
|
|
@@ -60,8 +62,9 @@ function stableStringify(value) {
|
|
|
60
62
|
}
|
|
61
63
|
return JSON.stringify(value);
|
|
62
64
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
/** Hashes both the live data context and the template's mtime, so editing a template (even with no data change) triggers a repaint. */
|
|
66
|
+
function hashContext(context, templateMtimeMs) {
|
|
67
|
+
return (0, crypto_1.createHash)("sha1").update(stableStringify(context)).update(String(templateMtimeMs)).digest("hex");
|
|
65
68
|
}
|
|
66
69
|
/** Merges `value` into `target` at the nested location described by a dotted SignalK path. */
|
|
67
70
|
function setAtPath(target, path, value) {
|
|
@@ -84,13 +87,16 @@ function setAtPath(target, path, value) {
|
|
|
84
87
|
* CLI's HTTP-fetched equivalent (`assembleLiveContext` in `cli/liveContext.ts`), so a `path=` binding
|
|
85
88
|
* resolves to the same value either way - without it, `resolveBinding` would hand back the whole
|
|
86
89
|
* wrapper object instead of e.g. a plain string, breaking any binding that expects one (a `<image>`'s
|
|
87
|
-
* `assets=`, a `format=` formatter, etc). `resources`-sourced bindings
|
|
88
|
-
* in-process too, with no such wrapper
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
90
|
+
* `assets=`, a `format=` formatter, etc). `resources`-sourced bindings with no `provider=` pin go
|
|
91
|
+
* through `app.resourcesApi` in-process too, with no such wrapper - but a `provider=`-pinned binding
|
|
92
|
+
* goes over HTTP instead (see the resources loop below), since the in-process API doesn't actually
|
|
93
|
+
* honor `providerId` on signalk-server 2.30.0. Per-path unit-conversion metadata (`pathMeta`, for
|
|
94
|
+
* automatic conversion - see `renderBinding`) and an explicit `category=` binding's resolved
|
|
95
|
+
* conversion both have no in-process equivalent (confirmed against the signalk-server source - that
|
|
96
|
+
* resolution only happens in its REST layer), so both need `apiUrl` - fetching `pathMeta` is
|
|
97
|
+
* best-effort (a missing/unreachable server just means no automatic conversion), but a `category=`
|
|
98
|
+
* binding (and now a `provider=`-pinned resource binding) is a declared dependency, so a missing
|
|
99
|
+
* `apiUrl` is a hard error there.
|
|
94
100
|
*/
|
|
95
101
|
async function assembleRawContext(app, apiUrl, bindings) {
|
|
96
102
|
var _a;
|
|
@@ -122,13 +128,33 @@ async function assembleRawContext(app, apiUrl, bindings) {
|
|
|
122
128
|
}
|
|
123
129
|
}
|
|
124
130
|
const resources = {};
|
|
125
|
-
const
|
|
126
|
-
for (const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
const resourceBindings = new Map();
|
|
132
|
+
for (const binding of bindings) {
|
|
133
|
+
if (binding.source !== "resources")
|
|
134
|
+
continue;
|
|
135
|
+
resourceBindings.set((0, binding_1.resourceContextKey)(binding), binding);
|
|
136
|
+
}
|
|
137
|
+
for (const [key, binding] of resourceBindings) {
|
|
138
|
+
if (binding.provider) {
|
|
139
|
+
// `app.resourcesApi.listResources(resType, params, providerId)` validates providerId against the
|
|
140
|
+
// registered providers but then - confirmed against signalk-server 2.30.0's own source - ignores
|
|
141
|
+
// it and merges every registered provider's results together (its internal `listFromAll`), so a
|
|
142
|
+
// second provider for the same resource type can silently clobber the one a binding pinned. The
|
|
143
|
+
// REST API's own `?provider=` query param doesn't have this bug (it queries only that provider),
|
|
144
|
+
// so a pinned binding goes over HTTP instead of the in-process API to actually get what it asked for.
|
|
145
|
+
if (!apiUrl) {
|
|
146
|
+
throw new Error(`binding pins resource "${binding.resource}" to provider "${binding.provider}" but no SignalK API base URL is configured`);
|
|
147
|
+
}
|
|
148
|
+
resources[key] = await (0, httpJson_1.fetchJson)(`${apiUrl}${RESOURCES_API_PATH}/${binding.resource}?provider=${encodeURIComponent(binding.provider)}`);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// `listResources`'s type only allows the standard SignalKResourceType union, but the underlying
|
|
152
|
+
// Resources API (and a custom provider like signalk-tides, registered under the non-standard
|
|
153
|
+
// "tides" type) accepts any registered resource type string - this cast matches `getResource`'s
|
|
154
|
+
// wider, accurate signature. No `provider=` pin here, so the in-process call's default-provider
|
|
155
|
+
// behavior (whichever the server picks) is fine - and cheaper than a redundant HTTP round trip.
|
|
156
|
+
resources[key] = await app.resourcesApi.listResources(binding.resource, {}, undefined);
|
|
157
|
+
}
|
|
132
158
|
}
|
|
133
159
|
const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
|
|
134
160
|
if (categoryNames.size > 0 && !apiUrl) {
|
|
@@ -155,13 +181,14 @@ async function considerRepaint(app, config, device, state, getApiUrl) {
|
|
|
155
181
|
}
|
|
156
182
|
const templatesDir = (0, config_1.resolveTemplatesDir)(config.templatesDir);
|
|
157
183
|
const templatePath = (0, config_1.resolveTemplatePath)(templatesDir, device.templateName);
|
|
184
|
+
const templateMtimeMs = (0, fs_1.statSync)(templatePath).mtimeMs;
|
|
158
185
|
const bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
|
|
159
186
|
const apiUrl = await getApiUrl().catch((err) => {
|
|
160
187
|
app.debug(`"${device.friendlyName}": ${err.message}`);
|
|
161
188
|
return undefined;
|
|
162
189
|
});
|
|
163
190
|
const rawContext = await assembleRawContext(app, apiUrl, bindings);
|
|
164
|
-
const hash = hashContext(rawContext);
|
|
191
|
+
const hash = hashContext(rawContext, templateMtimeMs);
|
|
165
192
|
if (state[device.friendlyName]?.hash === hash && !device.forceRepaint) {
|
|
166
193
|
app.debug(`"${device.friendlyName}": data unchanged, skipping repaint`);
|
|
167
194
|
return;
|
package/package.json
CHANGED
package/templates/tide.svg
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
13
13
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
14
14
|
xmlns="http://www.w3.org/2000/svg"
|
|
15
|
-
xmlns:svg="http://www.w3.org/2000/svg"
|
|
15
|
+
xmlns:svg="http://www.w3.org/2000/svg"
|
|
16
|
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
17
|
+
xmlns:cc="http://creativecommons.org/ns#"
|
|
18
|
+
xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
|
|
16
19
|
id="defs1" /><sodipodi:namedview
|
|
17
20
|
id="namedview1"
|
|
18
21
|
pagecolor="#ffffff"
|
|
@@ -69,7 +72,7 @@
|
|
|
69
72
|
fill="yellow"
|
|
70
73
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:serif;-inkscape-font-specification:'Roboto Serif 20pt';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffff00;stroke:#000000;stroke-width:1.2278;stroke-opacity:1"
|
|
71
74
|
transform="scale(0.89274878,1.1201359)">Tidal Station Name<desc
|
|
72
|
-
id="desc1">source=resources,resource=tides,path=station.name</desc></text><text
|
|
75
|
+
id="desc1">source=resources,resource=tides,provider=tides,path=station.name</desc></text><text
|
|
73
76
|
id="source"
|
|
74
77
|
x="367.44577"
|
|
75
78
|
y="226.1548"
|
|
@@ -78,7 +81,7 @@
|
|
|
78
81
|
fill="black"
|
|
79
82
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:13.3333px;font-family:sans-serif;-inkscape-font-specification:'\'Roboto\', sans-serif';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000;stroke-width:3.80045"
|
|
80
83
|
transform="scale(0.95907862,1.0426674)"><desc
|
|
81
|
-
id="desc21">source=resources,resource=tides,path=station.source.name</desc><tspan
|
|
84
|
+
id="desc21">source=resources,resource=tides,provider=tides,path=station.source.name</desc><tspan
|
|
82
85
|
sodipodi:role="line"
|
|
83
86
|
id="tspan20"
|
|
84
87
|
x="367.44577"
|
|
@@ -102,7 +105,7 @@
|
|
|
102
105
|
font-family="monospace"
|
|
103
106
|
fill="black"
|
|
104
107
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000"><desc
|
|
105
|
-
id="desc13">source=resources,resource=tides,path=extremes[0].label</desc>High or Low</text><text
|
|
108
|
+
id="desc13">source=resources,resource=tides,provider=tides,path=extremes[0].label</desc>High or Low</text><text
|
|
106
109
|
id="extremes.1"
|
|
107
110
|
x="7.6000967"
|
|
108
111
|
y="117.85719"
|
|
@@ -110,7 +113,7 @@
|
|
|
110
113
|
font-family="monospace"
|
|
111
114
|
fill="black"
|
|
112
115
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000"><desc
|
|
113
|
-
id="desc14">source=resources,resource=tides,path=extremes[1].label</desc>High or Low</text><text
|
|
116
|
+
id="desc14">source=resources,resource=tides,provider=tides,path=extremes[1].label</desc>High or Low</text><text
|
|
114
117
|
id="extremes.2"
|
|
115
118
|
x="7.2470098"
|
|
116
119
|
y="176.12112"
|
|
@@ -118,7 +121,7 @@
|
|
|
118
121
|
font-family="monospace"
|
|
119
122
|
fill="red"
|
|
120
123
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000"><desc
|
|
121
|
-
id="desc15">source=resources,resource=tides,path=extremes[2].label</desc>High or Low</text><text
|
|
124
|
+
id="desc15">source=resources,resource=tides,provider=tides,path=extremes[2].label</desc>High or Low</text><text
|
|
122
125
|
id="timezone"
|
|
123
126
|
x="330.60489"
|
|
124
127
|
y="204.67754"
|
|
@@ -148,7 +151,7 @@
|
|
|
148
151
|
font-family="sans-serif"
|
|
149
152
|
fill="#ffff00"
|
|
150
153
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:21.3333px;font-family:sans-serif;-inkscape-font-specification:'\'Roboto, Normal\', sans-serif';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#1a1a1a;stroke:#fefe54;stroke-opacity:1"><desc
|
|
151
|
-
id="desc18">source=resources,resource=tides,path=station.datums.LAT,category=depth,round=1</desc><tspan
|
|
154
|
+
id="desc18">source=resources,resource=tides,provider=tides,path=station.datums.LAT,category=depth,round=1</desc><tspan
|
|
152
155
|
sodipodi:role="line"
|
|
153
156
|
id="tspan17"
|
|
154
157
|
x="282.65424"
|
|
@@ -160,7 +163,7 @@
|
|
|
160
163
|
font-family="sans-serif"
|
|
161
164
|
fill="#ffff00"
|
|
162
165
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:21.3333px;font-family:sans-serif;-inkscape-font-specification:'\'Roboto, Normal\', sans-serif';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#1a1a1a;stroke:#fefe54;stroke-opacity:1"><desc
|
|
163
|
-
id="desc19">source=resources,resource=tides,path=station.datums.HAT,category=depth,round=1</desc><tspan
|
|
166
|
+
id="desc19">source=resources,resource=tides,provider=tides,path=station.datums.HAT,category=depth,round=1</desc><tspan
|
|
164
167
|
sodipodi:role="line"
|
|
165
168
|
id="tspan18"
|
|
166
169
|
x="360.77148"
|
|
@@ -180,7 +183,7 @@
|
|
|
180
183
|
x="322.52386"
|
|
181
184
|
y="158.08005"
|
|
182
185
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.78893">3.3m</tspan><desc
|
|
183
|
-
id="desc6">source=resources,resource=tides,path=extremes[2].level,category=depth</desc></text><text
|
|
186
|
+
id="desc6">source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth</desc></text><text
|
|
184
187
|
id="extremes.2.time"
|
|
185
188
|
x="203.77321"
|
|
186
189
|
y="146.1584"
|
|
@@ -189,7 +192,7 @@
|
|
|
189
192
|
fill="red"
|
|
190
193
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.51352"
|
|
191
194
|
transform="scale(0.70226239,1.4239692)">15:53<desc
|
|
192
|
-
id="desc10">source=resources,resource=tides,path=extremes[2].time,format=local_time</desc></text><text
|
|
195
|
+
id="desc10">source=resources,resource=tides,provider=tides,path=extremes[2].time,format=local_time</desc></text><text
|
|
193
196
|
id="extremes.1.level"
|
|
194
197
|
x="334.30084"
|
|
195
198
|
y="109.17664"
|
|
@@ -205,7 +208,7 @@
|
|
|
205
208
|
x="334.30084"
|
|
206
209
|
y="109.17664"
|
|
207
210
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.701">1.6m</tspan><desc
|
|
208
|
-
id="desc5">source=resources,resource=tides,path=extremes[1].level,category=depth</desc></text><text
|
|
211
|
+
id="desc5">source=resources,resource=tides,provider=tides,path=extremes[1].level,category=depth</desc></text><text
|
|
209
212
|
id="extremes.1.time"
|
|
210
213
|
x="188.63072"
|
|
211
214
|
y="108.65867"
|
|
@@ -214,7 +217,7 @@
|
|
|
214
217
|
fill="red"
|
|
215
218
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.48364"
|
|
216
219
|
transform="scale(0.7345432,1.3613903)">09:36<desc
|
|
217
|
-
id="desc8">source=resources,resource=tides,path=extremes[1].time,format=local_time</desc></text><text
|
|
220
|
+
id="desc8">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=local_time</desc></text><text
|
|
218
221
|
id="extremes.0.time"
|
|
219
222
|
x="184.40854"
|
|
220
223
|
y="64.242691"
|
|
@@ -223,7 +226,7 @@
|
|
|
223
226
|
fill="red"
|
|
224
227
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'Roboto Mono, Bold',monospace;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.55397"
|
|
225
228
|
transform="scale(0.74654856,1.3394976)">03:27<desc
|
|
226
|
-
id="desc3">source=resources,resource=tides,path=extremes[0].time,format=local_time</desc></text><text
|
|
229
|
+
id="desc3">source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time</desc></text><text
|
|
227
230
|
id="extremes.0.date"
|
|
228
231
|
x="10.326324"
|
|
229
232
|
y="74.630478"
|
|
@@ -232,7 +235,7 @@
|
|
|
232
235
|
fill="red"
|
|
233
236
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.32646"
|
|
234
237
|
transform="scale(0.82428216,1.2131768)"><desc
|
|
235
|
-
id="desc2">source=resources,resource=tides,path=extremes[0].time,format=day_mon</desc>27 Jun</text><text
|
|
238
|
+
id="desc2">source=resources,resource=tides,provider=tides,path=extremes[0].time,format=day_mon</desc>27 Jun</text><text
|
|
236
239
|
id="extremes.1.date"
|
|
237
240
|
x="9.9649849"
|
|
238
241
|
y="123.34451"
|
|
@@ -241,7 +244,7 @@
|
|
|
241
244
|
fill="red"
|
|
242
245
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.32646"
|
|
243
246
|
transform="scale(0.82428216,1.2131768)"><desc
|
|
244
|
-
id="desc7">source=resources,resource=tides,path=extremes[1].time,format=day_mon</desc>27 Jun</text><text
|
|
247
|
+
id="desc7">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=day_mon</desc>27 Jun</text><text
|
|
245
248
|
id="extremes.2.date"
|
|
246
249
|
x="12.934491"
|
|
247
250
|
y="156.77794"
|
|
@@ -250,7 +253,7 @@
|
|
|
250
253
|
fill="red"
|
|
251
254
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.4652"
|
|
252
255
|
transform="scale(0.74623156,1.3400666)"><desc
|
|
253
|
-
id="desc9">source=resources,resource=tides,path=extremes[2].time,format=day_mon</desc>27 Jun</text><text
|
|
256
|
+
id="desc9">source=resources,resource=tides,provider=tides,path=extremes[2].time,format=day_mon</desc>27 Jun</text><text
|
|
254
257
|
id="extremes.0.level"
|
|
255
258
|
x="324.0769"
|
|
256
259
|
y="64.82357"
|
|
@@ -264,7 +267,7 @@
|
|
|
264
267
|
x="324.0769"
|
|
265
268
|
y="64.82357"
|
|
266
269
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.71992">3.4m</tspan><desc
|
|
267
|
-
id="desc4">source=resources,resource=tides,path=extremes[0].level,category=depth</desc></text><text
|
|
270
|
+
id="desc4">source=resources,resource=tides,provider=tides,path=extremes[0].level,category=depth</desc></text><text
|
|
268
271
|
xml:space="preserve"
|
|
269
272
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:21.3333px;font-family:sans-serif;-inkscape-font-specification:'\'Roboto, Normal\', sans-serif';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#1a1a1a;stroke:#fefe54;stroke-width:0.612283;stroke-opacity:1"
|
|
270
273
|
x="337.254"
|