@rhizomatics/signalk-einklabel-plugin 1.2.2 → 1.2.3
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 +4 -0
- package/README.md +1 -1
- package/dist/config.js +1 -1
- package/dist/email/emailSender.d.ts +27 -0
- package/dist/email/emailSender.js +66 -0
- package/dist/render/fieldsTable.d.ts +15 -0
- package/dist/render/fieldsTable.js +52 -0
- package/dist/render/llmPrompt.d.ts +86 -0
- package/dist/render/llmPrompt.js +199 -0
- package/package.json +1 -1
- package/templates/.blank/296x128-BWRY.svg +69 -0
- package/templates/.error/296x128-BWRY.svg +93 -0
- package/templates/tides/296x128-BWRY.svg +67 -36
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
|
|
9
9
|
[](https://boat-tech-directory.rhizomatics.org.uk)
|
|
10
10
|
|
|
11
|
-
A SignalK plugin to display data from SignalK paths, Resource APIs and plugins on Electronic Shelf Labels (ESL) over a Bluetooth Low Energy (BLE) connection using simple SVG templates, or optionally created from a crafted prompt by GenAI if the companion [`@rhizomatics/signalk-einklabel-genai-plugin`](https://github.com/rhizomatics/signalk-einklabel-genai-plugin) is installed.
|
|
11
|
+
A SignalK plugin to display data from SignalK paths, Resource APIs and plugins on Electronic Shelf Labels (ESL) over a Bluetooth Low Energy (BLE) connection using simple SVG templates, or optionally created from a crafted prompt by GenAI if the companion [`@rhizomatics/signalk-einklabel-genai-plugin`](https://github.com/rhizomatics/signalk-einklabel-genai-plugin) is installed. Supports ESLs from two of the major Chinese manufacturers, and requires no firmware or hardware modifications, switch on and go.
|
|
12
12
|
|
|
13
13
|

|
|
14
14
|
|
package/dist/config.js
CHANGED
|
@@ -414,7 +414,7 @@ function configSchema(app, discovered = []) {
|
|
|
414
414
|
reframe: {
|
|
415
415
|
type: "string",
|
|
416
416
|
title: "If the render doesn't match the panel size",
|
|
417
|
-
description:
|
|
417
|
+
description: "Crop: place at the top-left, truncating anything too big or leaving the rest blank if too small. Scale: stretch to fit exactly (may distort). Fixed: fail the repaint instead of showing an off-size image.",
|
|
418
418
|
enum: ["crop", "scale", "fixed"],
|
|
419
419
|
default: "crop",
|
|
420
420
|
},
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { EmailConfig } from "../config";
|
|
2
|
+
import { Bitmap } from "../render/types";
|
|
3
|
+
import { FieldRow } from "../render/fieldsTable";
|
|
4
|
+
/** Either the SVG template's field-by-field table (`buildFieldsTable`), a `TemplateProvider`'s own rendered-content text (`describeContent`), or neither (a provider offered no `describeContent`). */
|
|
5
|
+
export type EmailContentBody = {
|
|
6
|
+
kind: "table";
|
|
7
|
+
rows: FieldRow[];
|
|
8
|
+
} | {
|
|
9
|
+
kind: "text";
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "none";
|
|
13
|
+
};
|
|
14
|
+
export interface LabelEmailContent {
|
|
15
|
+
friendlyName: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
bitmap: Bitmap;
|
|
18
|
+
body: EmailContentBody;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
|
|
22
|
+
* reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
|
|
23
|
+
* a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
|
|
24
|
+
* link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
|
|
25
|
+
* device's `emailTo` is set and its render succeeds.
|
|
26
|
+
*/
|
|
27
|
+
export declare function sendLabelEmail(emailConfig: EmailConfig, to: string, content: LabelEmailContent): Promise<void>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.sendLabelEmail = sendLabelEmail;
|
|
7
|
+
const nodemailer_1 = __importDefault(require("nodemailer"));
|
|
8
|
+
const png_1 = require("../render/png");
|
|
9
|
+
const DOCS_URL = "https://rhizomatics.github.io/signalk-einklabel-plugin/";
|
|
10
|
+
function escapeHtml(text) {
|
|
11
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12
|
+
}
|
|
13
|
+
function bodyHtml(body) {
|
|
14
|
+
if (body.kind === "table") {
|
|
15
|
+
if (body.rows.length === 0)
|
|
16
|
+
return "";
|
|
17
|
+
const rows = body.rows
|
|
18
|
+
.map((row) => `<tr><td>${escapeHtml(row.id)}</td><td>${escapeHtml(row.spec)}</td><td>${escapeHtml(row.value)}</td></tr>`)
|
|
19
|
+
.join("");
|
|
20
|
+
return ('<table border="1" cellpadding="4" cellspacing="0" style="border-collapse:collapse;font-family:monospace;font-size:13px">' +
|
|
21
|
+
`<thead><tr><th>id</th><th>spec</th><th>value</th></tr></thead><tbody>${rows}</tbody></table>`);
|
|
22
|
+
}
|
|
23
|
+
if (body.kind === "text") {
|
|
24
|
+
return `<pre style="white-space:pre-wrap;font-family:monospace;font-size:13px">${escapeHtml(body.text)}</pre>`;
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
|
|
30
|
+
* reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
|
|
31
|
+
* a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
|
|
32
|
+
* link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
|
|
33
|
+
* device's `emailTo` is set and its render succeeds.
|
|
34
|
+
*/
|
|
35
|
+
async function sendLabelEmail(emailConfig, to, content) {
|
|
36
|
+
const transporter = nodemailer_1.default.createTransport({
|
|
37
|
+
host: emailConfig.smtpHost,
|
|
38
|
+
port: emailConfig.smtpPort,
|
|
39
|
+
secure: emailConfig.smtpSecure,
|
|
40
|
+
auth: emailConfig.smtpUser ? { user: emailConfig.smtpUser, pass: emailConfig.smtpPassword } : undefined,
|
|
41
|
+
});
|
|
42
|
+
const png = (0, png_1.bitmapToPng)(content.bitmap);
|
|
43
|
+
const html = [
|
|
44
|
+
'<div style="font-family:sans-serif">',
|
|
45
|
+
`<h2>${escapeHtml(content.friendlyName)}</h2>`,
|
|
46
|
+
content.description ? `<p>${escapeHtml(content.description)}</p>` : "",
|
|
47
|
+
`<p><img src="cid:label-image" alt="${escapeHtml(content.friendlyName)}" style="border:1px solid #ccc" /></p>`,
|
|
48
|
+
bodyHtml(content.body),
|
|
49
|
+
`<p><a href="${DOCS_URL}">${DOCS_URL}</a></p>`,
|
|
50
|
+
"</div>",
|
|
51
|
+
].join("\n");
|
|
52
|
+
await transporter.sendMail({
|
|
53
|
+
from: emailConfig.fromAddress,
|
|
54
|
+
to,
|
|
55
|
+
subject: `eInk Label: ${content.friendlyName}`,
|
|
56
|
+
html,
|
|
57
|
+
attachments: [
|
|
58
|
+
{
|
|
59
|
+
filename: "label.png",
|
|
60
|
+
content: png,
|
|
61
|
+
contentType: "image/png",
|
|
62
|
+
cid: "label-image",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TemplateContext } from "./types";
|
|
2
|
+
export interface FieldRow {
|
|
3
|
+
id: string;
|
|
4
|
+
spec: string;
|
|
5
|
+
value: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
|
|
9
|
+
* spec, resolved value), but resolved against a single already-assembled `context` rather than
|
|
10
|
+
* re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
|
|
11
|
+
* the full context its render used. Only meaningful for a hand-authored SVG template - a
|
|
12
|
+
* `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
|
|
13
|
+
* `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildFieldsTable(svgSource: string, context: TemplateContext, templatesDir: string, bundledTemplatesDir: string): FieldRow[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildFieldsTable = buildFieldsTable;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
const xmldom_1 = require("@xmldom/xmldom");
|
|
6
|
+
const assets_1 = require("./assets");
|
|
7
|
+
const binding_1 = require("./binding");
|
|
8
|
+
function resolveFieldValue(tag, desc, context, templatesDir, bundledTemplatesDir) {
|
|
9
|
+
try {
|
|
10
|
+
const binding = (0, binding_1.parseBinding)(desc);
|
|
11
|
+
if (tag !== "image") {
|
|
12
|
+
return (0, binding_1.renderBinding)(binding, context);
|
|
13
|
+
}
|
|
14
|
+
if (!binding.assets) {
|
|
15
|
+
return 'ERROR: an <image> binding requires an "assets" key';
|
|
16
|
+
}
|
|
17
|
+
const key = (0, assets_1.normalizeAssetKey)((0, binding_1.resolveBinding)(binding, context));
|
|
18
|
+
if (!key)
|
|
19
|
+
return "(no usable value - image would be omitted)";
|
|
20
|
+
const assetPath = (0, assets_1.resolveAssetPath)(templatesDir, bundledTemplatesDir, binding.assets, key);
|
|
21
|
+
return assetPath ? `"${key}" -> ${(0, path_1.basename)(assetPath)}` : `"${key}" -> no matching asset file (image would be omitted)`;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return `ERROR: ${err.message}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
|
|
29
|
+
* spec, resolved value), but resolved against a single already-assembled `context` rather than
|
|
30
|
+
* re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
|
|
31
|
+
* the full context its render used. Only meaningful for a hand-authored SVG template - a
|
|
32
|
+
* `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
|
|
33
|
+
* `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
|
|
34
|
+
*/
|
|
35
|
+
function buildFieldsTable(svgSource, context, templatesDir, bundledTemplatesDir) {
|
|
36
|
+
const doc = new xmldom_1.DOMParser().parseFromString(svgSource, "image/svg+xml");
|
|
37
|
+
const rows = [];
|
|
38
|
+
for (const tag of ["text", "image"]) {
|
|
39
|
+
const elements = doc.getElementsByTagName(tag);
|
|
40
|
+
for (let i = 0; i < elements.length; i++) {
|
|
41
|
+
const element = elements.item(i);
|
|
42
|
+
if (!element)
|
|
43
|
+
continue;
|
|
44
|
+
const id = element.getAttribute("id") ?? `${tag}#${i}`;
|
|
45
|
+
const desc = element.getElementsByTagName("desc").item(0);
|
|
46
|
+
if (!desc?.textContent)
|
|
47
|
+
continue;
|
|
48
|
+
rows.push({ id, spec: desc.textContent, value: resolveFieldValue(tag, desc.textContent, context, templatesDir, bundledTemplatesDir) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return rows;
|
|
52
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { PluginConfig } from "../config";
|
|
2
|
+
import { Colour } from "../devices/types";
|
|
3
|
+
import { Binding } from "./binding";
|
|
4
|
+
import { TemplateContext } from "./types";
|
|
5
|
+
/** Facts about one physical label a prompt's `source=label,path=...` placeholders can reference - see `buildLabelContext`. */
|
|
6
|
+
export interface LabelMeta {
|
|
7
|
+
manufacturer: string;
|
|
8
|
+
/** The physical panel's own size label, e.g. `'3.7"'` - `DeviceMetadata.label` verbatim, see `../devices/types.ts`. */
|
|
9
|
+
label: string;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
colours: Colour[];
|
|
13
|
+
description?: string;
|
|
14
|
+
position?: {
|
|
15
|
+
latitude: number;
|
|
16
|
+
longitude: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Builds the `context.label` object a prompt/target-guidance fragment addresses via
|
|
21
|
+
* `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
|
|
22
|
+
* `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
|
|
23
|
+
* placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
|
|
24
|
+
* form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
|
|
25
|
+
* colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
|
|
26
|
+
* single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
|
|
27
|
+
* parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildLabelContext(meta: LabelMeta): Record<string, unknown>;
|
|
30
|
+
/**
|
|
31
|
+
* Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
|
|
32
|
+
* across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
|
|
33
|
+
* (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
|
|
34
|
+
* shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
|
|
35
|
+
* and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
|
|
36
|
+
* (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
|
|
37
|
+
* `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
|
|
38
|
+
* against `context.label`/`context.meta` instead (built by the caller, not fetched).
|
|
39
|
+
*
|
|
40
|
+
* A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
|
|
41
|
+
* skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
|
|
42
|
+
* binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
|
|
43
|
+
* hits the identical parse error at substitution time and turns it into "???" for just that field.
|
|
44
|
+
*/
|
|
45
|
+
export declare function findPromptBindings(...texts: string[]): Binding[];
|
|
46
|
+
/**
|
|
47
|
+
* Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
|
|
48
|
+
* (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
|
|
49
|
+
* `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
|
|
50
|
+
* anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
|
|
51
|
+
* prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
|
|
52
|
+
* reader would notice. Two things override that "???": a binding that resolves successfully to a
|
|
53
|
+
* legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
|
|
54
|
+
* that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
|
|
55
|
+
* that default instead, since the prompt author has already said what a missing value should read as.
|
|
56
|
+
*/
|
|
57
|
+
export declare function substitutePlaceholders(text: string, context: TemplateContext): string;
|
|
58
|
+
/**
|
|
59
|
+
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
60
|
+
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
61
|
+
* side, despite the target-guidance fragment telling it not to.
|
|
62
|
+
*/
|
|
63
|
+
export declare function extractSvg(raw: string): string;
|
|
64
|
+
export type LlmSettings = Pick<PluginConfig, "llmProvider" | "llmApiKey" | "llmModel" | "llmBaseUrl"> & {
|
|
65
|
+
llmTimeoutSeconds?: number;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
|
|
69
|
+
* `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
|
|
70
|
+
* LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
|
|
71
|
+
* fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
|
|
72
|
+
* break BLE painting/templates, which have nothing to do with it. That means every reference to one of
|
|
73
|
+
* these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
|
|
74
|
+
* calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
|
|
75
|
+
* is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
|
|
76
|
+
* still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
|
|
77
|
+
* entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
|
|
78
|
+
*/
|
|
79
|
+
export declare function loadOptional<M>(moduleName: string): M;
|
|
80
|
+
/**
|
|
81
|
+
* Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
|
|
82
|
+
* validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
|
|
83
|
+
* timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
|
|
84
|
+
* server's own REST API).
|
|
85
|
+
*/
|
|
86
|
+
export declare function callLlm(settings: LlmSettings, prompt: string): Promise<string>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildLabelContext = buildLabelContext;
|
|
4
|
+
exports.findPromptBindings = findPromptBindings;
|
|
5
|
+
exports.substitutePlaceholders = substitutePlaceholders;
|
|
6
|
+
exports.extractSvg = extractSvg;
|
|
7
|
+
exports.loadOptional = loadOptional;
|
|
8
|
+
exports.callLlm = callLlm;
|
|
9
|
+
const binding_1 = require("./binding");
|
|
10
|
+
const formatters_1 = require("./formatters");
|
|
11
|
+
const COLOUR_HEX = { black: "#000000", white: "#FFFFFF", red: "#FF0000", yellow: "#FFFF00" };
|
|
12
|
+
/** The only `font-family` values `SvgRenderer` is guaranteed to render - see `expandGenericFontFamilies` in `./svgRenderer.ts`. */
|
|
13
|
+
const SAFE_FONT_FAMILIES = ["serif", "sans-serif", "monospace"];
|
|
14
|
+
/**
|
|
15
|
+
* Builds the `context.label` object a prompt/target-guidance fragment addresses via
|
|
16
|
+
* `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
|
|
17
|
+
* `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
|
|
18
|
+
* placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
|
|
19
|
+
* form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
|
|
20
|
+
* colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
|
|
21
|
+
* single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
|
|
22
|
+
* parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
|
|
23
|
+
*/
|
|
24
|
+
function buildLabelContext(meta) {
|
|
25
|
+
return {
|
|
26
|
+
manufacturer: meta.manufacturer,
|
|
27
|
+
label: meta.label,
|
|
28
|
+
width: meta.width,
|
|
29
|
+
height: meta.height,
|
|
30
|
+
colours: meta.colours.map((colour) => `${colour} (${COLOUR_HEX[colour]})`),
|
|
31
|
+
fonts: SAFE_FONT_FAMILIES,
|
|
32
|
+
description: meta.description ?? "",
|
|
33
|
+
position: meta.position ? (0, formatters_1.applyFormat)("position", meta.position, {}, 3) : undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
|
|
38
|
+
* across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
|
|
39
|
+
* (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
|
|
40
|
+
* shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
|
|
41
|
+
* and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
|
|
42
|
+
* (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
|
|
43
|
+
* `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
|
|
44
|
+
* against `context.label`/`context.meta` instead (built by the caller, not fetched).
|
|
45
|
+
*
|
|
46
|
+
* A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
|
|
47
|
+
* skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
|
|
48
|
+
* binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
|
|
49
|
+
* hits the identical parse error at substitution time and turns it into "???" for just that field.
|
|
50
|
+
*/
|
|
51
|
+
function findPromptBindings(...texts) {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const bindings = [];
|
|
54
|
+
for (const text of texts) {
|
|
55
|
+
for (const match of text.matchAll(/\{([^{}]+)\}/g)) {
|
|
56
|
+
const key = match[1].trim();
|
|
57
|
+
if (seen.has(key))
|
|
58
|
+
continue;
|
|
59
|
+
seen.add(key);
|
|
60
|
+
try {
|
|
61
|
+
bindings.push((0, binding_1.parseBinding)(key));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// see doc comment above - left for `substitutePlaceholders` to turn into "???"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return bindings;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
|
|
72
|
+
* (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
|
|
73
|
+
* `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
|
|
74
|
+
* anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
|
|
75
|
+
* prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
|
|
76
|
+
* reader would notice. Two things override that "???": a binding that resolves successfully to a
|
|
77
|
+
* legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
|
|
78
|
+
* that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
|
|
79
|
+
* that default instead, since the prompt author has already said what a missing value should read as.
|
|
80
|
+
*/
|
|
81
|
+
function substitutePlaceholders(text, context) {
|
|
82
|
+
return text.replace(/\{([^{}]+)\}/g, (_match, raw) => {
|
|
83
|
+
const key = raw.trim();
|
|
84
|
+
try {
|
|
85
|
+
const binding = (0, binding_1.parseBinding)(key);
|
|
86
|
+
const value = (0, binding_1.resolveBinding)(binding, context);
|
|
87
|
+
if ((value === undefined || value === null) && binding.default === undefined)
|
|
88
|
+
return "???";
|
|
89
|
+
return (0, binding_1.renderBinding)(binding, context);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return "???";
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
98
|
+
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
99
|
+
* side, despite the target-guidance fragment telling it not to.
|
|
100
|
+
*/
|
|
101
|
+
function extractSvg(raw) {
|
|
102
|
+
const start = raw.indexOf("<svg");
|
|
103
|
+
const end = raw.lastIndexOf("</svg>");
|
|
104
|
+
if (start === -1 || end === -1 || end < start) {
|
|
105
|
+
throw new Error('LLM response did not contain a "<svg>...</svg>" document');
|
|
106
|
+
}
|
|
107
|
+
return raw.slice(start, end + "</svg>".length);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
|
|
111
|
+
* `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
|
|
112
|
+
* LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
|
|
113
|
+
* fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
|
|
114
|
+
* break BLE painting/templates, which have nothing to do with it. That means every reference to one of
|
|
115
|
+
* these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
|
|
116
|
+
* calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
|
|
117
|
+
* is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
|
|
118
|
+
* still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
|
|
119
|
+
* entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
|
|
120
|
+
*/
|
|
121
|
+
function loadOptional(moduleName) {
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
124
|
+
return require(moduleName);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
throw new Error(`LLM provider support needs the optional dependency "${moduleName}", which isn't installed - run "npm install ${moduleName}" (${err.message})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
|
|
131
|
+
const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
|
|
132
|
+
/**
|
|
133
|
+
* Resolves `settings` to a Vercel AI SDK model handle. `"ollama"` and `"local"` both go through the
|
|
134
|
+
* generic `@ai-sdk/openai-compatible` provider (Ollama/LM Studio/vLLM all expose an OpenAI-compatible
|
|
135
|
+
* endpoint, and none of them has - or needs - its own dedicated `@ai-sdk/*` package): `"ollama"`
|
|
136
|
+
* defaults `llmBaseUrl` to Ollama's own standard local address so it works with no further config
|
|
137
|
+
* (override it only if Ollama is running elsewhere, e.g. on the SignalK server's own host reached over
|
|
138
|
+
* the network); `"local"` is for anything else OpenAI-compatible, where there's no sensible universal
|
|
139
|
+
* default, so `llmBaseUrl` is required.
|
|
140
|
+
*/
|
|
141
|
+
function resolveModel(settings) {
|
|
142
|
+
const model = settings.llmModel;
|
|
143
|
+
if (!model) {
|
|
144
|
+
throw new Error("no LLM model configured (PluginConfig.llmModel)");
|
|
145
|
+
}
|
|
146
|
+
switch (settings.llmProvider ?? "openai") {
|
|
147
|
+
case "anthropic": {
|
|
148
|
+
const { createAnthropic } = loadOptional("@ai-sdk/anthropic");
|
|
149
|
+
return createAnthropic({ apiKey: settings.llmApiKey })(model);
|
|
150
|
+
}
|
|
151
|
+
case "google": {
|
|
152
|
+
const { createGoogleGenerativeAI } = loadOptional("@ai-sdk/google");
|
|
153
|
+
return createGoogleGenerativeAI({ apiKey: settings.llmApiKey })(model);
|
|
154
|
+
}
|
|
155
|
+
case "xai": {
|
|
156
|
+
const { createXai } = loadOptional("@ai-sdk/xai");
|
|
157
|
+
return createXai({ apiKey: settings.llmApiKey })(model);
|
|
158
|
+
}
|
|
159
|
+
case "ollama": {
|
|
160
|
+
const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
|
|
161
|
+
return createOpenAICompatible({
|
|
162
|
+
name: "ollama",
|
|
163
|
+
baseURL: settings.llmBaseUrl || DEFAULT_OLLAMA_BASE_URL,
|
|
164
|
+
apiKey: settings.llmApiKey,
|
|
165
|
+
})(model);
|
|
166
|
+
}
|
|
167
|
+
case "local": {
|
|
168
|
+
if (!settings.llmBaseUrl) {
|
|
169
|
+
throw new Error('llmBaseUrl is required when llmProvider is "local"');
|
|
170
|
+
}
|
|
171
|
+
const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
|
|
172
|
+
return createOpenAICompatible({ name: "local", baseURL: settings.llmBaseUrl, apiKey: settings.llmApiKey })(model);
|
|
173
|
+
}
|
|
174
|
+
case "openai":
|
|
175
|
+
default: {
|
|
176
|
+
const { createOpenAI } = loadOptional("@ai-sdk/openai");
|
|
177
|
+
return createOpenAI({ apiKey: settings.llmApiKey })(model);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
|
|
183
|
+
* validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
|
|
184
|
+
* timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
|
|
185
|
+
* server's own REST API).
|
|
186
|
+
*/
|
|
187
|
+
async function callLlm(settings, prompt) {
|
|
188
|
+
const model = resolveModel(settings);
|
|
189
|
+
const { generateText } = loadOptional("ai");
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ?? 30) * 1000);
|
|
192
|
+
try {
|
|
193
|
+
const { text } = await generateText({ model, prompt, abortSignal: controller.signal });
|
|
194
|
+
return text;
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
+
<svg
|
|
3
|
+
width="296"
|
|
4
|
+
height="128"
|
|
5
|
+
viewBox="0 0 296 128"
|
|
6
|
+
version="1.1"
|
|
7
|
+
id="svg1"
|
|
8
|
+
sodipodi:docname="296x128-BWRY.svg"
|
|
9
|
+
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
|
10
|
+
xml:space="preserve"
|
|
11
|
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
12
|
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
13
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
14
|
+
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
|
15
|
+
id="defs1" /><sodipodi:namedview
|
|
16
|
+
id="namedview1"
|
|
17
|
+
pagecolor="#ffffff"
|
|
18
|
+
bordercolor="#000000"
|
|
19
|
+
borderopacity="0.25"
|
|
20
|
+
inkscape:showpageshadow="2"
|
|
21
|
+
inkscape:pageopacity="0.0"
|
|
22
|
+
inkscape:pagecheckerboard="0"
|
|
23
|
+
inkscape:deskcolor="#d1d1d1"
|
|
24
|
+
inkscape:zoom="1.3906433"
|
|
25
|
+
inkscape:cx="159.2788"
|
|
26
|
+
inkscape:cy="101.03238"
|
|
27
|
+
inkscape:window-width="1488"
|
|
28
|
+
inkscape:window-height="674"
|
|
29
|
+
inkscape:window-x="24"
|
|
30
|
+
inkscape:window-y="33"
|
|
31
|
+
inkscape:window-maximized="0"
|
|
32
|
+
inkscape:current-layer="svg1"
|
|
33
|
+
showgrid="true"
|
|
34
|
+
showguides="true"><inkscape:grid
|
|
35
|
+
id="grid1"
|
|
36
|
+
units="px"
|
|
37
|
+
originx="0"
|
|
38
|
+
originy="0"
|
|
39
|
+
spacingx="1"
|
|
40
|
+
spacingy="1"
|
|
41
|
+
empcolor="#0099e5"
|
|
42
|
+
empopacity="0.30196078"
|
|
43
|
+
color="#0099e5"
|
|
44
|
+
opacity="0.14901961"
|
|
45
|
+
empspacing="5"
|
|
46
|
+
dotted="false"
|
|
47
|
+
gridanglex="30"
|
|
48
|
+
gridanglez="30"
|
|
49
|
+
visible="true" /></sodipodi:namedview><rect
|
|
50
|
+
x="1.0169492"
|
|
51
|
+
y="2.0798673e-08"
|
|
52
|
+
width="296"
|
|
53
|
+
height="128"
|
|
54
|
+
fill="#ffffff"
|
|
55
|
+
id="rect1"
|
|
56
|
+
style="stroke-width:0.616026" /><text
|
|
57
|
+
id="last_repaint"
|
|
58
|
+
x="245.9742"
|
|
59
|
+
y="104.33255"
|
|
60
|
+
font-size="18"
|
|
61
|
+
font-family="serif"
|
|
62
|
+
fill="black"
|
|
63
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:10.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffff00;stroke-width:1.19309"
|
|
64
|
+
transform="scale(0.85412559,1.170788)"><desc
|
|
65
|
+
id="desc12">source=einklabel,path=repainted,format=local_datetime_short</desc><tspan
|
|
66
|
+
sodipodi:role="line"
|
|
67
|
+
id="tspan3"
|
|
68
|
+
x="245.9742"
|
|
69
|
+
y="104.33255">27 Jun 26 18:05</tspan></text></svg>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
+
<svg
|
|
3
|
+
width="296"
|
|
4
|
+
height="128"
|
|
5
|
+
viewBox="0 0 296 128"
|
|
6
|
+
version="1.1"
|
|
7
|
+
id="svg4"
|
|
8
|
+
sodipodi:docname="296x128-BWRY.svg"
|
|
9
|
+
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
|
10
|
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
11
|
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
12
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
13
|
+
xmlns:svg="http://www.w3.org/2000/svg">
|
|
14
|
+
<defs
|
|
15
|
+
id="defs4" />
|
|
16
|
+
<sodipodi:namedview
|
|
17
|
+
id="namedview4"
|
|
18
|
+
pagecolor="#ffffff"
|
|
19
|
+
bordercolor="#000000"
|
|
20
|
+
borderopacity="0.25"
|
|
21
|
+
inkscape:showpageshadow="2"
|
|
22
|
+
inkscape:pageopacity="0.0"
|
|
23
|
+
inkscape:pagecheckerboard="0"
|
|
24
|
+
inkscape:deskcolor="#d1d1d1"
|
|
25
|
+
inkscape:zoom="0.944"
|
|
26
|
+
inkscape:cx="124.47034"
|
|
27
|
+
inkscape:cy="64.088983"
|
|
28
|
+
inkscape:window-width="1480"
|
|
29
|
+
inkscape:window-height="578"
|
|
30
|
+
inkscape:window-x="311"
|
|
31
|
+
inkscape:window-y="41"
|
|
32
|
+
inkscape:window-maximized="0"
|
|
33
|
+
inkscape:current-layer="svg4" />
|
|
34
|
+
<rect
|
|
35
|
+
x="-1.059322"
|
|
36
|
+
y="1.059322"
|
|
37
|
+
width="296"
|
|
38
|
+
height="128"
|
|
39
|
+
fill="#ffffff"
|
|
40
|
+
id="rect1"
|
|
41
|
+
style="stroke-width:1.08812" />
|
|
42
|
+
<polygon
|
|
43
|
+
points="45,18 12,96 78,96 "
|
|
44
|
+
fill="#ff0000"
|
|
45
|
+
stroke="#000000"
|
|
46
|
+
stroke-width="3"
|
|
47
|
+
stroke-linejoin="round"
|
|
48
|
+
id="polygon1" />
|
|
49
|
+
<rect
|
|
50
|
+
x="41"
|
|
51
|
+
y="46"
|
|
52
|
+
width="8"
|
|
53
|
+
height="26"
|
|
54
|
+
fill="#000000"
|
|
55
|
+
id="rect2" />
|
|
56
|
+
<circle
|
|
57
|
+
cx="45"
|
|
58
|
+
cy="84"
|
|
59
|
+
r="5"
|
|
60
|
+
fill="#000000"
|
|
61
|
+
id="circle2" />
|
|
62
|
+
<text
|
|
63
|
+
x="92"
|
|
64
|
+
y="46"
|
|
65
|
+
font-size="22px"
|
|
66
|
+
font-family="sans-serif"
|
|
67
|
+
font-weight="bold"
|
|
68
|
+
fill="#000000"
|
|
69
|
+
id="text2">CONTENT</text>
|
|
70
|
+
<text
|
|
71
|
+
x="92"
|
|
72
|
+
y="72"
|
|
73
|
+
font-size="22px"
|
|
74
|
+
font-family="sans-serif"
|
|
75
|
+
font-weight="bold"
|
|
76
|
+
fill="#000000"
|
|
77
|
+
id="text3">UNAVAILABLE</text>
|
|
78
|
+
<text
|
|
79
|
+
x="92"
|
|
80
|
+
y="92"
|
|
81
|
+
font-size="12px"
|
|
82
|
+
font-family="sans-serif"
|
|
83
|
+
fill="#000000"
|
|
84
|
+
id="text4">Check manually</text>
|
|
85
|
+
<text
|
|
86
|
+
id="last_attempt"
|
|
87
|
+
x="92"
|
|
88
|
+
y="112"
|
|
89
|
+
font-size="10px"
|
|
90
|
+
font-family="monospace"
|
|
91
|
+
fill="#000000"><desc
|
|
92
|
+
id="desc4">source=einklabel,path=repainted,format=local_datetime_short</desc>last attempt: --</text>
|
|
93
|
+
</svg>
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
inkscape:pagecheckerboard="0"
|
|
23
23
|
inkscape:deskcolor="#d1d1d1"
|
|
24
24
|
inkscape:zoom="1.3906433"
|
|
25
|
-
inkscape:cx="
|
|
26
|
-
inkscape:cy="
|
|
25
|
+
inkscape:cx="188.04247"
|
|
26
|
+
inkscape:cy="101.03238"
|
|
27
27
|
inkscape:window-width="1488"
|
|
28
28
|
inkscape:window-height="674"
|
|
29
|
-
inkscape:window-x="
|
|
29
|
+
inkscape:window-x="102"
|
|
30
30
|
inkscape:window-y="33"
|
|
31
31
|
inkscape:window-maximized="0"
|
|
32
32
|
inkscape:current-layer="svg1"
|
|
@@ -49,11 +49,18 @@
|
|
|
49
49
|
visible="true" /></sodipodi:namedview><rect
|
|
50
50
|
x="1.0169492"
|
|
51
51
|
y="2.0798673e-08"
|
|
52
|
-
width="
|
|
52
|
+
width="294.47351"
|
|
53
53
|
height="129.97897"
|
|
54
54
|
fill="#ffffff"
|
|
55
55
|
id="rect1"
|
|
56
|
-
style="stroke-width:0.
|
|
56
|
+
style="stroke-width:0.619167" /><rect
|
|
57
|
+
style="fill:#ffff00;stroke:#000000;stroke-width:0.517511"
|
|
58
|
+
id="rect2"
|
|
59
|
+
width="295.38821"
|
|
60
|
+
height="18.45849"
|
|
61
|
+
x="-8.1818981"
|
|
62
|
+
y="-129.92928"
|
|
63
|
+
transform="matrix(1,0,-0.07553602,-0.99714307,0,0)" /><text
|
|
57
64
|
id="station.name"
|
|
58
65
|
x="7.2081757"
|
|
59
66
|
y="19.148237"
|
|
@@ -64,18 +71,28 @@
|
|
|
64
71
|
transform="scale(0.89274881,1.1201359)">Tidal Station Name<desc
|
|
65
72
|
id="desc1">source=resources,resource=tides,provider=tides,path=station.name</desc></text><text
|
|
66
73
|
id="last_repaint"
|
|
67
|
-
x="
|
|
68
|
-
y="
|
|
74
|
+
x="174.82307"
|
|
75
|
+
y="131.08972"
|
|
69
76
|
font-size="18"
|
|
70
77
|
font-family="serif"
|
|
71
78
|
fill="black"
|
|
72
|
-
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:
|
|
73
|
-
transform="scale(
|
|
79
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.1048px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.20787;stroke-dasharray:none;stroke-opacity:1"
|
|
80
|
+
transform="scale(1.0484359,0.95380175)"><desc
|
|
74
81
|
id="desc12">source=einklabel,path=repainted,format=local_datetime_short</desc><tspan
|
|
75
82
|
sodipodi:role="line"
|
|
76
83
|
id="tspan3"
|
|
77
|
-
x="
|
|
78
|
-
y="
|
|
84
|
+
x="174.82307"
|
|
85
|
+
y="131.08972"
|
|
86
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.1048px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.20787;stroke-dasharray:none;stroke-opacity:1">27 Jun 26 18:05</tspan></text><text
|
|
87
|
+
id="timezone"
|
|
88
|
+
x="169.78108"
|
|
89
|
+
y="107.90738"
|
|
90
|
+
font-size="10.373px"
|
|
91
|
+
font-family="sans-serif"
|
|
92
|
+
fill="#ffff00"
|
|
93
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12px;font-family:Roboto;-inkscape-font-specification:'Roboto, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;direction:rtl;fill:#000000;stroke-width:0.864419"
|
|
94
|
+
transform="scale(0.86441854,1.156847)">UTC+01:00<desc
|
|
95
|
+
id="desc11">source=einklabel,path=local_zone</desc></text><text
|
|
79
96
|
id="extremes.0"
|
|
80
97
|
x="7.9661012"
|
|
81
98
|
y="42.022709"
|
|
@@ -93,38 +110,38 @@
|
|
|
93
110
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000"><desc
|
|
94
111
|
id="desc14">source=resources,resource=tides,provider=tides,path=extremes[1].label</desc>High or Low</text><text
|
|
95
112
|
id="extremes.1.level"
|
|
96
|
-
x="
|
|
97
|
-
y="
|
|
113
|
+
x="236.04683"
|
|
114
|
+
y="74.289345"
|
|
98
115
|
font-size="14"
|
|
99
116
|
font-family="monospace"
|
|
100
117
|
fill="red"
|
|
101
|
-
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:
|
|
102
|
-
transform="scale(0.
|
|
103
|
-
inkscape:transform-center-y="-
|
|
104
|
-
inkscape:transform-center-x="-0.
|
|
118
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:29.3333px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.88094"
|
|
119
|
+
transform="scale(0.74369426,1.3446386)"
|
|
120
|
+
inkscape:transform-center-y="-156.89445"
|
|
121
|
+
inkscape:transform-center-x="-0.00039560164"><tspan
|
|
105
122
|
sodipodi:role="line"
|
|
106
123
|
id="tspan2-1"
|
|
107
|
-
x="
|
|
108
|
-
y="
|
|
109
|
-
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:
|
|
124
|
+
x="236.04683"
|
|
125
|
+
y="74.289345"
|
|
126
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:29.33333333px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.88094">1.6m</tspan><desc
|
|
110
127
|
id="desc5">source=resources,resource=tides,provider=tides,path=extremes[1].level,category=depth</desc></text><text
|
|
111
128
|
id="extremes.1.time"
|
|
112
|
-
x="
|
|
113
|
-
y="
|
|
129
|
+
x="148.60976"
|
|
130
|
+
y="72.137405"
|
|
114
131
|
font-size="14"
|
|
115
132
|
font-family="monospace"
|
|
116
133
|
fill="red"
|
|
117
|
-
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:
|
|
118
|
-
transform="scale(0.
|
|
134
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.74366"
|
|
135
|
+
transform="scale(0.71873644,1.3913306)">09:36<desc
|
|
119
136
|
id="desc8">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=local_time</desc></text><text
|
|
120
137
|
id="extremes.0.time"
|
|
121
|
-
x="
|
|
122
|
-
y="
|
|
138
|
+
x="148.53975"
|
|
139
|
+
y="44.011204"
|
|
123
140
|
font-size="14"
|
|
124
141
|
font-family="monospace"
|
|
125
142
|
fill="red"
|
|
126
|
-
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:
|
|
127
|
-
transform="scale(0.
|
|
143
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.79195"
|
|
144
|
+
transform="scale(0.73254241,1.3651087)">03:27<desc
|
|
128
145
|
id="desc3">source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time</desc></text><text
|
|
129
146
|
id="extremes.0.date"
|
|
130
147
|
x="11.198709"
|
|
@@ -145,16 +162,30 @@
|
|
|
145
162
|
transform="scale(0.82428216,1.2131768)"><desc
|
|
146
163
|
id="desc7">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=day_mon</desc>27 Jun</text><text
|
|
147
164
|
id="extremes.0.level"
|
|
148
|
-
x="
|
|
149
|
-
y="
|
|
165
|
+
x="243.27972"
|
|
166
|
+
y="44.41362"
|
|
150
167
|
font-size="14"
|
|
151
168
|
font-family="monospace"
|
|
152
169
|
fill="red"
|
|
153
|
-
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:
|
|
154
|
-
transform="scale(0.
|
|
170
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:29.3333px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.91777"
|
|
171
|
+
transform="scale(0.72743302,1.374697)"><tspan
|
|
155
172
|
sodipodi:role="line"
|
|
156
173
|
id="tspan2"
|
|
157
|
-
x="
|
|
158
|
-
y="
|
|
159
|
-
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:
|
|
160
|
-
id="desc4">source=resources,resource=tides,provider=tides,path=extremes[0].level,category=depth</desc></text
|
|
174
|
+
x="243.27972"
|
|
175
|
+
y="44.41362"
|
|
176
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:29.33333347px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.91777">3.4m</tspan><desc
|
|
177
|
+
id="desc4">source=resources,resource=tides,provider=tides,path=extremes[0].level,category=depth</desc></text><text
|
|
178
|
+
id="source"
|
|
179
|
+
x="5.7133317"
|
|
180
|
+
y="119.17512"
|
|
181
|
+
font-size="18"
|
|
182
|
+
font-family="serif"
|
|
183
|
+
fill="black"
|
|
184
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:13.3333px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000;stroke-width:3.80045"
|
|
185
|
+
transform="scale(0.95907861,1.0426674)"><desc
|
|
186
|
+
id="desc21">source=resources,resource=tides,provider=tides,path=station.source.name</desc><tspan
|
|
187
|
+
sodipodi:role="line"
|
|
188
|
+
id="tspan20"
|
|
189
|
+
x="5.7133317"
|
|
190
|
+
y="119.17512"
|
|
191
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:13.3333px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:3.80045">SOURCE</tspan></text></svg>
|