@ham2k/extension-sdk 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/dist/index.d.ts +204 -1
- package/dist/index.js +13 -0
- package/dist/svgScene.js +0 -0
- package/dist/templateContext.js +20 -0
- package/dist/templates.js +9 -0
- package/docs/hooks.md +96 -0
- package/docs/templates.md +24 -3
- package/package.json +1 -1
- package/samples/README.md +1 -0
- package/samples/k2hrc-svg-scenes/README.md +24 -0
- package/samples/k2hrc-svg-scenes/build.mjs +6 -0
- package/samples/k2hrc-svg-scenes/manifest.json +31 -0
- package/samples/k2hrc-svg-scenes/src/index.ts +12 -0
- package/samples/k2hrc-svg-scenes/src/scenes.ts +128 -0
package/AGENTS.md
CHANGED
|
@@ -81,7 +81,7 @@ will actually call.
|
|
|
81
81
|
|
|
82
82
|
| Doing this | Read |
|
|
83
83
|
| --- | --- |
|
|
84
|
-
| Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel) |
|
|
84
|
+
| Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel), `k2hrc-svg-scenes` (experimental interactive SVG) |
|
|
85
85
|
| Any hook at all — what the category is, what it's handed, what it must return | `docs/hooks.md`, the section named for the category |
|
|
86
86
|
| A pane in a view: `getPanels`, `render`, panel content kinds | `docs/hooks.md` §`panel` |
|
|
87
87
|
| Callsign lookups, spot sources, exporters, reference types (POTA-style) | `docs/hooks.md` §`lookup`, §`spots`, §`export`, §`ref:<type>` |
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,121 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.
|
|
1
|
+
// @ham2k/extension-sdk 0.5.1
|
|
2
|
+
/** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
|
|
3
|
+
export interface SvgScene {
|
|
4
|
+
version: 1;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
values: Record<string, number>;
|
|
8
|
+
layers: SvgSceneLayer[];
|
|
9
|
+
controls?: SvgSceneControl[];
|
|
10
|
+
}
|
|
11
|
+
/** A clamped, linear mapping. No expressions or extension code run per frame. */
|
|
12
|
+
export interface SceneBinding {
|
|
13
|
+
value: string;
|
|
14
|
+
/** Transform the source before mapping (for example selecting a display digit). */
|
|
15
|
+
scale?: number;
|
|
16
|
+
truncate?: boolean;
|
|
17
|
+
modulo?: number;
|
|
18
|
+
input: [
|
|
19
|
+
number,
|
|
20
|
+
number
|
|
21
|
+
];
|
|
22
|
+
output: [
|
|
23
|
+
number,
|
|
24
|
+
number
|
|
25
|
+
];
|
|
26
|
+
/** Optional equally spaced output samples over input; replaces the output ramp. */
|
|
27
|
+
samples?: number[];
|
|
28
|
+
}
|
|
29
|
+
export interface SvgSceneLayer {
|
|
30
|
+
id: string;
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
/** Supply exactly one of svg or text. SVG must be self-contained. */
|
|
36
|
+
svg?: string;
|
|
37
|
+
/** Exactly one of literal/value. Use text layers, not SVG <text>, for font support.
|
|
38
|
+
* Size is unscaled; the host applies OS text scaling once. */
|
|
39
|
+
text?: {
|
|
40
|
+
value?: string;
|
|
41
|
+
literal?: string;
|
|
42
|
+
prefix?: string;
|
|
43
|
+
suffix?: string;
|
|
44
|
+
decimals?: number;
|
|
45
|
+
size?: number;
|
|
46
|
+
color?: string;
|
|
47
|
+
samples?: (number | string)[];
|
|
48
|
+
/** Multiply numeric text values before formatting. Defaults to 1. */
|
|
49
|
+
scale?: number;
|
|
50
|
+
/** Numeric formatting after scaling: truncate, then modulo, then zero padding. */
|
|
51
|
+
truncate?: boolean;
|
|
52
|
+
modulo?: number;
|
|
53
|
+
minIntegerDigits?: number;
|
|
54
|
+
fontFamily?: string;
|
|
55
|
+
fontWeight?: number;
|
|
56
|
+
lineHeight?: number;
|
|
57
|
+
letterSpacing?: number;
|
|
58
|
+
/** Horizontal alignment within the layer. Defaults to start. */
|
|
59
|
+
align?: "start" | "center" | "end";
|
|
60
|
+
};
|
|
61
|
+
rotation?: SceneBinding;
|
|
62
|
+
translateX?: SceneBinding;
|
|
63
|
+
translateY?: SceneBinding;
|
|
64
|
+
opacity?: SceneBinding;
|
|
65
|
+
/** Rotation origin as fractions of this layer's dimensions; default [0.5, 0.5]. */
|
|
66
|
+
pivot?: [
|
|
67
|
+
number,
|
|
68
|
+
number
|
|
69
|
+
];
|
|
70
|
+
transitionMs?: number;
|
|
71
|
+
/** Local deterministic flicker/pulse; pauses when hidden, disabled for reduced motion. */
|
|
72
|
+
pulse?: {
|
|
73
|
+
periodMs: number;
|
|
74
|
+
minOpacity: number;
|
|
75
|
+
flicker?: boolean;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export interface SvgSceneControl {
|
|
79
|
+
id: string;
|
|
80
|
+
label: string;
|
|
81
|
+
kind: "button" | "slider" | "knob";
|
|
82
|
+
/** A button can open a native dropdown. Picking an item sends its event as the action. */
|
|
83
|
+
menu?: {
|
|
84
|
+
label: string;
|
|
85
|
+
event: string;
|
|
86
|
+
}[];
|
|
87
|
+
x: number;
|
|
88
|
+
y: number;
|
|
89
|
+
width: number;
|
|
90
|
+
height: number;
|
|
91
|
+
/** Omit event for local-only interaction (for example a chart inspection cursor). */
|
|
92
|
+
event?: string;
|
|
93
|
+
/** Send coalesced change events during dragging, plus the final commit. */
|
|
94
|
+
continuous?: boolean;
|
|
95
|
+
value?: string;
|
|
96
|
+
min?: number;
|
|
97
|
+
max?: number;
|
|
98
|
+
step?: number;
|
|
99
|
+
/** Equal-width buckets, with one bucket per step (useful for hourly charts). */
|
|
100
|
+
discrete?: boolean;
|
|
101
|
+
/** Inspect on hover. Only allowed on local-only sliders; touch/keyboard also work. */
|
|
102
|
+
hover?: boolean;
|
|
103
|
+
/** Accessible descriptions indexed by (value - min) / step. */
|
|
104
|
+
valueLabels?: string[];
|
|
105
|
+
/** Knobs use relative horizontal/upward dragging. Default: step per scene unit. */
|
|
106
|
+
sensitivity?: number;
|
|
107
|
+
}
|
|
108
|
+
export interface PanelSceneEvent {
|
|
109
|
+
controlId: string;
|
|
110
|
+
action: string;
|
|
111
|
+
phase: "activate" | "change" | "commit";
|
|
112
|
+
sequence: number;
|
|
113
|
+
value?: number;
|
|
114
|
+
}
|
|
115
|
+
/** Patch only named scene values. The host ignores stale responses. */
|
|
116
|
+
export interface PanelSceneEventResult {
|
|
117
|
+
values: Record<string, number>;
|
|
118
|
+
}
|
|
2
119
|
export type CallInfo = {
|
|
3
120
|
call: string;
|
|
4
121
|
baseCall?: string;
|
|
@@ -807,6 +924,8 @@ export interface ExtensionManifest {
|
|
|
807
924
|
domains?: string[];
|
|
808
925
|
allowUserAgentOverride?: boolean;
|
|
809
926
|
requiresLocation?: boolean;
|
|
927
|
+
requiresRadioRead?: boolean;
|
|
928
|
+
requiresRadioWrite?: boolean;
|
|
810
929
|
experiments?: string[];
|
|
811
930
|
relevance?: ExtensionRelevance;
|
|
812
931
|
translations?: Record<string, {
|
|
@@ -1122,7 +1241,75 @@ export interface PanelDescriptor {
|
|
|
1122
1241
|
multiple?: boolean;
|
|
1123
1242
|
form?: SettingsField[];
|
|
1124
1243
|
}
|
|
1244
|
+
/** Resolved host typography; sizes are logical pixels, not the app Font Scale. */
|
|
1245
|
+
export interface PanelTypography {
|
|
1246
|
+
fontFamily: string | null;
|
|
1247
|
+
fontFamilyFallback: string[];
|
|
1248
|
+
fontSize: number;
|
|
1249
|
+
/** OS text scaling at this exact role size (the scaler may be nonlinear). */
|
|
1250
|
+
scaledFontSize: number;
|
|
1251
|
+
fontWeight: number;
|
|
1252
|
+
lineHeight: number;
|
|
1253
|
+
letterSpacing: number;
|
|
1254
|
+
}
|
|
1255
|
+
/** Current placement environment. Never multiply coordinates by devicePixelRatio. */
|
|
1256
|
+
export interface PanelEnvironment {
|
|
1257
|
+
version: 1;
|
|
1258
|
+
width: number;
|
|
1259
|
+
height: number;
|
|
1260
|
+
safeInsets: {
|
|
1261
|
+
left: number;
|
|
1262
|
+
top: number;
|
|
1263
|
+
right: number;
|
|
1264
|
+
bottom: number;
|
|
1265
|
+
};
|
|
1266
|
+
brightness: "light" | "dark";
|
|
1267
|
+
colors: Record<"surface" | "surfaceContainer" | "onSurface" | "onSurfaceVariant" | "accent" | "primary" | "onPrimary" | "secondary" | "outline" | "outlineVariant" | "error" | "onError", string>;
|
|
1268
|
+
typography: Record<"body" | "label" | "title" | "display" | "mono", PanelTypography>;
|
|
1269
|
+
locale: string;
|
|
1270
|
+
textDirection: "ltr" | "rtl";
|
|
1271
|
+
devicePixelRatio: number;
|
|
1272
|
+
reducedMotion: boolean;
|
|
1273
|
+
highContrast: boolean;
|
|
1274
|
+
}
|
|
1275
|
+
/** A local CAT radio. Values are reported readings, never manual VFO fallbacks. */
|
|
1276
|
+
export interface PanelRadioState {
|
|
1277
|
+
id: string | null;
|
|
1278
|
+
name: string | null;
|
|
1279
|
+
status: "disconnected" | "connecting" | "connected" | "disconnecting" | "error";
|
|
1280
|
+
stale: boolean;
|
|
1281
|
+
problem: string | null;
|
|
1282
|
+
frequencyHz: number | null;
|
|
1283
|
+
mode: string | null;
|
|
1284
|
+
powerWatts: number | null;
|
|
1285
|
+
transmitting: boolean | null;
|
|
1286
|
+
meters: Partial<Record<"signalDbm" | "powerOut" | "swr" | "alc" | "supplyVolts", number>>;
|
|
1287
|
+
canTune: boolean;
|
|
1288
|
+
}
|
|
1289
|
+
export interface PanelRadioTune {
|
|
1290
|
+
/** Reject if this identity no longer matches the selected radio. */
|
|
1291
|
+
id: string;
|
|
1292
|
+
/** Pin a local radio by ID; omitted means follow the designated radio. */
|
|
1293
|
+
selection?: string;
|
|
1294
|
+
frequencyHz?: number;
|
|
1295
|
+
mode?: "LSB" | "USB" | "CW" | "AM" | "FM";
|
|
1296
|
+
}
|
|
1297
|
+
export interface PanelRadioTuneResult {
|
|
1298
|
+
/** Accepted by the host; confirmation still comes from reported state. */
|
|
1299
|
+
accepted: boolean;
|
|
1300
|
+
reason?: "changed" | "disconnected" | "transmitting" | "invalid";
|
|
1301
|
+
state: PanelRadioState | null;
|
|
1302
|
+
}
|
|
1125
1303
|
export interface PanelRenderArgs {
|
|
1304
|
+
/** Present on environment-capable hosts. Changes automatically request a throttled render of an `svgScene` panel. */
|
|
1305
|
+
environment?: PanelEnvironment;
|
|
1306
|
+
/** Display time follows developer time travel; real time is for network retry/cache budgets. */
|
|
1307
|
+
clock?: {
|
|
1308
|
+
nowMillis: number;
|
|
1309
|
+
realNowMillis: number;
|
|
1310
|
+
};
|
|
1311
|
+
/** Stable placement identity, supplied by scene-capable hosts. */
|
|
1312
|
+
instanceId?: string;
|
|
1126
1313
|
panelKey: string;
|
|
1127
1314
|
operation: Record<string, JSONValue>;
|
|
1128
1315
|
qso?: Record<string, JSONValue>;
|
|
@@ -1131,6 +1318,11 @@ export interface PanelRenderArgs {
|
|
|
1131
1318
|
reason: string;
|
|
1132
1319
|
}
|
|
1133
1320
|
export type PanelContent = {
|
|
1321
|
+
kind: "svgScene";
|
|
1322
|
+
scene: SvgScene;
|
|
1323
|
+
title?: string;
|
|
1324
|
+
triggers?: string[];
|
|
1325
|
+
} | {
|
|
1134
1326
|
kind: "markdown";
|
|
1135
1327
|
content: string;
|
|
1136
1328
|
title?: string;
|
|
@@ -1149,6 +1341,9 @@ export type PanelContent = {
|
|
|
1149
1341
|
export interface PanelHook {
|
|
1150
1342
|
getPanels(args: Record<string, never>, ctx: HookContext): Promise<PanelDescriptor[]>;
|
|
1151
1343
|
render(args: PanelRenderArgs, ctx: HookContext): Promise<PanelContent>;
|
|
1344
|
+
onEvent?(args: PanelRenderArgs & {
|
|
1345
|
+
event: PanelSceneEvent;
|
|
1346
|
+
}, ctx: HookContext): Promise<PanelSceneEventResult>;
|
|
1152
1347
|
}
|
|
1153
1348
|
/**
|
|
1154
1349
|
* The standard per-extension translator: takes the extension's i18next
|
|
@@ -1458,6 +1653,14 @@ export declare const hooks: {
|
|
|
1458
1653
|
}[]>;
|
|
1459
1654
|
};
|
|
1460
1655
|
export declare const host: {
|
|
1656
|
+
listRadios(): Promise<PanelRadioState[]>;
|
|
1657
|
+
readRadio(id?: string): Promise<PanelRadioState | null>;
|
|
1658
|
+
setRadioConnection(args: {
|
|
1659
|
+
id: string;
|
|
1660
|
+
selection?: string;
|
|
1661
|
+
connected: boolean;
|
|
1662
|
+
}): Promise<PanelRadioTuneResult>;
|
|
1663
|
+
tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
|
|
1461
1664
|
fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
|
|
1462
1665
|
kvGet(key: string): Promise<JSONValue | null>;
|
|
1463
1666
|
getLocation(): Promise<DeviceLocation | null>;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
|
+
export * from "./svgScene.js";
|
|
2
3
|
export * from "./i18n.js";
|
|
3
4
|
export * from "./dxcc.js";
|
|
4
5
|
export * from "./location.js";
|
|
@@ -60,6 +61,18 @@ const hooks = {
|
|
|
60
61
|
}
|
|
61
62
|
};
|
|
62
63
|
const host = {
|
|
64
|
+
async listRadios() {
|
|
65
|
+
return await hostCallFn()("listRadios", {});
|
|
66
|
+
},
|
|
67
|
+
async readRadio(id) {
|
|
68
|
+
return await hostCallFn()("readRadio", { ...id ? { id } : {} });
|
|
69
|
+
},
|
|
70
|
+
async setRadioConnection(args) {
|
|
71
|
+
return await hostCallFn()("setRadioConnection", { ...args });
|
|
72
|
+
},
|
|
73
|
+
async tuneRadio(args) {
|
|
74
|
+
return await hostCallFn()("tuneRadio", { ...args });
|
|
75
|
+
},
|
|
63
76
|
async fetch(url, options) {
|
|
64
77
|
return await hostCallFn()("fetch", { url, ...options });
|
|
65
78
|
},
|
package/dist/svgScene.js
ADDED
|
File without changes
|
package/dist/templateContext.js
CHANGED
|
@@ -60,6 +60,7 @@ function qsoValues(qso) {
|
|
|
60
60
|
const their = qso.their ?? {};
|
|
61
61
|
const our = qso.our ?? {};
|
|
62
62
|
const startMillis = Number(qso.startAtMillis ?? 0);
|
|
63
|
+
const serialSent = refField(qso.refs, "ourSerial");
|
|
63
64
|
return {
|
|
64
65
|
call: their.call ?? "",
|
|
65
66
|
their,
|
|
@@ -71,12 +72,31 @@ function qsoValues(qso) {
|
|
|
71
72
|
// sides — `qso.our.sent` is what WE sent, i.e. the report they received.
|
|
72
73
|
rstSent: our.sent ?? "",
|
|
73
74
|
rstRcvd: their.sent ?? "",
|
|
75
|
+
// Unpadded, as stored: `001` or `1` is the operator's call, not the
|
|
76
|
+
// contest's, so it is left to the `pad` filter.
|
|
77
|
+
serialSent,
|
|
78
|
+
serialRcvd: refField(qso.refs, "theirSerial"),
|
|
79
|
+
// The short forms are the SENT side: a CW message is what we send, and
|
|
80
|
+
// that is where these get typed.
|
|
81
|
+
rst: our.sent ?? "",
|
|
82
|
+
serial: serialSent,
|
|
74
83
|
notes: qso.notes ?? "",
|
|
75
84
|
refs: qso.refs ?? [],
|
|
76
85
|
...startMillis > 0 ? dateValues(startMillis) : { date: "", dateCompact: "", time: "", at: "" },
|
|
77
86
|
startAtMillis: startMillis
|
|
78
87
|
};
|
|
79
88
|
}
|
|
89
|
+
function refField(refs, field) {
|
|
90
|
+
if (!Array.isArray(refs)) return "";
|
|
91
|
+
for (const ref of refs) {
|
|
92
|
+
if (ref === null || typeof ref !== "object" || Array.isArray(ref)) continue;
|
|
93
|
+
const value = ref[field];
|
|
94
|
+
if (typeof value !== "string" && typeof value !== "number") continue;
|
|
95
|
+
const text = String(value).trim();
|
|
96
|
+
if (text !== "") return text;
|
|
97
|
+
}
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
80
100
|
function logValues(values) {
|
|
81
101
|
return {
|
|
82
102
|
station: values.station ?? "",
|
package/dist/templates.js
CHANGED
|
@@ -30,7 +30,16 @@ function registerFilters(liquid2) {
|
|
|
30
30
|
(value) => String(value ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")
|
|
31
31
|
);
|
|
32
32
|
liquid2.registerFilter("alnum", (value) => String(value ?? "").replace(/[^A-Za-z0-9]/g, ""));
|
|
33
|
+
liquid2.registerFilter("pad", (value, width = 3) => {
|
|
34
|
+
const text = String(value ?? "").trim();
|
|
35
|
+
return text === "" ? "" : text.padStart(Number(width) || 0, "0");
|
|
36
|
+
});
|
|
37
|
+
liquid2.registerFilter("cut", (value, digits = "09") => {
|
|
38
|
+
const chosen = String(digits ?? "");
|
|
39
|
+
return String(value ?? "").replace(/[0159]/g, (digit) => chosen.includes(digit) ? CUT_NUMBERS[digit] : digit);
|
|
40
|
+
});
|
|
33
41
|
}
|
|
42
|
+
const CUT_NUMBERS = { "0": "T", "1": "A", "5": "E", "9": "N" };
|
|
34
43
|
class TemplateError extends Error {
|
|
35
44
|
constructor(message, options) {
|
|
36
45
|
super(message);
|
package/docs/hooks.md
CHANGED
|
@@ -1237,6 +1237,7 @@ own identity, config form and refresh budget.
|
|
|
1237
1237
|
interface PanelHook {
|
|
1238
1238
|
getPanels(args: {}, ctx): Promise<PanelDescriptor[]>
|
|
1239
1239
|
render(args: PanelRenderArgs, ctx): Promise<PanelContent>
|
|
1240
|
+
onEvent?(args: PanelRenderArgs & {event: PanelSceneEvent}, ctx): Promise<PanelSceneEventResult>
|
|
1240
1241
|
}
|
|
1241
1242
|
// PanelDescriptor: {key, title, description?, icon?, preview?, on?, form?}
|
|
1242
1243
|
// PanelRenderArgs: {panelKey, operation, qso?, qsoCount, config, reason}
|
|
@@ -1247,6 +1248,101 @@ The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
|
|
|
1247
1248
|
inside saved layouts — renaming a `key` drops the panel out of every
|
|
1248
1249
|
arrangement holding it.
|
|
1249
1250
|
|
|
1251
|
+
An experimental fourth kind, `svgScene`, adds native SVG layers, numeric bindings,
|
|
1252
|
+
local controls, and host-run animations. Its payload is `{kind: 'svgScene', scene,
|
|
1253
|
+
title?, triggers?}`; it does not have a `content` string. Scene-capable hosts pass
|
|
1254
|
+
`instanceId` on render/event calls. `onEvent` handles control events and returns
|
|
1255
|
+
numeric value patches; local-only controls and animation frames make no bridge
|
|
1256
|
+
calls. Controls with `continuous: true` send `change` events during dragging and a
|
|
1257
|
+
final `commit` on release; pending movements are coalesced to the newest value.
|
|
1258
|
+
Numeric text supports `scale`, `truncate`, `modulo`, and `minIntegerDigits`
|
|
1259
|
+
for local numeric formatting. The Radio Panel Example uses these for its Modern
|
|
1260
|
+
frequency groups; its settings offer Modern/LCD styles and radio selection. Local sliders may use `hover` and `discrete` hourly buckets. Text `samples`
|
|
1261
|
+
may hold numbers or preformatted strings; control `valueLabels` supplies accessible
|
|
1262
|
+
value descriptions. Transform/opacity bindings may also supply numeric `samples`,
|
|
1263
|
+
equally spaced over their `input` range, instead of the `output` ramp. The host
|
|
1264
|
+
interpolates and clamps these locally; chart markers and callouts can follow
|
|
1265
|
+
individual readings without bridge calls. See [the prototype contract and migration plan](https://github.com/ham2k/halo/blob/main/docs/design/svg-scenes.md)
|
|
1266
|
+
and the `k2hrc-svg-scenes` sample. This API is experimental.
|
|
1267
|
+
|
|
1268
|
+
Scene-capable hosts also supply `args.environment` on **render and event** calls:
|
|
1269
|
+
|
|
1270
|
+
- `width`, `height`, and `safeInsets` describe the current panel in logical pixels.
|
|
1271
|
+
The app's Font Scale is already applied to this coordinate space. Do not multiply
|
|
1272
|
+
coordinates by a font-scale preference or `devicePixelRatio`.
|
|
1273
|
+
- `brightness` is resolved to `light` or `dark`, including system theme changes.
|
|
1274
|
+
`colors` contains `surface`, `surfaceContainer`, `onSurface`, `onSurfaceVariant`,
|
|
1275
|
+
`accent`, `primary`, `onPrimary`, `secondary`, `outline`, `outlineVariant`,
|
|
1276
|
+
`error`, and `onError`, all `#RRGGBB` strings.
|
|
1277
|
+
- `typography` contains `body`, `label`, `title`, `display`, and `mono` roles, each
|
|
1278
|
+
with `fontFamily`, fallbacks, `fontSize`, `scaledFontSize`, `fontWeight`,
|
|
1279
|
+
`lineHeight`, and `letterSpacing`. Family can be null for a platform default.
|
|
1280
|
+
`scaledFontSize` describes OS accessibility scaling **at that role's size**;
|
|
1281
|
+
do not assume one linear factor for all sizes. Use it when reserving label space.
|
|
1282
|
+
Supply the unscaled `fontSize` as scene text `size`: the host scales text once.
|
|
1283
|
+
- `locale`, `textDirection`, `devicePixelRatio`, `reducedMotion`, and `highContrast`
|
|
1284
|
+
describe the resolved presentation context. Font names refer to host fonts;
|
|
1285
|
+
they are not URLs or permission to load remote fonts.
|
|
1286
|
+
|
|
1287
|
+
For a panel showing an `svgScene`, environment changes automatically request a
|
|
1288
|
+
render through the existing throttle, even with no triggers declared. Hidden
|
|
1289
|
+
panels catch up on reveal; repeated resize updates coalesce; a scene laid out for
|
|
1290
|
+
an old environment cannot overwrite a newer one. Markdown and HTML panels are
|
|
1291
|
+
themed and laid out by the host, so an environment change does not re-render
|
|
1292
|
+
them; their next render carries the current environment.
|
|
1293
|
+
`args.clock.nowMillis` follows the app clock; `realNowMillis` is for network retry
|
|
1294
|
+
and cache budgets, so developer time travel does not hammer an external API.
|
|
1295
|
+
Both `environment` and `clock` are optional in the SDK for older-host detection.
|
|
1296
|
+
|
|
1297
|
+
Use scene **text layers** for typography; SVG `<text>` is not a portable text path
|
|
1298
|
+
through the renderer. Text supports either `literal` or bound `value`, plus
|
|
1299
|
+
`fontFamily`, `fontWeight`, `lineHeight`, and `letterSpacing`. Text participates
|
|
1300
|
+
in native directionality, accessibility scaling and semantics.
|
|
1301
|
+
Button controls can supply `menu: [{label, event}]` to open a native dropdown.
|
|
1302
|
+
Selecting an item emits its `event` as the action with the original control ID;
|
|
1303
|
+
dismissing the menu emits nothing.
|
|
1304
|
+
|
|
1305
|
+
The `svg-weather`, `svg-solar`, and `svg-radio` reference panels live in
|
|
1306
|
+
[ham2k/extensions](https://github.com/ham2k/extensions/tree/main/extensions/dashboard).
|
|
1307
|
+
They use public host APIs and ship as `.h2kext` packages, not app assets.
|
|
1308
|
+
Install the packages, then add their panels through Edit Layout. Their original
|
|
1309
|
+
extension and panel keys are retained so existing placements and settings survive.
|
|
1310
|
+
See that repository’s dashboard README for building against the unreleased SDK.
|
|
1311
|
+
|
|
1312
|
+
The radio host calls are manifest capabilities, refused unless declared — the
|
|
1313
|
+
same shape as `requiresLocation`, and shown to the operator at install:
|
|
1314
|
+
`"requiresRadioRead": true` allows `host.listRadios()` and `host.readRadio()`;
|
|
1315
|
+
`"requiresRadioWrite": true` allows `host.tuneRadio()` and
|
|
1316
|
+
`host.setRadioConnection()`, and implies read, since a command answers with the
|
|
1317
|
+
radio's state. An undeclared call rejects rather than answering null.
|
|
1318
|
+
|
|
1319
|
+
**Radio Panel Example** is the CAT reference extension. `host.listRadios()` lists configured
|
|
1320
|
+
local transceivers. `host.readRadio(id?)` returns
|
|
1321
|
+
a **local** radio's reported frequency (Hz), mode, connection state,
|
|
1322
|
+
power, TX state and fresh telemetry, or null on hosts without this API.
|
|
1323
|
+
`meters.signalDbm` is receive power in whole dBm when supported, refreshed a few
|
|
1324
|
+
times a second; it expires after two seconds without a reading. The Flex driver receives the selected slice’s LEVEL
|
|
1325
|
+
meter through its native UDP channel. The reference panel draws a segmented
|
|
1326
|
+
receive meter and leaves it unlit when unavailable, stale, or transmitting. It does
|
|
1327
|
+
not substitute the app's manually entered VFO for a radio reading.
|
|
1328
|
+
`host.tuneRadio({id, frequencyHz?, mode?})` targets that same radio through the
|
|
1329
|
+
existing VFO/CAT service. Supported mode requests are LSB, USB, CW, AM and FM.
|
|
1330
|
+
The host rejects a changed designation, disconnected/stale radio, transmission
|
|
1331
|
+
in progress or malformed command, and spaces accepted tuning requests by at
|
|
1332
|
+
least 250 ms. Omitting a selection follows the designated logging radio; an explicit `selection`
|
|
1333
|
+
pins tuning to that local radio without changing the logging designation.
|
|
1334
|
+
An accepted result means requested, not confirmed; only subsequent
|
|
1335
|
+
reported state confirms it. Drivers retain their own protocol/echo handling.
|
|
1336
|
+
`host.setRadioConnection({id, selection?, connected})` starts or ends a local CAT
|
|
1337
|
+
connection using the same selection and identity checks. Acceptance starts the
|
|
1338
|
+
operation; follow reported connection state for completion. It does not switch
|
|
1339
|
+
the hardware's mains power or initiate transmission. The reference panel samples
|
|
1340
|
+
state once per second while visible; knob motion and LCD digit updates run locally,
|
|
1341
|
+
and tuning is sent continuously during dragging, paced by the CAT adapter. The
|
|
1342
|
+
main readout follows the requested value immediately; confirmation status distinguishes pending requests from the radio
|
|
1343
|
+
reporting the requested frequency. Radio-specific hardware verification remains
|
|
1344
|
+
necessary before considering the experimental control API stable.
|
|
1345
|
+
|
|
1250
1346
|
**Content is a document, not widgets** — the same UI-catalog seam every
|
|
1251
1347
|
other declarative hook sits behind. Prefer `markdown`: it renders with the
|
|
1252
1348
|
app's own typography, theme, font scale and density, and costs no web view.
|
package/docs/templates.md
CHANGED
|
@@ -74,12 +74,22 @@ that contact's length.
|
|
|
74
74
|
|
|
75
75
|
### `qso` — one contact
|
|
76
76
|
`call`, `their`, `our` (both whole, so `qso.their.guess.name` reaches the
|
|
77
|
-
lookup's answer), `band`, `mode`, `freq`, `rstSent`, `rstRcvd`, `
|
|
78
|
-
`date`, `dateCompact`, `time`, `at`, `startAtMillis`,
|
|
77
|
+
lookup's answer), `band`, `mode`, `freq`, `rstSent`, `rstRcvd`, `serialSent`,
|
|
78
|
+
`serialRcvd`, `notes`, `date`, `dateCompact`, `time`, `at`, `startAtMillis`,
|
|
79
|
+
`refs` — and two short forms for the sent side, `rst` (= `rstSent`) and
|
|
80
|
+
`serial` (= `serialSent`), since a CW message is what we send and that is
|
|
81
|
+
where these get typed: `{{ qso.call }} {{ qso.rst | cut }} {{ qso.serial | pad: 3 | cut }}`.
|
|
79
82
|
|
|
80
83
|
`rstSent` is what WE sent — QSON stores each side's report under its own
|
|
81
84
|
`sent`, and these are named for the operator's view of the contact.
|
|
82
85
|
|
|
86
|
+
`serialSent` / `serialRcvd` are a contest's serial numbers, blank outside
|
|
87
|
+
one. A contest keeps them on its own ref, so they are found by field name —
|
|
88
|
+
the first ref carrying `ourSerial` / `theirSerial` — which is what lets one
|
|
89
|
+
message serve every serial contest (docs/design/contests.md §5.8). In a CW
|
|
90
|
+
message `serialSent` is the number the serial field is SHOWING, the one the
|
|
91
|
+
QSO will be logged with. It is unpadded (`7`, not `007`): use `pad`.
|
|
92
|
+
|
|
83
93
|
### `config` — a panel's own form values
|
|
84
94
|
Whatever that panel's `form` declared, under the keys it used.
|
|
85
95
|
|
|
@@ -112,12 +122,19 @@ with nothing to say it was invented.
|
|
|
112
122
|
|
|
113
123
|
All of Liquid's own (`date`, `downcase`, `upcase`, `strip`, `default`,
|
|
114
124
|
`join`, `size`, `first`, `last`, `round`, `truncate`, `replace`, `map`,
|
|
115
|
-
`where`, …), plus
|
|
125
|
+
`where`, …), plus four:
|
|
116
126
|
|
|
117
127
|
| filter | does | example |
|
|
118
128
|
|---|---|---|
|
|
119
129
|
| `dash` | non-alphanumerics → `-`, collapsed and trimmed, **case preserved** | `N0CALL/P` → `N0CALL-P` |
|
|
120
130
|
| `alnum` | strip to alphanumerics | `2026-07-27` → `20260727` |
|
|
131
|
+
| `pad: n` | zero-pad on the left to `n` characters (default 3); **blank stays blank** | `7` → `007` |
|
|
132
|
+
| `cut: "digits"` | CW cut numbers for the listed digits, of `0`→`T` `1`→`A` `5`→`E` `9`→`N`; default `"09"` | `599` → `5NN`, `{{ 9 \| pad: 3 \| cut }}` → `TTN` |
|
|
133
|
+
|
|
134
|
+
`pad` leaves a blank alone because a CW message can be keyed before the
|
|
135
|
+
serial field has a number, and `000` sent in that gap is a serial the log
|
|
136
|
+
will never agree with. Order matters with `cut`: pad first, or the padding
|
|
137
|
+
zeros go out uncut.
|
|
121
138
|
|
|
122
139
|
`alnum` is app-polo's `compact` helper under a different name: Liquid already
|
|
123
140
|
has a `compact` (it drops nils from an **array**, and `op.refs`/`qso.refs` are
|
|
@@ -140,6 +157,10 @@ Each of these was measured against the shipped runtime, not assumed.
|
|
|
140
157
|
number as SECONDS, so epoch millis render in the year 58567. Use the `…At`
|
|
141
158
|
strings.
|
|
142
159
|
|
|
160
|
+
**`cut`'s digit list must be quoted.** Liquid reads a bare `0159` as the
|
|
161
|
+
number 159 before the filter sees it, so `{{ n | cut: 09 }}` cuts only the
|
|
162
|
+
nines — the zero is gone with no error. Write `cut: "09"`.
|
|
163
|
+
|
|
143
164
|
**Unknown names are silent.** An unknown variable renders empty and an
|
|
144
165
|
unknown filter passes its value through. That is deliberate — an operator's
|
|
145
166
|
typo costs one line, not the document — but it means a misspelled
|
package/package.json
CHANGED
package/samples/README.md
CHANGED
|
@@ -10,6 +10,7 @@ building — the header comment in each `src/index.ts` says what it leaves out.
|
|
|
10
10
|
| `k2hrc-llota` | An award program: references, an offline list, activation scoring |
|
|
11
11
|
| `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
|
|
12
12
|
| `k2hrc-radio` | An HTML panel, and why you should probably write markdown instead |
|
|
13
|
+
| `k2hrc-svg-scenes` | Experimental SVG controls and animations, with simulated radio and weather panels |
|
|
13
14
|
|
|
14
15
|
## Running one
|
|
15
16
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SVG Scene Prototypes
|
|
2
|
+
|
|
3
|
+
Experimental radio front face and weather inspection chart, using the public
|
|
4
|
+
`svgScene` / `PanelHook.onEvent` contracts. Requires a host with SVG scene support.
|
|
5
|
+
All data is simulated; this extension does not fetch weather or control a radio.
|
|
6
|
+
|
|
7
|
+
The radio keeps requested frequency separate from reported frequency, simulates
|
|
8
|
+
2-second CAT acknowledgments, and has buttons for external tuning, disconnection,
|
|
9
|
+
and changing acknowledgment delay. Drag the knob horizontally/upward, use the band
|
|
10
|
+
slider, or focus either control and use arrows (Shift for larger steps) / the wheel.
|
|
11
|
+
|
|
12
|
+
The weather chart demonstrates local-only cursor and numeric sample inspection.
|
|
13
|
+
Drag, click, or use arrows; no interaction events cross the extension bridge.
|
|
14
|
+
|
|
15
|
+
In this repository:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
node build.mjs
|
|
19
|
+
node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-svg-scenes.h2kext
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For the standalone Flutter preview and migration plan, see
|
|
23
|
+
`docs/design/svg-scenes.md` in the logger repository. This is an API experiment,
|
|
24
|
+
not a replacement for the production weather/solar panels yet.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "k2hrc-svg-scenes",
|
|
3
|
+
"name": "SVG Scene Prototypes",
|
|
4
|
+
"shortName": "SVG Scenes",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "Experimental radio front face and weather chart; simulated data only",
|
|
7
|
+
"category": "dashboard",
|
|
8
|
+
"icon": "radio-tower",
|
|
9
|
+
"accentColor": "#4C6EF5",
|
|
10
|
+
"api": 1,
|
|
11
|
+
"keywords": [
|
|
12
|
+
"svg",
|
|
13
|
+
"panel",
|
|
14
|
+
"sample",
|
|
15
|
+
"weather",
|
|
16
|
+
"radio"
|
|
17
|
+
],
|
|
18
|
+
"hooks": [
|
|
19
|
+
"panel"
|
|
20
|
+
],
|
|
21
|
+
"sharedDependencies": {
|
|
22
|
+
"@ham2k/lib-callsigns": "^1.0.0",
|
|
23
|
+
"@ham2k/lib-country-files": "^1.0.0",
|
|
24
|
+
"@ham2k/lib-dxcc-data": "^1.0.0",
|
|
25
|
+
"@ham2k/lib-format-tools": "^1.0.0",
|
|
26
|
+
"@ham2k/lib-geo-tools": "^1.0.0",
|
|
27
|
+
"@ham2k/lib-operation-data": "^1.0.0",
|
|
28
|
+
"i18next": "^23.0.0",
|
|
29
|
+
"liquidjs": "^10.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { defineExtension } from '@ham2k/extension-sdk'
|
|
4
|
+
import manifest from '../manifest.json' with { type: 'json' }
|
|
5
|
+
import { createScenePanels } from './scenes.ts'
|
|
6
|
+
|
|
7
|
+
defineExtension({
|
|
8
|
+
...manifest,
|
|
9
|
+
onActivation({ registerHook }) {
|
|
10
|
+
registerHook('panel', { key: manifest.key, hook: createScenePanels() })
|
|
11
|
+
},
|
|
12
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from '@ham2k/extension-sdk'
|
|
4
|
+
|
|
5
|
+
const svg = (body: string, width = 640, height = 360) =>
|
|
6
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
|
|
7
|
+
const bind = (value: string, input: [number, number], output: [number, number]): SceneBinding => ({ value, input, output })
|
|
8
|
+
const label = (x: number, y: number, text: string, size = 16, color = '#9aaec6') =>
|
|
9
|
+
`<text x="${x}" y="${y}" fill="${color}" font-family="sans-serif" font-size="${size}">${text}</text>`
|
|
10
|
+
const button = (x: number, text: string) => `<rect x="${x}" y="298" width="172" height="38" rx="8" fill="#283b52"/>${label(x + 12, 322, text, 14, '#e7edf4')}`
|
|
11
|
+
const textLayer = (id: string, value: string, x: number, y: number, width: number,
|
|
12
|
+
size: number, prefix = '', suffix = '', decimals = 0): SvgSceneLayer =>
|
|
13
|
+
({ id, x, y, width, height: size + 12, text: { value, prefix, suffix, decimals, size, color: '#a9efcf' } })
|
|
14
|
+
|
|
15
|
+
type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
|
|
16
|
+
/** Inject time for deterministic tests; no timers or per-frame extension work. */
|
|
17
|
+
export function createScenePanels(now: () => number = () => Date.now()): PanelHook {
|
|
18
|
+
const radios = new Map<string, RadioState>()
|
|
19
|
+
function state(id: string): RadioState {
|
|
20
|
+
let s = radios.get(id)
|
|
21
|
+
if (!s) {
|
|
22
|
+
// Bounded session-only simulator state; no storage or CAT commands.
|
|
23
|
+
if (radios.size >= 64) radios.delete(radios.keys().next().value!)
|
|
24
|
+
s = { requested: 14074, reported: 14074, connected: 1, due: 0, slow: 1 }
|
|
25
|
+
radios.set(id, s)
|
|
26
|
+
}
|
|
27
|
+
if (s.connected && s.due && now() >= s.due) { s.reported = s.requested; s.due = 0 }
|
|
28
|
+
return s
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
async getPanels() {
|
|
32
|
+
return [
|
|
33
|
+
{ key: 'radio', title: 'Radio Front Face · Simulation', icon: 'radio-tower', multiple: true, on: ['tick:1'] },
|
|
34
|
+
{ key: 'weather', title: 'Weather Chart · Simulation', icon: 'weather-partly-cloudy', multiple: true },
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
async render(args): Promise<PanelContent> {
|
|
38
|
+
if (args.panelKey === 'weather') {
|
|
39
|
+
const temperatures = [17, 18, 21, 24, 25, 23, 20, 18]
|
|
40
|
+
const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(' ')
|
|
41
|
+
return { kind: 'svgScene', scene: {
|
|
42
|
+
version: 1, width: 640, height: 360, values: { hour: 0 },
|
|
43
|
+
layers: [
|
|
44
|
+
{ id: 'chart', x: 0, y: 0, width: 640, height: 360, svg: svg(`
|
|
45
|
+
<rect width="640" height="360" rx="18" fill="#142337"/>
|
|
46
|
+
${label(30, 38, 'FORECAST / SIMULATED DATA', 18, '#e7edf4')}
|
|
47
|
+
${[15, 20, 25].map(t => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join('')}
|
|
48
|
+
<polyline points="${points}" fill="none" stroke="#8bdccc" stroke-width="4"/>
|
|
49
|
+
${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288, '+' + i + 'h', 12)}`).join('')}
|
|
50
|
+
${label(30, 334, 'Drag the chart to inspect · Arrow keys to step', 14)}
|
|
51
|
+
`) },
|
|
52
|
+
{ id: 'cursor', x: 59, y: 100, width: 2, height: 172,
|
|
53
|
+
svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
|
|
54
|
+
translateX: bind('hour', [0, 7], [0, 518]) },
|
|
55
|
+
textLayer('hour', 'hour', 30, 52, 250, 24, 'In ', ' hours'),
|
|
56
|
+
{ ...textLayer('temperature', 'hour', 370, 52, 240, 24, '', ' °C'),
|
|
57
|
+
text: { value: 'hour', samples: temperatures, suffix: ' °C', size: 24, color: '#a9efcf' } },
|
|
58
|
+
],
|
|
59
|
+
controls: [{ id: 'inspect', kind: 'slider', label: 'Forecast hour', x: 60, y: 100, width: 518, height: 185,
|
|
60
|
+
value: 'hour', min: 0, max: 7, step: 1 }],
|
|
61
|
+
} }
|
|
62
|
+
}
|
|
63
|
+
const s = state(args.instanceId ?? args.panelKey)
|
|
64
|
+
return { kind: 'svgScene', scene: {
|
|
65
|
+
version: 1, width: 640, height: 360,
|
|
66
|
+
values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
|
|
67
|
+
layers: [
|
|
68
|
+
{ id: 'face', x: 0, y: 0, width: 640, height: 360, svg: svg(`
|
|
69
|
+
<rect width="640" height="360" rx="18" fill="#142337"/>
|
|
70
|
+
${label(28, 35, 'RADIO FRONT FACE / SIMULATED CAT', 18, '#e7edf4')}
|
|
71
|
+
<rect x="28" y="58" width="350" height="114" rx="12" fill="#0a1622"/>
|
|
72
|
+
${label(42, 78, 'REPORTED VFO', 12)}${label(42, 143, 'REQUESTED', 12)}
|
|
73
|
+
<path d="M417 150 A90 90 0 0 1 597 150" fill="none" stroke="#52657d" stroke-width="5"/>
|
|
74
|
+
${label(423, 170, '14.0', 12)}${label(565, 170, '14.35', 12)}
|
|
75
|
+
${label(424, 197, 'VFO POSITION', 12)}
|
|
76
|
+
<path d="M42 236 H332" stroke="#52657d" stroke-width="6"/>
|
|
77
|
+
${label(42, 268, 'Band position / kHz', 14)}
|
|
78
|
+
${label(548, 256, 'LINK', 12)}
|
|
79
|
+
${button(28, 'Connect / disconnect')}${button(234, 'External tune +5 kHz')}${button(440, 'Toggle 2 s / 0 s delay')}
|
|
80
|
+
`) },
|
|
81
|
+
textLayer('reported', 'reported', 42, 78, 320, 34, '', ' kHz', 1),
|
|
82
|
+
textLayer('requested', 'requested', 168, 121, 210, 22, '', '', 1),
|
|
83
|
+
{ id: 'needle', x: 503, y: 72, width: 8, height: 80, pivot: [0.5, 1], transitionMs: 450,
|
|
84
|
+
svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
|
|
85
|
+
rotation: bind('reported', [14000, 14350], [-80, 80]) },
|
|
86
|
+
{ id: 'knob', x: 400, y: 204, width: 72, height: 72,
|
|
87
|
+
svg: svg('<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>', 72, 72),
|
|
88
|
+
rotation: bind('requested', [14000, 14350], [-150, 150]) },
|
|
89
|
+
{ id: 'handle', x: 36, y: 224, width: 12, height: 24,
|
|
90
|
+
svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
|
|
91
|
+
translateX: bind('requested', [14000, 14350], [0, 290]) },
|
|
92
|
+
{ id: 'led', x: 548, y: 208, width: 40, height: 40,
|
|
93
|
+
svg: svg('<defs><radialGradient id="glow"><stop stop-color="#a9ffbd"/><stop offset="0.3" stop-color="#58e8a2"/><stop offset="1" stop-color="#58e8a2" stop-opacity="0"/></radialGradient></defs><circle cx="20" cy="20" r="20" fill="url(#glow)"/>', 40, 40),
|
|
94
|
+
opacity: bind('connected', [0, 1], [0.08, 1]), pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true } },
|
|
95
|
+
],
|
|
96
|
+
controls: [
|
|
97
|
+
{ id: 'tune', label: 'Tuning knob, kHz', kind: 'knob', x: 398, y: 202, width: 76, height: 76,
|
|
98
|
+
value: 'requested', min: 14000, max: 14350, step: 0.1, sensitivity: 0.1, event: 'tune' },
|
|
99
|
+
{ id: 'band', label: 'Band position, kHz', kind: 'slider', x: 42, y: 215, width: 290, height: 42,
|
|
100
|
+
value: 'requested', min: 14000, max: 14350, step: 0.1, event: 'tune' },
|
|
101
|
+
...(['connect', 'external', 'delay'] as const).map((event, i) => ({
|
|
102
|
+
id: event, label: ['Toggle connection', 'Simulate external tuning', 'Toggle CAT delay'][i],
|
|
103
|
+
kind: 'button' as const, x: 28 + i * 206, y: 298, width: 172, height: 38, event,
|
|
104
|
+
})),
|
|
105
|
+
],
|
|
106
|
+
} }
|
|
107
|
+
},
|
|
108
|
+
async onEvent(args) {
|
|
109
|
+
const s = state(args.instanceId ?? args.panelKey)
|
|
110
|
+
const event = args.event
|
|
111
|
+
if (event.action === 'connect') { s.connected = 1 - s.connected; s.due = 0; s.requested = s.reported }
|
|
112
|
+
if (event.action === 'delay') s.slow = 1 - s.slow
|
|
113
|
+
if (event.action === 'external') {
|
|
114
|
+
if (!s.connected) throw new Error('Radio disconnected')
|
|
115
|
+
s.reported = s.reported + 5 > 14350 ? 14000 : s.reported + 5
|
|
116
|
+
s.requested = s.reported; s.due = 0
|
|
117
|
+
}
|
|
118
|
+
if (event.action === 'tune') {
|
|
119
|
+
if (!s.connected) throw new Error('Radio disconnected')
|
|
120
|
+
if (typeof event.value !== 'number' || !Number.isFinite(event.value)) throw new Error('Invalid frequency')
|
|
121
|
+
s.requested = Math.max(14000, Math.min(14350, event.value))
|
|
122
|
+
s.due = now() + (s.slow ? 2000 : 0)
|
|
123
|
+
if (!s.slow) { s.reported = s.requested; s.due = 0 }
|
|
124
|
+
}
|
|
125
|
+
return { values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow } }
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|