@ham2k/extension-sdk 0.9.1 → 0.10.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 +173 -17
- package/dist/index.js +9 -1
- package/dist/nearby.js +10 -0
- package/dist/referenceActivity.js +4 -3
- package/docs/distribution.md +86 -24
- package/docs/hooks.md +265 -25
- package/package.json +4 -4
- package/samples/README.md +1 -1
- package/samples/{k2hrc-svg-scenes → k2hrc-panel-scenes}/README.md +5 -5
- package/samples/{k2hrc-svg-scenes → k2hrc-panel-scenes}/manifest.json +5 -5
- package/samples/{k2hrc-svg-scenes → k2hrc-panel-scenes}/src/scenes.ts +123 -5
- /package/dist/{svgScene.js → panelScene.js} +0 -0
- /package/samples/{k2hrc-svg-scenes → k2hrc-panel-scenes}/build.mjs +0 -0
- /package/samples/{k2hrc-svg-scenes → k2hrc-panel-scenes}/src/index.ts +0 -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), `k2hrc-
|
|
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-panel-scenes` (experimental interactive panel scene) |
|
|
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,12 +1,17 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.
|
|
2
|
-
/** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
|
|
3
|
-
export interface
|
|
1
|
+
// @ham2k/extension-sdk 0.10.1
|
|
2
|
+
/** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
|
|
3
|
+
export interface PanelScene {
|
|
4
4
|
version: 1;
|
|
5
5
|
width: number;
|
|
6
6
|
height: number;
|
|
7
7
|
values: Record<string, number>;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/** String state for text fields and choices. A key is in `values` or here, never both. At most 256, each up to 4096 characters. */
|
|
9
|
+
strings?: Record<string, string>;
|
|
10
|
+
/** Painted in order, each over the ones before. */
|
|
11
|
+
layers: PanelSceneLayer[];
|
|
12
|
+
controls?: PanelSceneControl[];
|
|
13
|
+
/** Where rect-less native controls and the artwork go. Omitted, the artwork fills the panel. */
|
|
14
|
+
layout?: PanelSceneLayoutNode;
|
|
10
15
|
}
|
|
11
16
|
/** A clamped, linear mapping. No expressions or extension code run per frame. */
|
|
12
17
|
export interface SceneBinding {
|
|
@@ -26,7 +31,7 @@ export interface SceneBinding {
|
|
|
26
31
|
/** Optional equally spaced output samples over input; replaces the output ramp. */
|
|
27
32
|
samples?: number[];
|
|
28
33
|
}
|
|
29
|
-
export interface
|
|
34
|
+
export interface PanelSceneLayer {
|
|
30
35
|
id: string;
|
|
31
36
|
x: number;
|
|
32
37
|
y: number;
|
|
@@ -34,7 +39,12 @@ export interface SvgSceneLayer {
|
|
|
34
39
|
height: number;
|
|
35
40
|
/** Supply exactly one of svg or text. SVG must be self-contained. */
|
|
36
41
|
svg?: string;
|
|
37
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Take the clicks that land on this layer. Off by default: layers are click-through, so
|
|
44
|
+
* artwork painted over a control does not stop it working. On, it covers the controls under it.
|
|
45
|
+
*/
|
|
46
|
+
blocksPointer?: boolean;
|
|
47
|
+
/** Exactly one of literal/value. A value naming a string shows it as-is. Use text layers, not SVG <text>, for font support.
|
|
38
48
|
* Size is unscaled; the host applies OS text scaling once. */
|
|
39
49
|
text?: {
|
|
40
50
|
value?: string;
|
|
@@ -75,24 +85,77 @@ export interface SvgSceneLayer {
|
|
|
75
85
|
flicker?: boolean;
|
|
76
86
|
};
|
|
77
87
|
}
|
|
78
|
-
|
|
88
|
+
/**
|
|
89
|
+
* `button`, `slider` and `knob` are invisible hit areas over the extension's own artwork.
|
|
90
|
+
* The `native…` kinds are Material widgets, themed by the app:
|
|
91
|
+
*
|
|
92
|
+
* - `nativeButton`: sends `activate`. `variant`, `icon`, and optionally `menu`.
|
|
93
|
+
* - `nativeSlider`: a number, between `min` and `max` in `step`s.
|
|
94
|
+
* - `nativeTextField`: a string. Commits on Enter, or on leaving the field after typing.
|
|
95
|
+
* - `nativeDropdown`, `nativeRadio`, `nativeSegmented`: a string, one of `options`.
|
|
96
|
+
* A `multi` segmented control holds its chosen values joined with commas, in option order.
|
|
97
|
+
* - `nativeSwitch`, `nativeCheckbox`: a number, 1 or 0. Shows `label` beside it.
|
|
98
|
+
* - `nativeChip`: with `value`, a toggle (1 or 0); without, an action that sends `activate`.
|
|
99
|
+
* - `nativeText`: shows `label`, or its bound value. Sends nothing.
|
|
100
|
+
*
|
|
101
|
+
* `label` is every control's accessible name. There are no tooltips.
|
|
102
|
+
*/
|
|
103
|
+
export type PanelSceneControlKind = "button" | "slider" | "knob" | "nativeButton" | "nativeSlider" | "nativeTextField" | "nativeDropdown" | "nativeSwitch" | "nativeCheckbox" | "nativeRadio" | "nativeSegmented" | "nativeChip" | "nativeText";
|
|
104
|
+
export interface PanelSceneControl {
|
|
79
105
|
id: string;
|
|
80
106
|
label: string;
|
|
81
|
-
kind:
|
|
107
|
+
kind: PanelSceneControlKind;
|
|
82
108
|
/** A button can open a native dropdown. Picking an item sends its event as the action. */
|
|
83
109
|
menu?: {
|
|
84
110
|
label: string;
|
|
85
111
|
event: string;
|
|
86
112
|
}[];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
/**
|
|
114
|
+
* The control's rect over the artwork, in scene units. Required for the drawn kinds.
|
|
115
|
+
* A native control omits all four to be placed by `layout` instead.
|
|
116
|
+
*/
|
|
117
|
+
x?: number;
|
|
118
|
+
y?: number;
|
|
119
|
+
width?: number;
|
|
120
|
+
height?: number;
|
|
121
|
+
/**
|
|
122
|
+
* With a rect: the id of the layer this control paints directly after, so later layers draw
|
|
123
|
+
* over it. Omitted, the control paints above every layer.
|
|
124
|
+
*/
|
|
125
|
+
after?: string;
|
|
91
126
|
/** Omit event for local-only interaction (for example a chart inspection cursor). */
|
|
92
127
|
event?: string;
|
|
93
|
-
/**
|
|
128
|
+
/** `slider`, `knob`, `nativeSlider` and `nativeTextField` only: send coalesced change events while dragging or typing, plus the final commit. */
|
|
94
129
|
continuous?: boolean;
|
|
130
|
+
/**
|
|
131
|
+
* The name of the value this control shows and sets: in `values`, or in `strings` for a string kind.
|
|
132
|
+
* A choice's string must be `""` or one of its `options` (each part, for `multi`). `nativeButton` takes none.
|
|
133
|
+
*/
|
|
95
134
|
value?: string;
|
|
135
|
+
/** Choices for `nativeDropdown`, `nativeRadio` and `nativeSegmented`. Values are unique. */
|
|
136
|
+
options?: {
|
|
137
|
+
label: string;
|
|
138
|
+
value: string;
|
|
139
|
+
}[];
|
|
140
|
+
/** `nativeSegmented` only: choose several. */
|
|
141
|
+
multi?: boolean;
|
|
142
|
+
/** Shown but inert: no clicks, drags, keys or screen-reader adjustments act on it. */
|
|
143
|
+
disabled?: boolean;
|
|
144
|
+
/**
|
|
145
|
+
* 0 to 1, fixed or bound like a layer's. At 0 the control is gone: it takes no clicks,
|
|
146
|
+
* keyboard focus or screen-reader node. Anything between is only drawn fainter.
|
|
147
|
+
*/
|
|
148
|
+
opacity?: number | SceneBinding;
|
|
149
|
+
/** `nativeButton` only. Default `filled`. */
|
|
150
|
+
variant?: "filled" | "tonal" | "outlined" | "text";
|
|
151
|
+
/** `nativeButton` and `nativeChip`: a Material Design Icons name, such as `radio-tower`. */
|
|
152
|
+
icon?: string;
|
|
153
|
+
/** `nativeTextField` and `nativeDropdown`: shown while empty. */
|
|
154
|
+
placeholder?: string;
|
|
155
|
+
/** `nativeText` only: a typography role from the render environment. Default `body`. */
|
|
156
|
+
style?: "body" | "label" | "title" | "display" | "mono";
|
|
157
|
+
/** `nativeText` only. Default `start`. */
|
|
158
|
+
align?: "start" | "center" | "end";
|
|
96
159
|
min?: number;
|
|
97
160
|
max?: number;
|
|
98
161
|
step?: number;
|
|
@@ -110,12 +173,66 @@ export interface PanelSceneEvent {
|
|
|
110
173
|
action: string;
|
|
111
174
|
phase: "activate" | "change" | "commit";
|
|
112
175
|
sequence: number;
|
|
176
|
+
/** The control's number, for a control bound to `values`. */
|
|
113
177
|
value?: number;
|
|
178
|
+
/** The control's string, for a control bound to `strings`. */
|
|
179
|
+
text?: string;
|
|
114
180
|
}
|
|
115
|
-
/** Patch only named scene values. The host ignores stale responses. */
|
|
181
|
+
/** Patch only named scene values and strings. The host ignores stale responses. */
|
|
116
182
|
export interface PanelSceneEventResult {
|
|
117
183
|
values: Record<string, number>;
|
|
184
|
+
strings?: Record<string, string>;
|
|
118
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Flutter's Row, Column and Wrap, with Flexible, Spacer and SizedBox as fields.
|
|
188
|
+
* Sizes are logical pixels, like the render environment's. At most 8 deep and 128 nodes.
|
|
189
|
+
*
|
|
190
|
+
* - `{row: [...]}` / `{column: [...]}` / `{wrap: [...]}` lay out their children.
|
|
191
|
+
* - `{control: "id"}` places a native control that has no rect. Each appears exactly once.
|
|
192
|
+
* - `{scene: true}` places the artwork, scaled to fit. At most once.
|
|
193
|
+
* - `{spacer: flex}` takes a share of the free space.
|
|
194
|
+
*
|
|
195
|
+
* A text field or dropdown given no width (a row child without `flex` or `width`) is 200 wide.
|
|
196
|
+
*/
|
|
197
|
+
export type PanelSceneLayoutNode = ({
|
|
198
|
+
row: PanelSceneLayoutNode[];
|
|
199
|
+
} | {
|
|
200
|
+
column: PanelSceneLayoutNode[];
|
|
201
|
+
} | {
|
|
202
|
+
wrap: PanelSceneLayoutNode[];
|
|
203
|
+
} | {
|
|
204
|
+
control: string;
|
|
205
|
+
} | {
|
|
206
|
+
scene: true;
|
|
207
|
+
} | {
|
|
208
|
+
spacer: number;
|
|
209
|
+
}) & {
|
|
210
|
+
/** Only on a row or column's direct child: share the free space, like Expanded (`tight`, the default) or Flexible (`loose`). */
|
|
211
|
+
flex?: number;
|
|
212
|
+
fit?: "tight" | "loose";
|
|
213
|
+
width?: number;
|
|
214
|
+
height?: number;
|
|
215
|
+
/** All sides, or `[start, top, end, bottom]`. */
|
|
216
|
+
padding?: number | [
|
|
217
|
+
number,
|
|
218
|
+
number,
|
|
219
|
+
number,
|
|
220
|
+
number
|
|
221
|
+
];
|
|
222
|
+
/** Gap between children; for a wrap, between items in a run. */
|
|
223
|
+
spacing?: number;
|
|
224
|
+
/** Wrap only: gap between runs. */
|
|
225
|
+
runSpacing?: number;
|
|
226
|
+
mainAxisAlignment?: "start" | "center" | "end" | "spaceBetween" | "spaceAround" | "spaceEvenly";
|
|
227
|
+
/** `stretch` on a row or column only. */
|
|
228
|
+
crossAxisAlignment?: "start" | "center" | "end" | "stretch";
|
|
229
|
+
};
|
|
230
|
+
/** @deprecated Renamed to `PanelScene`. */
|
|
231
|
+
export type SvgScene = PanelScene;
|
|
232
|
+
/** @deprecated Renamed to `PanelSceneLayer`. */
|
|
233
|
+
export type SvgSceneLayer = PanelSceneLayer;
|
|
234
|
+
/** @deprecated Renamed to `PanelSceneControl`. */
|
|
235
|
+
export type SvgSceneControl = PanelSceneControl;
|
|
119
236
|
export declare const HOOK_CATEGORIES: readonly [
|
|
120
237
|
"account",
|
|
121
238
|
"activity",
|
|
@@ -413,6 +530,7 @@ export interface RefGeojson {
|
|
|
413
530
|
url: string;
|
|
414
531
|
maxAgeInDays?: number;
|
|
415
532
|
missMaxAgeInDays?: number;
|
|
533
|
+
sourceName?: string;
|
|
416
534
|
}
|
|
417
535
|
export interface LoggingControlDescriptor {
|
|
418
536
|
key: string;
|
|
@@ -507,6 +625,12 @@ export interface ActivitySuggestion extends Ref {
|
|
|
507
625
|
relevance?: number;
|
|
508
626
|
allowsMultiple?: boolean;
|
|
509
627
|
}
|
|
628
|
+
export interface MapBounds {
|
|
629
|
+
south: number;
|
|
630
|
+
west: number;
|
|
631
|
+
north: number;
|
|
632
|
+
east: number;
|
|
633
|
+
}
|
|
510
634
|
export interface SuggestArgs {
|
|
511
635
|
operation: Record<string, JSONValue>;
|
|
512
636
|
location?: {
|
|
@@ -515,6 +639,7 @@ export interface SuggestArgs {
|
|
|
515
639
|
};
|
|
516
640
|
callsign?: string;
|
|
517
641
|
searchTerm?: string;
|
|
642
|
+
bounds?: MapBounds;
|
|
518
643
|
scoped?: boolean;
|
|
519
644
|
}
|
|
520
645
|
export interface ActivityHook {
|
|
@@ -536,6 +661,7 @@ export interface AdifFieldsHook {
|
|
|
536
661
|
fieldsForOneQSO(args: {
|
|
537
662
|
qso: Record<string, JSONValue>;
|
|
538
663
|
operation: Record<string, JSONValue>;
|
|
664
|
+
mainHandler?: boolean;
|
|
539
665
|
}, ctx: HookContext): Promise<{
|
|
540
666
|
name: string;
|
|
541
667
|
value: string;
|
|
@@ -818,6 +944,7 @@ export interface CommandAction {
|
|
|
818
944
|
qsoCount?: number;
|
|
819
945
|
};
|
|
820
946
|
devLogCat?: Record<string, never>;
|
|
947
|
+
devMapInfo?: Record<string, never>;
|
|
821
948
|
toggleExperiment?: {
|
|
822
949
|
token: string;
|
|
823
950
|
};
|
|
@@ -1411,7 +1538,7 @@ export interface PanelRadioTuneResult {
|
|
|
1411
1538
|
state: PanelRadioState | null;
|
|
1412
1539
|
}
|
|
1413
1540
|
export interface PanelRenderArgs {
|
|
1414
|
-
/** Present on environment-capable hosts. Changes automatically request a throttled render of
|
|
1541
|
+
/** Present on environment-capable hosts. Changes automatically request a throttled render of a `scene` panel. */
|
|
1415
1542
|
environment?: PanelEnvironment;
|
|
1416
1543
|
/** Display time follows developer time travel; real time is for network retry/cache budgets. */
|
|
1417
1544
|
clock?: {
|
|
@@ -1428,8 +1555,18 @@ export interface PanelRenderArgs {
|
|
|
1428
1555
|
reason: string;
|
|
1429
1556
|
}
|
|
1430
1557
|
export type PanelContent = {
|
|
1558
|
+
kind: "scene";
|
|
1559
|
+
scene: PanelScene;
|
|
1560
|
+
title?: string;
|
|
1561
|
+
triggers?: string[];
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* @deprecated The same kind as `scene`, under its name through SDK 0.9. Apps before extension
|
|
1565
|
+
* API 5 know only `svgScene`; `scene` needs `"api": 5`, as do native controls, `strings` and `layout`.
|
|
1566
|
+
*/
|
|
1567
|
+
| {
|
|
1431
1568
|
kind: "svgScene";
|
|
1432
|
-
scene:
|
|
1569
|
+
scene: PanelScene;
|
|
1433
1570
|
title?: string;
|
|
1434
1571
|
triggers?: string[];
|
|
1435
1572
|
} | {
|
|
@@ -1733,6 +1870,21 @@ export interface ReferenceActivityHooks {
|
|
|
1733
1870
|
adifImportHook: AdifImportHook;
|
|
1734
1871
|
}
|
|
1735
1872
|
export declare function referenceActivity(config: ReferenceActivityConfig): ReferenceActivityHooks;
|
|
1873
|
+
export interface NearbyLookupHost {
|
|
1874
|
+
dbLookupSelectByLocation(category: string, lat: number, lon: number, delta: number, activeOnly?: boolean): Promise<LookupRow[]>;
|
|
1875
|
+
dbLookupSelectInBounds(category: string, bounds: MapBounds, options?: {
|
|
1876
|
+
activeOnly?: boolean;
|
|
1877
|
+
limit?: number;
|
|
1878
|
+
}): Promise<LookupRow[]>;
|
|
1879
|
+
}
|
|
1880
|
+
export declare const VIEWPORT_LIMIT = 500;
|
|
1881
|
+
export declare function nearbyLookupRows(host: NearbyLookupHost, category: string, { location, bounds }: Pick<SuggestArgs, "location" | "bounds">, { delta, activeOnly }: {
|
|
1882
|
+
delta: number;
|
|
1883
|
+
activeOnly?: boolean;
|
|
1884
|
+
}): Promise<{
|
|
1885
|
+
rows: LookupRow[];
|
|
1886
|
+
inViewport: boolean;
|
|
1887
|
+
}>;
|
|
1736
1888
|
export declare class TemplateError extends Error {
|
|
1737
1889
|
readonly cause?: unknown;
|
|
1738
1890
|
constructor(message: string, options?: {
|
|
@@ -1836,6 +1988,10 @@ export declare const host: {
|
|
|
1836
1988
|
dbLookupSelectOne(category: string, key: string): Promise<LookupRow | null>;
|
|
1837
1989
|
dbLookupSelectAll(category: string, query: string, subCategory?: string, activeOnly?: boolean): Promise<LookupRow[]>;
|
|
1838
1990
|
dbLookupSelectByLocation(category: string, lat: number, lon: number, delta: number, activeOnly?: boolean): Promise<LookupRow[]>;
|
|
1991
|
+
dbLookupSelectInBounds(category: string, bounds: MapBounds, options?: {
|
|
1992
|
+
activeOnly?: boolean;
|
|
1993
|
+
limit?: number;
|
|
1994
|
+
}): Promise<LookupRow[]>;
|
|
1839
1995
|
showForm(form: FormDefinition, state?: Record<string, any>): Promise<Record<string, any> | null>;
|
|
1840
1996
|
statusBar: {
|
|
1841
1997
|
setMessage(params: StatusBarMessageParams): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { base64ToBytes, bytesToBase64 } from "./base64.js";
|
|
|
2
2
|
import { adoptExtensionTimers, clearInterval, clearTimeout, setInterval, setTimeout } from "./timers.js";
|
|
3
3
|
export * from "./types.js";
|
|
4
4
|
export * from "./hookCategories.js";
|
|
5
|
-
export * from "./
|
|
5
|
+
export * from "./panelScene.js";
|
|
6
6
|
export * from "./i18n.js";
|
|
7
7
|
export * from "./dxcc.js";
|
|
8
8
|
export * from "./location.js";
|
|
@@ -16,6 +16,7 @@ export * from "./activityAdifImport.js";
|
|
|
16
16
|
export * from "./exportNames.js";
|
|
17
17
|
export * from "./exportSettings.js";
|
|
18
18
|
export * from "./referenceActivity.js";
|
|
19
|
+
export * from "./nearby.js";
|
|
19
20
|
export * from "./refTransforms.js";
|
|
20
21
|
export * from "./templates.js";
|
|
21
22
|
export * from "./templateContext.js";
|
|
@@ -304,6 +305,13 @@ const host = {
|
|
|
304
305
|
async dbLookupSelectByLocation(category, lat, lon, delta, activeOnly) {
|
|
305
306
|
return await hostCallFn()("dbLookupSelectByLocation", { category, lat, lon, delta, activeOnly });
|
|
306
307
|
},
|
|
308
|
+
/// Every row inside `bounds`, nearest its centre first, at most `limit`
|
|
309
|
+
/// (default 500, never past 1000). `west` past `east` is a box across the
|
|
310
|
+
/// antimeridian. Only an app that sends `SuggestArgs.bounds` answers this
|
|
311
|
+
/// call — so call it only when `bounds` arrived, as `nearbyLookupRows` does.
|
|
312
|
+
async dbLookupSelectInBounds(category, bounds, options = {}) {
|
|
313
|
+
return await hostCallFn()("dbLookupSelectInBounds", { category, ...bounds, ...options });
|
|
314
|
+
},
|
|
307
315
|
async showForm(form, state = {}) {
|
|
308
316
|
return kernel().host.showForm(form, state, currentExtensionKey);
|
|
309
317
|
},
|
package/dist/nearby.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const VIEWPORT_LIMIT = 500;
|
|
2
|
+
async function nearbyLookupRows(host, category, { location, bounds }, { delta, activeOnly }) {
|
|
3
|
+
if (bounds) return { rows: await host.dbLookupSelectInBounds(category, bounds, { activeOnly, limit: VIEWPORT_LIMIT }), inViewport: true };
|
|
4
|
+
if (location) return { rows: await host.dbLookupSelectByLocation(category, location.lat, location.lon, delta, activeOnly), inViewport: false };
|
|
5
|
+
return { rows: [], inViewport: false };
|
|
6
|
+
}
|
|
7
|
+
export {
|
|
8
|
+
VIEWPORT_LIMIT,
|
|
9
|
+
nearbyLookupRows
|
|
10
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nearbyLookupRows } from "./nearby.js";
|
|
1
2
|
import { distanceOnEarth } from "@ham2k/lib-geo-tools";
|
|
2
3
|
import { activityAdifImport } from "./activityAdifImport.js";
|
|
3
4
|
async function hostApi() {
|
|
@@ -186,9 +187,9 @@ function referenceActivity(config) {
|
|
|
186
187
|
];
|
|
187
188
|
}
|
|
188
189
|
} : {},
|
|
189
|
-
async suggest({ location, searchTerm, scoped }, ctx) {
|
|
190
|
+
async suggest({ location, bounds, searchTerm, scoped }, ctx) {
|
|
190
191
|
const host = await hostApi();
|
|
191
|
-
const rows = searchTerm ? await host.dbLookupSelectAll(category, searchTerm) :
|
|
192
|
+
const { rows, inViewport } = searchTerm ? { rows: await host.dbLookupSelectAll(category, searchTerm), inViewport: false } : await nearbyLookupRows(host, category, { location, bounds }, { delta: NEARBY_DELTA });
|
|
192
193
|
const withDistance = rows.map((row) => ({
|
|
193
194
|
row,
|
|
194
195
|
distance: location && row.lat != null && row.lon != null ? distanceOnEarth(location, { lat: row.lat, lon: row.lon }) ?? void 0 : void 0
|
|
@@ -196,7 +197,7 @@ function referenceActivity(config) {
|
|
|
196
197
|
if (!searchTerm) {
|
|
197
198
|
withDistance.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
|
|
198
199
|
}
|
|
199
|
-
const suggestions = withDistance.slice(0, MAX_SUGGESTIONS).map(({ row, distance }) => {
|
|
200
|
+
const suggestions = withDistance.slice(0, inViewport ? withDistance.length : MAX_SUGGESTIONS).map(({ row, distance }) => {
|
|
200
201
|
const data = row.data ?? {};
|
|
201
202
|
return {
|
|
202
203
|
type: config.activationType,
|
package/docs/distribution.md
CHANGED
|
@@ -51,7 +51,9 @@ reaches every app that can run it: 2 adds `host.webSocket`, and the packer
|
|
|
51
51
|
refuses a manifest declaring `webSockets` under anything less; 3 adds timers
|
|
52
52
|
and `onShow`/`onHide`, and from 3 the build rewrites a bare `setTimeout` into
|
|
53
53
|
the SDK's (README's "Timers"); 4 lets `host.fetch` reach a plain-`http://` host
|
|
54
|
-
the app fronts with its https proxy (README's `host.fetch`)
|
|
54
|
+
the app fronts with its https proxy (README's `host.fetch`); 5 adds panel
|
|
55
|
+
scenes' `scene` kind, native controls, `strings`, `layout`, `after` and control
|
|
56
|
+
`opacity` (hooks.md's `panel`).
|
|
55
57
|
|
|
56
58
|
Three fields are **refused** in a distributed bundle, and it is worth
|
|
57
59
|
understanding why:
|
|
@@ -260,7 +262,7 @@ It reports every problem at once rather than one per run:
|
|
|
260
262
|
h2kext-pack: ./build is not ready to package:
|
|
261
263
|
manifest.json: missing required field 'name'
|
|
262
264
|
manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
|
|
263
|
-
manifest.json: missing 'api' — declare the extension API this was built against (currently
|
|
265
|
+
manifest.json: missing 'api' — declare the extension API this was built against (currently 5)
|
|
264
266
|
```
|
|
265
267
|
|
|
266
268
|
`--force-name` waives the naming convention below. It exists for Ham2K's own
|
|
@@ -399,8 +401,9 @@ built-in's own state stays where it is.
|
|
|
399
401
|
One rule bends for these, and only for them:
|
|
400
402
|
|
|
401
403
|
- **Built-in collisions.** An installed bundle may not claim a key the app
|
|
402
|
-
ships — judged against what the app is currently OFFERING, which
|
|
403
|
-
catalog
|
|
404
|
+
ships — judged against what the app is currently OFFERING, which once the
|
|
405
|
+
upgrade to catalog extensions is taken is the core extensions and nothing
|
|
406
|
+
else. `ham2k-lookup`
|
|
404
407
|
is both a built-in and a pre-load, and could otherwise never install.
|
|
405
408
|
|
|
406
409
|
**Build secrets are not among them, and used to be.** A pre-load holding a
|
|
@@ -412,14 +415,16 @@ sees no build secret, whatever key it claims and wherever it came from.
|
|
|
412
415
|
|
|
413
416
|
### The upgrade to catalog extensions
|
|
414
417
|
|
|
415
|
-
|
|
416
|
-
at
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
418
|
+
A new install — one whose default profile has never had a callsign — starts
|
|
419
|
+
on the catalog at its first boot, on the bundles the app ships, with nothing
|
|
420
|
+
to offer and nothing to carry; the decision is recorded once
|
|
421
|
+
(`seedExtensionsMigratedDefault`). Onboarding still names its activities by
|
|
422
|
+
their built-in keys, which `offeredKeyFor` maps to the shipped twin that
|
|
423
|
+
actually runs. An install that has been running the app's own extensions is
|
|
424
|
+
offered the move at start, on every launch until it is taken. Only a taken upgrade withdraws
|
|
425
|
+
anything, and the offer may be deferred. A catalog that cannot be reached
|
|
426
|
+
raises no offer and says nothing: it is a launch nobody was watching, and the
|
|
427
|
+
offer comes back next time.
|
|
423
428
|
|
|
424
429
|
Accepting installs the pre-loads, then fetches the catalog's twin of every
|
|
425
430
|
other built-in the operator had switched on — `wca` becomes `ham2k-wca` —
|
|
@@ -521,15 +526,72 @@ Beyond what the packer already refused:
|
|
|
521
526
|
|
|
522
527
|
Each has its own message. A bundle that cannot be installed says why.
|
|
523
528
|
|
|
524
|
-
##
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
529
|
+
## Where installed bundles live
|
|
530
|
+
|
|
531
|
+
Everything above is platform-neutral — the zip walk, the manifest rules, the
|
|
532
|
+
consent screen, the hash check. What differs is only where the bytes are kept
|
|
533
|
+
(`BundleStorage`):
|
|
534
|
+
|
|
535
|
+
- **natively**, one directory per key under the app's data directory, one
|
|
536
|
+
file per bundle entry;
|
|
537
|
+
- **on web**, one IndexedDB record per key (database `halo-extensions`),
|
|
538
|
+
holding every entry's bytes under its own name. IndexedDB rather than the
|
|
539
|
+
Origin Private File System, which would mirror the native layout: every
|
|
540
|
+
browser HaLo runs in supports it the same way, while OPFS writes from the
|
|
541
|
+
page are newer and patchier on Safari.
|
|
542
|
+
|
|
543
|
+
So the web build installs from a file, installs and updates from the catalog,
|
|
544
|
+
pre-loads the shipped bundles and takes the upgrade exactly as the native ones
|
|
545
|
+
do.
|
|
546
|
+
|
|
547
|
+
### When the store is lost
|
|
548
|
+
|
|
549
|
+
The store can go while the preferences describing it survive: a browser
|
|
550
|
+
clearing IndexedDB alone, a data directory deleted by hand, a store that
|
|
551
|
+
failed mid-session. (When a browser clears a whole site, preferences go with
|
|
552
|
+
it, and the app starts as a new install.) So what the operator installed is
|
|
553
|
+
kept apart from the store that holds it, and put back from there.
|
|
554
|
+
|
|
555
|
+
`extensionsInstalled` records every extension the operator installed
|
|
556
|
+
(`{key: name}`). Every install writes it; only an uninstall — or Forget, below
|
|
557
|
+
— removes it. An install that predates it is seeded from its store once, and
|
|
558
|
+
after that it never follows the store: a store found short is exactly what it
|
|
559
|
+
must not shrink to. Whatever is recorded there and not in the store is
|
|
560
|
+
**missing**, however it went — judged by the keys the store holds, not by the
|
|
561
|
+
extensions the app offers, since a built-in or a dev server can stand in front
|
|
562
|
+
of an installed bundle. A store that cannot be read at all has lost nothing:
|
|
563
|
+
that launch offers nothing, rather than everything back into a failing store.
|
|
564
|
+
Where each came from is its provenance record's answer, and a key with none
|
|
565
|
+
came from a file.
|
|
566
|
+
|
|
567
|
+
The operator's on/off choice for a missing extension is kept too: the boot
|
|
568
|
+
cleanup that prunes settings for keys the app no longer knows treats every
|
|
569
|
+
recorded key as known.
|
|
570
|
+
|
|
571
|
+
Putting them back, after the app starts:
|
|
572
|
+
|
|
573
|
+
- **Shipped bundles** come back at boot from the app's own assets, unasked and
|
|
574
|
+
offline. The pre-load record says they were already offered, which is true
|
|
575
|
+
and beside the point; they are installed anyway, never as a first install,
|
|
576
|
+
so their settings and on/off choice stand. An install that has not taken the
|
|
577
|
+
upgrade gets back only what it had.
|
|
578
|
+
- **Everything else** is offered in a dialog once the boot has settled:
|
|
579
|
+
- extensions from the catalog in use are listed to install, at the release
|
|
580
|
+
it serves now. **Later** keeps every one for the next launch;
|
|
581
|
+
- extensions from a file are listed to install again from their files, and
|
|
582
|
+
ones from a different catalog as not available here. Neither is fetched —
|
|
583
|
+
the bytes would come from a publisher the operator never agreed to;
|
|
584
|
+
- every row has **Forget**, which drops it with everything it stored — the
|
|
585
|
+
only way out for one the catalog no longer serves, which would otherwise
|
|
586
|
+
be offered, and fail, every launch.
|
|
587
|
+
- **Install** installs each on its own: one that is not listed, will not
|
|
588
|
+
download or fails the check costs only itself. The runtime restarts with
|
|
589
|
+
everything that landed; a bundle it will not evaluate is set aside, as the
|
|
590
|
+
upgrade does, and the runtime restarts without it. The result names what
|
|
591
|
+
could not be installed (offered again next launch) and what installed but
|
|
592
|
+
would not run (kept, marked in the panel, where an update is the way back);
|
|
593
|
+
says so when the runtime would not start at all, rather than calling it a
|
|
594
|
+
success; and says the app is still busy — offering them again next launch
|
|
595
|
+
— when another catalog pass holds the store.
|
|
596
|
+
- The offer waits for the boot's catch-up to finish, or for an upgrade that
|
|
597
|
+
every catch-up stepped aside for to let go.
|
package/docs/hooks.md
CHANGED
|
@@ -46,8 +46,8 @@ hook method receives `(args, ctx)` where `ctx: HookContext` is:
|
|
|
46
46
|
effect must stay invisible until an experiment gating the surface it
|
|
47
47
|
belongs to has shipped ungated — checked per-call, since gating at the
|
|
48
48
|
whole-extension `manifest.experiments` grain would also hide any other
|
|
49
|
-
hook the same extension registers (e.g. `radio-commands`'
|
|
50
|
-
checks `
|
|
49
|
+
hook the same extension registers (e.g. `radio-commands`' WPM command
|
|
50
|
+
checks `keyer` here; its BAND/MODE/frequency siblings don't).
|
|
51
51
|
* `experiments?: { key, name, aliases }[]` — The experiment catalog itself,
|
|
52
52
|
on or off, so a hook can resolve a typed key-or-alias to one of the keys
|
|
53
53
|
`enabledExperiments` lists (the EXP preview says "Enable"/"Disable" rather
|
|
@@ -473,15 +473,18 @@ its own program name reads the wrong program aloud. Programs built on the SDK's
|
|
|
473
473
|
from one `linkUrl` config line.
|
|
474
474
|
|
|
475
475
|
`geojsonUrlForRef` says where the reference's own footprint can be fetched as
|
|
476
|
-
GeoJSON — a SOTA summit's activation zone,
|
|
477
|
-
program publishes.
|
|
476
|
+
GeoJSON — a SOTA summit's activation zone, a POTA park's boundary, and
|
|
477
|
+
whatever equivalent another program publishes. Areas (`Polygon`,
|
|
478
|
+
`MultiPolygon`) are drawn filled with an edge; lines (`LineString`,
|
|
479
|
+
`MultiLineString`) — a trail or a river that is the park — are drawn as an
|
|
480
|
+
edge alone; points are ignored. Same contract as `linkForRef`: build the URL from the
|
|
478
481
|
reference, **never hit the network**, return `null` for a reference the program
|
|
479
482
|
has no shape for. The core does the fetching and the caching (`GeoshapeService`),
|
|
480
483
|
so an extension never decides how long a device keeps a file or how large one may
|
|
481
484
|
be; it only names the two ages the core should use:
|
|
482
485
|
|
|
483
486
|
```ts
|
|
484
|
-
// RefGeojson: {url, maxAgeInDays?, missMaxAgeInDays?}
|
|
487
|
+
// RefGeojson: {url, maxAgeInDays?, missMaxAgeInDays?, sourceName?}
|
|
485
488
|
```
|
|
486
489
|
|
|
487
490
|
`maxAgeInDays` (default 30) is how long a fetched shape stays usable offline —
|
|
@@ -490,6 +493,14 @@ long the core remembers that a reference has NO shape, deliberately shorter so a
|
|
|
490
493
|
newly surveyed zone appears within a day rather than a month. Both are ignored
|
|
491
494
|
unless positive: a zero would re-fetch on every rebuild of the map.
|
|
492
495
|
|
|
496
|
+
`sourceName` is who drew the shapes, short enough for the credit line a map
|
|
497
|
+
shows under its outlines ("Source: OSM, sotl.as") — the host of `url` when it
|
|
498
|
+
is left out. Name the data's owner, not the server that relays it: POTA's
|
|
499
|
+
outlines are served by Ham2K and credited to OSM.
|
|
500
|
+
|
|
501
|
+
A document past 16MB is not kept: the core remembers it as a miss, on
|
|
502
|
+
`missMaxAgeInDays`, rather than downloading it again on every retry.
|
|
503
|
+
|
|
493
504
|
The URL must be `https`, and the core does not follow redirects to reach it.
|
|
494
505
|
Unlike a link, this one is fetched rather than handed to the platform, and a
|
|
495
506
|
plaintext one — named outright, or arrived at through a `Location:` header —
|
|
@@ -497,10 +508,13 @@ would downgrade every activator's connection on a program's say-so.
|
|
|
497
508
|
|
|
498
509
|
Shapes are asked for sparingly: the Map tab asks about the operation's own
|
|
499
510
|
references, and the Activities pane's results map about those plus the one
|
|
500
|
-
suggestion whose card is open
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
511
|
+
suggestion whose card is open, drawn solid. The rest of the map's suggestions
|
|
512
|
+
are asked about only once it is zoomed in (from GL zoom 7, fading in by 8)
|
|
513
|
+
and drawn as a dashed edge under the solid ones — a search answers with dozens
|
|
514
|
+
of references, and further out a footprint is a few pixels across. Implemented by: `sota` (both types, via
|
|
515
|
+
SOTLAS's `az.sotl.as`, used with the operators' permission) and `pota` (both
|
|
516
|
+
types, via `services.ham2k.net/lookups/outlines/pota/`, which links parks to
|
|
517
|
+
OpenStreetMap shapes; a park not yet linked answers 404).
|
|
504
518
|
|
|
505
519
|
`suggestOperationTitle` lets an activity build a natural-language operation
|
|
506
520
|
title/subtitle from its refs — "at Yosemite NP" (POTA), "for Field Day"
|
|
@@ -537,7 +551,7 @@ interface ActivityHook {
|
|
|
537
551
|
// extension only says what the short form is.
|
|
538
552
|
// `skipFocus: true` takes a main logging field out of the Space and Tab cycle;
|
|
539
553
|
// the arrow keys still reach it. Every shipped contest sets it on Our Serial.
|
|
540
|
-
// SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped
|
|
554
|
+
// SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?, bounds?: {south, west, north, east}}
|
|
541
555
|
// ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
|
|
542
556
|
|
|
543
557
|
// The input catalog. A new kind is earned by a different WIDGET, never by
|
|
@@ -622,7 +636,17 @@ proposing a new `input.kind` is a core PR, deliberately (DESIGN.md §5's
|
|
|
622
636
|
|
|
623
637
|
`suggest` powers the Activities view's "add activity" search: called with a
|
|
624
638
|
`location`/`callsign` the core resolves from the operation if set, else the
|
|
625
|
-
station defaults (extensions never need to know which).
|
|
639
|
+
station defaults (extensions never need to know which). When the operator pans
|
|
640
|
+
the Activities results map it asks again with the map's view as `bounds` (and
|
|
641
|
+
its centre as `location`, no `searchTerm`, the box's scope if it has one):
|
|
642
|
+
answer with EVERY reference inside the view, not the nearest few, or the view's
|
|
643
|
+
corners stay empty however far the operator pans. `nearbyLookupRows` reads
|
|
644
|
+
either case, through the `dbLookupSelectInBounds` host call for a view — only
|
|
645
|
+
an app that sends `bounds` answers that call, so an extension that calls it
|
|
646
|
+
only then runs on older apps unchanged, and one that predates `bounds` answers
|
|
647
|
+
with what is nearest the view's centre. Measure `distance` from the `location`
|
|
648
|
+
given either way; the core re-measures a panned answer from the operation's own
|
|
649
|
+
place before any card shows it. Called with no
|
|
626
650
|
`searchTerm` for nearby suggestions, or with one for name/code search — an
|
|
627
651
|
extension may honor either, both, or neither (returning `[]`). `scoped` is
|
|
628
652
|
true when THIS extension alone was asked — the operator tapped its "Activity
|
|
@@ -717,12 +741,17 @@ knowing them. Called by the `adif` extension **inside the runtime** via
|
|
|
717
741
|
|
|
718
742
|
```ts
|
|
719
743
|
interface AdifFieldsHook {
|
|
720
|
-
fieldsForOneQSO(args: { qso, operation }, ctx): Promise<{name, value}[]>
|
|
744
|
+
fieldsForOneQSO(args: { qso, operation, mainHandler? }, ctx): Promise<{name, value}[]>
|
|
721
745
|
// One field set PER ADIF RECORD this contact should produce.
|
|
722
|
-
fieldCombinationsForOneQSO?(args: { qso, operation }, ctx): Promise<{name, value}[][]>
|
|
746
|
+
fieldCombinationsForOneQSO?(args: { qso, operation, mainHandler? }, ctx): Promise<{name, value}[][]>
|
|
723
747
|
}
|
|
724
748
|
```
|
|
725
749
|
|
|
750
|
+
`mainHandler` is true when the file is this extension's own export — app-polo's
|
|
751
|
+
argument of the same name. It is how a program writes what only its own
|
|
752
|
+
submission asks for: SOTA's files carry both stations' gridsquares, which the
|
|
753
|
+
full export leaves to the contact's own privacy rules.
|
|
754
|
+
|
|
726
755
|
POTA contributes `SIG/SIG_INFO/POTA_REF` (hunted refs on the QSO) and
|
|
727
756
|
`MY_SIG/MY_SIG_INFO/MY_POTA_REF` (activation refs on the operation).
|
|
728
757
|
|
|
@@ -795,11 +824,18 @@ would claim no reference at all.
|
|
|
795
824
|
**A field name is written once per record, and the first to carry a value
|
|
796
825
|
wins.** ADIF gives no meaning to a repeated field, so where two hooks answer
|
|
797
826
|
the same name — the full export asks every program on the contact, and a park
|
|
798
|
-
and a summit both answer `MY_SIG` — the later one is dropped
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
827
|
+
and a summit both answer `MY_SIG` — the later one is dropped. Hooks are asked
|
|
828
|
+
main handler first, then each `includeFieldsFrom` key in the order given, so
|
|
829
|
+
that order decides — whichever of the two methods each hook answered. The
|
|
830
|
+
contact's own fields come after every hook's: a program knows what its file
|
|
831
|
+
needs in a field better than the contact's generic answer does (the section a
|
|
832
|
+
Field Day station sent, a satellite's downlink band). The exception is what
|
|
833
|
+
identifies the contact — `CALL`, `QSO_DATE`, `TIME_ON`, `QSO_DATE_OFF`,
|
|
834
|
+
`TIME_OFF`, `BAND`, `FREQ`, `MODE`, `SUBMODE`, `STATION_CALLSIGN`,
|
|
835
|
+
`OPERATOR`: a hook answering one of those is dropped, since it would rewrite
|
|
836
|
+
every record it is asked about, the whole-log backup included. This is a
|
|
837
|
+
deliberate divergence from app-polo, whose contact fields come first and whose
|
|
838
|
+
main handler's are appended unchecked, so a name both answer is written twice. The **full export**, which asks every
|
|
803
839
|
program on the contact, has no such order: hooks answering `fieldCombinationsForOneQSO` are asked as one
|
|
804
840
|
group and hooks answering `fieldsForOneQSO` as another, so which of two programs
|
|
805
841
|
keeps a shared `MY_SIG` there is not something either of them chose.
|
|
@@ -1389,17 +1425,25 @@ interface PanelHook {
|
|
|
1389
1425
|
// PanelDescriptor: {key, title, description?, icon?, preview?, on?, form?}
|
|
1390
1426
|
// PanelRenderArgs: {panelKey, operation, qso?, qsoCount, config, reason}
|
|
1391
1427
|
// PanelContent: {kind: 'markdown' | 'svg' | 'html', content, title?, triggers?}
|
|
1428
|
+
// | {kind: 'scene', scene, title?, triggers?} (see Panel scene reference)
|
|
1392
1429
|
```
|
|
1393
1430
|
|
|
1394
1431
|
The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
|
|
1395
1432
|
inside saved layouts — renaming a `key` drops the panel out of every
|
|
1396
1433
|
arrangement holding it.
|
|
1397
1434
|
|
|
1398
|
-
An experimental fourth kind,
|
|
1399
|
-
local controls, and host-run animations.
|
|
1400
|
-
title?, triggers?}`; it does not have a
|
|
1435
|
+
An experimental fourth kind, the **panel scene**, adds SVG and text layers, numeric
|
|
1436
|
+
bindings, local controls, native Material controls, a layout, and host-run animations.
|
|
1437
|
+
Its payload is `{kind: 'scene', scene, title?, triggers?}`; it does not have a
|
|
1438
|
+
`content` string. `kind: 'svgScene'` is the same kind under its name through SDK 0.9,
|
|
1439
|
+
which the SDK keeps as a deprecated alias along with the `SvgScene*` type names.
|
|
1440
|
+
**`scene` and everything it adds are extension API 5**: native controls,
|
|
1441
|
+
`strings`, `layout`, `after` and control `opacity`. A bundle using any of them
|
|
1442
|
+
declares `"api": 5` ([distribution.md](distribution.md)), and an app speaking
|
|
1443
|
+
less refuses it at install rather than showing the panel as an unknown kind.
|
|
1444
|
+
A plain scene that must reach those apps keeps `svgScene` and its lower API. Scene-capable hosts pass
|
|
1401
1445
|
`instanceId` on render/event calls. `onEvent` handles control events and returns
|
|
1402
|
-
|
|
1446
|
+
value patches (`values`, and `strings` for string controls); local-only controls and animation frames make no bridge
|
|
1403
1447
|
calls. Controls with `continuous: true` send `change` events during dragging and a
|
|
1404
1448
|
final `commit` on release; pending movements are coalesced to the newest value.
|
|
1405
1449
|
Numeric text supports `scale`, `truncate`, `modulo`, and `minIntegerDigits`
|
|
@@ -1410,8 +1454,8 @@ may hold numbers or preformatted strings; control `valueLabels` supplies accessi
|
|
|
1410
1454
|
value descriptions. Transform/opacity bindings may also supply numeric `samples`,
|
|
1411
1455
|
equally spaced over their `input` range, instead of the `output` ramp. The host
|
|
1412
1456
|
interpolates and clamps these locally; chart markers and callouts can follow
|
|
1413
|
-
individual readings without bridge calls. See [the design notes and open milestones](https://github.com/ham2k/halo/blob/main/docs/design/
|
|
1414
|
-
and the `k2hrc-
|
|
1457
|
+
individual readings without bridge calls. See [the design notes and open milestones](https://github.com/ham2k/halo/blob/main/docs/design/panel-scenes.md)
|
|
1458
|
+
and the `k2hrc-panel-scenes` sample. This API is experimental.
|
|
1415
1459
|
|
|
1416
1460
|
Scene-capable hosts also supply `args.environment` on **render and event** calls:
|
|
1417
1461
|
|
|
@@ -1432,7 +1476,7 @@ Scene-capable hosts also supply `args.environment` on **render and event** calls
|
|
|
1432
1476
|
describe the resolved presentation context. Font names refer to host fonts;
|
|
1433
1477
|
they are not URLs or permission to load remote fonts.
|
|
1434
1478
|
|
|
1435
|
-
For a panel showing
|
|
1479
|
+
For a panel showing a scene, environment changes automatically request a
|
|
1436
1480
|
render through the existing throttle, even with no triggers declared. Hidden
|
|
1437
1481
|
panels catch up on reveal; repeated resize updates coalesce; a scene laid out for
|
|
1438
1482
|
an old environment cannot overwrite a newer one. Markdown and HTML panels are
|
|
@@ -1450,6 +1494,202 @@ Button controls can supply `menu: [{label, event}]` to open a native dropdown.
|
|
|
1450
1494
|
Selecting an item emits its `event` as the action with the original control ID;
|
|
1451
1495
|
dismissing the menu emits nothing.
|
|
1452
1496
|
|
|
1497
|
+
**Native controls.** Besides the drawn `button`, `slider` and `knob` — invisible
|
|
1498
|
+
hit areas over the extension's own artwork — a scene can use Material widgets
|
|
1499
|
+
the app themes itself: `nativeButton`, `nativeSlider`, `nativeTextField`,
|
|
1500
|
+
`nativeDropdown`, `nativeSwitch`, `nativeCheckbox`, `nativeRadio`,
|
|
1501
|
+
`nativeSegmented`, `nativeChip` and `nativeText`. `PanelSceneControl` in the SDK
|
|
1502
|
+
lists what each kind takes. String-valued controls (text field, dropdown, radio,
|
|
1503
|
+
segmented) bind to the scene's `strings` map, which sits beside `values`; a key
|
|
1504
|
+
is in one or the other, never both; a choice's string is `""` or one of its
|
|
1505
|
+
`options` (each comma-separated part, for `multi`). Their events carry `text` instead of `value`,
|
|
1506
|
+
and an `onEvent` result may return `strings` as well as `values`. A text layer
|
|
1507
|
+
whose `value` names a string shows it as-is. A text field commits on Enter, or
|
|
1508
|
+
on leaving it after typing; a render that lands while the operator is typing
|
|
1509
|
+
does not overwrite the typing. Every control's `label` is its accessible name;
|
|
1510
|
+
the app has no tooltips. A text field or dropdown in a panel keeps keyboard
|
|
1511
|
+
focus; clicking any other native control hands focus back to the callsign
|
|
1512
|
+
field, as clicking a button anywhere else in the Operation view does.
|
|
1513
|
+
|
|
1514
|
+
**Layout.** A native control either has an `x`/`y`/`width`/`height` rect over the
|
|
1515
|
+
artwork, like the drawn kinds — it takes the rect's width and its own height, up
|
|
1516
|
+
to the rect's, so give a field a rect tall enough for it — or omits all four and is placed by
|
|
1517
|
+
`scene.layout` — a tree of Flutter's `row`, `column` and `wrap`, with `flex`/`fit`
|
|
1518
|
+
(Expanded and Flexible), `width`/`height`, `padding`, `spacing`, the two axis
|
|
1519
|
+
alignments, `{spacer: flex}`, `{control: id}`, and `{scene: true}` for the
|
|
1520
|
+
artwork, scaled to fit. Sizes are logical pixels, like the render environment's.
|
|
1521
|
+
Every rect-less control appears in the layout exactly once; a layout with no
|
|
1522
|
+
`scene` node shows no artwork, so it may declare no layers either. A text field
|
|
1523
|
+
or dropdown given no width (a row child without `flex` or `width`) is 200 wide,
|
|
1524
|
+
and a row's children that do not fit overflow as a Flutter `Row`'s do — use
|
|
1525
|
+
`flex` or `wrap`. A `flex` only shares space its row or column has: in a row
|
|
1526
|
+
that is itself an unflexed child of a row, there is none, and its children lay
|
|
1527
|
+
out at their own size. The layout is at most 8 deep and 128 nodes.
|
|
1528
|
+
|
|
1529
|
+
**Controls among the layers.** Layers paint in order, and a control with a rect
|
|
1530
|
+
paints above all of them unless its `after` names a layer: then it paints
|
|
1531
|
+
directly after that one, and later layers draw over it. Layers are
|
|
1532
|
+
click-through: art painted over a control does not stop it working. A layer with
|
|
1533
|
+
`blocksPointer: true` takes the clicks that land on it while it is visible,
|
|
1534
|
+
covering the controls under it (keyboard focus still reaches them; mark them
|
|
1535
|
+
`disabled` too to shut them off). Only a pending action disables action
|
|
1536
|
+
controls; a value being sent does not. Any control can take an `opacity`, fixed or bound like a layer's;
|
|
1537
|
+
at 0 it takes no clicks, focus or screen-reader node.
|
|
1538
|
+
|
|
1539
|
+
### Panel scene reference
|
|
1540
|
+
|
|
1541
|
+
The schema in one place; `panelScene.ts` in the SDK carries the same as types.
|
|
1542
|
+
Every rule below is checked when the scene arrives: a scene that breaks one is
|
|
1543
|
+
refused whole, and the pane shows the error naming what is wrong, rather than
|
|
1544
|
+
drawing part of it.
|
|
1545
|
+
|
|
1546
|
+
**The scene document**
|
|
1547
|
+
|
|
1548
|
+
| Field | |
|
|
1549
|
+
|---|---|
|
|
1550
|
+
| `version` | `1` |
|
|
1551
|
+
| `width`, `height` | The artwork's viewBox, in scene units. Size it to `environment.width`/`height` and a scene unit is a logical pixel. |
|
|
1552
|
+
| `values` | `{name: number}`: what bindings, numeric text and numeric controls read. At most 256. |
|
|
1553
|
+
| `strings` | `{name: string}`: what text fields and choices hold. At most 256, each up to 4096 characters. A name is in `values` or `strings`, never both. |
|
|
1554
|
+
| `layers` | Painted in order, each over the ones before: `svg` artwork or `text`. At most 128, and 1 MiB of artwork and text in all. |
|
|
1555
|
+
| `controls` | At most 64. Drawn or native; see below. |
|
|
1556
|
+
| `layout` | Optional. Where rect-less native controls and the artwork go; omitted, the artwork fills the pane. |
|
|
1557
|
+
|
|
1558
|
+
**Controls.** Every control has an `id` and a `label`, which is its accessible
|
|
1559
|
+
name. An `event` names the action sent when it is used; without one the control
|
|
1560
|
+
is local-only: its value changes on screen, survives renders, and never reaches
|
|
1561
|
+
the extension. `value` names what it shows and sets.
|
|
1562
|
+
|
|
1563
|
+
| Kind | Binds to | Sends | Also takes |
|
|
1564
|
+
|---|---|---|---|
|
|
1565
|
+
| `button` | — | `activate` | `menu` |
|
|
1566
|
+
| `slider` | number | `commit` | `min`, `max`, `step`, `discrete`, `hover`, `valueLabels`, `continuous` |
|
|
1567
|
+
| `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `valueLabels`, `continuous` |
|
|
1568
|
+
| `nativeButton` | — | `activate` | `variant` (`filled`, `tonal`, `outlined`, `text`), `icon`, `menu`; needs `event` or `menu` |
|
|
1569
|
+
| `nativeSlider` | number | `commit` | `min`, `max`, `step`, `valueLabels`, `continuous` |
|
|
1570
|
+
| `nativeTextField` | string | `commit` on Enter, or on leaving it after typing | `placeholder`, `continuous` |
|
|
1571
|
+
| `nativeDropdown` | string | `commit` | `options`, `placeholder` |
|
|
1572
|
+
| `nativeRadio` | string | `commit` | `options` |
|
|
1573
|
+
| `nativeSegmented` | string | `commit` | `options`, `multi` |
|
|
1574
|
+
| `nativeSwitch`, `nativeCheckbox` | number, 1 or 0 | `commit` | — (shows `label` beside it) |
|
|
1575
|
+
| `nativeChip` | number, 1 or 0; or none | `commit`; `activate` without a value | `icon`; needs `event` without a value |
|
|
1576
|
+
| `nativeText` | number, string, or none | nothing | `style` (a typography role), `align` (`start`, `center`, `end`) |
|
|
1577
|
+
|
|
1578
|
+
The three drawn kinds are invisible hit areas over artwork the extension paints;
|
|
1579
|
+
the native kinds are Material widgets the app paints and themes. Every control
|
|
1580
|
+
may also take:
|
|
1581
|
+
|
|
1582
|
+
- `x`, `y`, `width`, `height`: its rect over the artwork, in scene units.
|
|
1583
|
+
Required for the drawn kinds; a native control omits all four to be placed by
|
|
1584
|
+
`layout` instead, and takes the rect's width and its own height when it has one.
|
|
1585
|
+
- `after`: with a rect, the layer it paints directly after.
|
|
1586
|
+
- `opacity`: 0 to 1, fixed or bound like a layer's. At 0 the control is gone.
|
|
1587
|
+
- `disabled`: shown, but no click, drag, key or screen-reader adjustment acts on it.
|
|
1588
|
+
|
|
1589
|
+
`options` is `[{label, value}]`, up to 64, with each `value` unique. A choice's
|
|
1590
|
+
string is `""` or one of them; a `multi` choice holds its values joined with
|
|
1591
|
+
commas in option order, so no option may contain one. `menu` is
|
|
1592
|
+
`[{label, event}]`, up to 32: picking an item sends its `event` as the action.
|
|
1593
|
+
|
|
1594
|
+
**Layout nodes.** Each is an object with exactly one of these keys:
|
|
1595
|
+
|
|
1596
|
+
| Node | |
|
|
1597
|
+
|---|---|
|
|
1598
|
+
| `{row: [...]}`, `{column: [...]}` | Flutter's `Row` and `Column`. `mainAxisAlignment` (`start`, `center`, `end`, `spaceBetween`, `spaceAround`, `spaceEvenly`), `crossAxisAlignment` (`start`, `center`, `end`, `stretch`), `spacing`. |
|
|
1599
|
+
| `{wrap: [...]}` | Flutter's `Wrap`: `spacing`, `runSpacing`, the same alignments but no `stretch`. |
|
|
1600
|
+
| `{control: "id"}` | A native control without a rect. Each appears exactly once. |
|
|
1601
|
+
| `{scene: true}` | The artwork, scaled to fit. At most once. |
|
|
1602
|
+
| `{spacer: n}` | `Spacer(flex: n)`. |
|
|
1603
|
+
|
|
1604
|
+
Any node may add `width`, `height` and `padding` (one number, or
|
|
1605
|
+
`[start, top, end, bottom]`). A row or column's direct child may add `flex`, with
|
|
1606
|
+
`fit: "tight"` (the default, Flutter's `Expanded`) or `"loose"` (`Flexible`). At
|
|
1607
|
+
most 8 deep and 128 nodes.
|
|
1608
|
+
|
|
1609
|
+
**Events and results.** `onEvent` receives, beside the render args:
|
|
1610
|
+
|
|
1611
|
+
```ts
|
|
1612
|
+
event: {
|
|
1613
|
+
controlId: string // the control's id
|
|
1614
|
+
action: string // its `event`, or the menu item's
|
|
1615
|
+
phase: "activate" | "change" | "commit"
|
|
1616
|
+
sequence: number // increases with every event from this pane
|
|
1617
|
+
value?: number // for a control bound to `values`
|
|
1618
|
+
text?: string // for a control bound to `strings`
|
|
1619
|
+
}
|
|
1620
|
+
```
|
|
1621
|
+
|
|
1622
|
+
It answers `{values, strings?}`: a patch of names already in the scene. An
|
|
1623
|
+
answer with neither is taken as a failure, and so is one that throws or runs out
|
|
1624
|
+
of time: the pane says the action failed, and the control goes back to what the
|
|
1625
|
+
scene last reported. The host sends one event at a time, keeps only the newest
|
|
1626
|
+
of a control's queued changes, ignores an answer to an event a newer one has
|
|
1627
|
+
superseded, and requests a render after each. Action controls are disabled while an
|
|
1628
|
+
action is waiting for its answer; a value being sent does not disable them. A text field's typing is sent before any action, and an
|
|
1629
|
+
action queued behind a value the extension refused is not sent.
|
|
1630
|
+
|
|
1631
|
+
**An example.** A spot form over a status strip:
|
|
1632
|
+
|
|
1633
|
+
```ts
|
|
1634
|
+
const state = { call: "", band: "20m", status: "Nothing spotted yet" }
|
|
1635
|
+
|
|
1636
|
+
export const spotter: PanelHook = {
|
|
1637
|
+
async getPanels() {
|
|
1638
|
+
return [{ key: "spotter", title: "Spotter", icon: "bullhorn" }]
|
|
1639
|
+
},
|
|
1640
|
+
async render() {
|
|
1641
|
+
return {
|
|
1642
|
+
kind: "scene",
|
|
1643
|
+
scene: {
|
|
1644
|
+
version: 1,
|
|
1645
|
+
width: 400,
|
|
1646
|
+
height: 40,
|
|
1647
|
+
values: {},
|
|
1648
|
+
strings: state,
|
|
1649
|
+
layers: [
|
|
1650
|
+
{
|
|
1651
|
+
id: "strip", x: 0, y: 0, width: 400, height: 40,
|
|
1652
|
+
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40"><rect width="400" height="40" rx="8" fill="#142337"/></svg>',
|
|
1653
|
+
},
|
|
1654
|
+
{ id: "status", x: 12, y: 8, width: 376, height: 24, text: { value: "status", size: 14, color: "#a9efcf" } },
|
|
1655
|
+
],
|
|
1656
|
+
controls: [
|
|
1657
|
+
{ id: "call", kind: "nativeTextField", label: "Callsign", value: "call", event: "set" },
|
|
1658
|
+
{
|
|
1659
|
+
id: "band", kind: "nativeDropdown", label: "Band", value: "band", event: "set",
|
|
1660
|
+
options: ["40m", "20m", "15m"].map((b) => ({ label: b, value: b })),
|
|
1661
|
+
},
|
|
1662
|
+
{ id: "spot", kind: "nativeButton", label: "Spot", icon: "bullhorn", event: "spot" },
|
|
1663
|
+
],
|
|
1664
|
+
layout: {
|
|
1665
|
+
column: [
|
|
1666
|
+
{ row: [{ control: "call", flex: 1 }, { control: "band", width: 110 }, { control: "spot" }], spacing: 8 },
|
|
1667
|
+
{ scene: true, flex: 1 },
|
|
1668
|
+
],
|
|
1669
|
+
padding: 12,
|
|
1670
|
+
spacing: 8,
|
|
1671
|
+
crossAxisAlignment: "stretch",
|
|
1672
|
+
},
|
|
1673
|
+
},
|
|
1674
|
+
}
|
|
1675
|
+
},
|
|
1676
|
+
async onEvent({ event }) {
|
|
1677
|
+
if (event.controlId === "call" && event.text !== undefined) state.call = event.text.toUpperCase()
|
|
1678
|
+
if (event.controlId === "band" && event.text !== undefined) state.band = event.text
|
|
1679
|
+
if (event.action === "spot") {
|
|
1680
|
+
if (!state.call) throw new Error("No callsign to spot")
|
|
1681
|
+
state.status = `Spotted ${state.call} on ${state.band}`
|
|
1682
|
+
}
|
|
1683
|
+
return { values: {}, strings: state }
|
|
1684
|
+
},
|
|
1685
|
+
}
|
|
1686
|
+
```
|
|
1687
|
+
|
|
1688
|
+
The callsign comes back upper-cased, and the field shows the extension's
|
|
1689
|
+
version once the typing is committed. A bundle carrying this declares
|
|
1690
|
+
`"api": 5`. The `k2hrc-panel-scenes` sample in the SDK has every kind, a
|
|
1691
|
+
control faded in over the artwork, and a click-through layer painted over it.
|
|
1692
|
+
|
|
1453
1693
|
The `ki2d-weather-panel`, `ki2d-solar-panel` and `ki2d-radio-panel` reference
|
|
1454
1694
|
panels live in
|
|
1455
1695
|
[ham2k/extensions](https://github.com/ham2k/extensions/tree/main/extensions/dashboard).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ham2k/extension-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ham2k",
|
|
@@ -71,12 +71,12 @@
|
|
|
71
71
|
"build": "node build.mjs"
|
|
72
72
|
},
|
|
73
73
|
"//peerDependencies": [
|
|
74
|
-
"The libraries the extension host
|
|
74
|
+
"The libraries the extension host provides. An extension does NOT need these",
|
|
75
75
|
"installed: the build preset rewrites each one the manifest declares into a",
|
|
76
76
|
"lookup on the host's single instance, before esbuild ever resolves it, and",
|
|
77
|
-
"the published .d.ts
|
|
77
|
+
"the published .d.ts has their types rolled in. They are here, optional,",
|
|
78
78
|
"for the one case that does reach the filesystem \u2014 `inline: [...]`, which",
|
|
79
|
-
"ships your own copy because you need a version the host does not
|
|
79
|
+
"ships your own copy because you need a version the host does not provide."
|
|
80
80
|
],
|
|
81
81
|
"peerDependencies": {
|
|
82
82
|
"@ham2k/lib-callsigns": "*",
|
package/samples/README.md
CHANGED
|
@@ -10,7 +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-
|
|
13
|
+
| `k2hrc-panel-scenes` | Experimental panel scenes: SVG artwork, controls and animations, with simulated radio and weather panels |
|
|
14
14
|
|
|
15
15
|
## Running one
|
|
16
16
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Panel Scene Prototypes
|
|
2
2
|
|
|
3
|
-
Experimental radio front face
|
|
4
|
-
`
|
|
3
|
+
Experimental radio front face, weather inspection chart and native controls form, using the public
|
|
4
|
+
`scene` / `PanelHook.onEvent` contracts. Requires a host with panel scene support.
|
|
5
5
|
All data is simulated; this extension does not fetch weather or control a radio.
|
|
6
6
|
|
|
7
7
|
The radio keeps requested frequency separate from reported frequency, simulates
|
|
@@ -16,9 +16,9 @@ In this repository:
|
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
18
|
node build.mjs
|
|
19
|
-
node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-
|
|
19
|
+
node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-panel-scenes.h2kext
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
For the contract and migration plan, see `docs/design/
|
|
22
|
+
For the contract and migration plan, see `docs/design/panel-scenes.md` in the
|
|
23
23
|
logger repository. This is an API experiment,
|
|
24
24
|
not a replacement for the production weather/solar panels yet.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"key": "k2hrc-
|
|
3
|
-
"name": "
|
|
4
|
-
"shortName": "
|
|
2
|
+
"key": "k2hrc-panel-scenes",
|
|
3
|
+
"name": "Panel Scene Prototypes",
|
|
4
|
+
"shortName": "Panel Scenes",
|
|
5
5
|
"version": "0.1.0",
|
|
6
|
-
"description": "Experimental radio front face and
|
|
6
|
+
"description": "Experimental radio front face, weather chart and native controls; simulated data only",
|
|
7
7
|
"category": "dashboard",
|
|
8
8
|
"icon": "radio-tower",
|
|
9
9
|
"accentColor": "#4C6EF5",
|
|
10
|
-
"api":
|
|
10
|
+
"api": 5,
|
|
11
11
|
"keywords": ["svg", "panel", "sample", "weather", "radio"],
|
|
12
12
|
"hooks": ["panel"],
|
|
13
13
|
"sharedDependencies": {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import type { PanelContent, PanelHook, SceneBinding,
|
|
3
|
+
import type { PanelContent, PanelHook, PanelScene, SceneBinding, PanelSceneLayer } from "@ham2k/extension-sdk"
|
|
4
4
|
|
|
5
5
|
const svg = (body: string, width = 640, height = 360) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
|
|
6
6
|
const bind = (value: string, input: [number, number], output: [number, number]): SceneBinding => ({ value, input, output })
|
|
7
7
|
const label = (x: number, y: number, text: string, size = 16, color = "#9aaec6") =>
|
|
8
8
|
`<text x="${x}" y="${y}" fill="${color}" font-family="sans-serif" font-size="${size}">${text}</text>`
|
|
9
9
|
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")}`
|
|
10
|
-
const textLayer = (id: string, value: string, x: number, y: number, width: number, size: number, prefix = "", suffix = "", decimals = 0):
|
|
10
|
+
const textLayer = (id: string, value: string, x: number, y: number, width: number, size: number, prefix = "", suffix = "", decimals = 0): PanelSceneLayer => ({
|
|
11
11
|
id,
|
|
12
12
|
x,
|
|
13
13
|
y,
|
|
@@ -17,9 +17,23 @@ const textLayer = (id: string, value: string, x: number, y: number, width: numbe
|
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
|
|
20
|
+
type FormState = { strings: Record<string, string>; values: Record<string, number> }
|
|
20
21
|
/** Inject time for deterministic tests; no timers or per-frame extension work. */
|
|
21
22
|
export function createScenePanels(now: () => number = () => Date.now()): PanelHook {
|
|
22
23
|
const radios = new Map<string, RadioState>()
|
|
24
|
+
const forms = new Map<string, FormState>()
|
|
25
|
+
function form(id: string): FormState {
|
|
26
|
+
let f = forms.get(id)
|
|
27
|
+
if (!f) {
|
|
28
|
+
if (forms.size >= 64) forms.delete(forms.keys().next().value!)
|
|
29
|
+
f = {
|
|
30
|
+
strings: { call: "", band: "20m", mode: "CW", antenna: "vertical", status: "Nothing spotted yet" },
|
|
31
|
+
values: { power: 100, qrp: 0, cluster: 1, cwOnly: 0, spotted: 0 },
|
|
32
|
+
}
|
|
33
|
+
forms.set(id, f)
|
|
34
|
+
}
|
|
35
|
+
return f
|
|
36
|
+
}
|
|
23
37
|
function state(id: string): RadioState {
|
|
24
38
|
let s = radios.get(id)
|
|
25
39
|
if (!s) {
|
|
@@ -39,14 +53,16 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
|
|
|
39
53
|
return [
|
|
40
54
|
{ key: "radio", title: "Radio Front Face · Simulation", icon: "radio-tower", multiple: true, on: ["tick:1"] },
|
|
41
55
|
{ key: "weather", title: "Weather Chart · Simulation", icon: "weather-partly-cloudy", multiple: true },
|
|
56
|
+
{ key: "controls", title: "Native Controls · Simulation", icon: "form-textbox", multiple: true },
|
|
42
57
|
]
|
|
43
58
|
},
|
|
44
59
|
async render(args): Promise<PanelContent> {
|
|
60
|
+
if (args.panelKey === "controls") return { kind: "scene", scene: controlsScene(form(args.instanceId ?? args.panelKey)) }
|
|
45
61
|
if (args.panelKey === "weather") {
|
|
46
62
|
const temperatures = [17, 18, 21, 24, 25, 23, 20, 18]
|
|
47
63
|
const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(" ")
|
|
48
64
|
return {
|
|
49
|
-
kind: "
|
|
65
|
+
kind: "scene",
|
|
50
66
|
scene: {
|
|
51
67
|
version: 1,
|
|
52
68
|
width: 640,
|
|
@@ -91,7 +107,7 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
|
|
|
91
107
|
}
|
|
92
108
|
const s = state(args.instanceId ?? args.panelKey)
|
|
93
109
|
return {
|
|
94
|
-
kind: "
|
|
110
|
+
kind: "scene",
|
|
95
111
|
scene: {
|
|
96
112
|
version: 1,
|
|
97
113
|
width: 640,
|
|
@@ -213,8 +229,25 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
|
|
|
213
229
|
}
|
|
214
230
|
},
|
|
215
231
|
async onEvent(args) {
|
|
216
|
-
const s = state(args.instanceId ?? args.panelKey)
|
|
217
232
|
const event = args.event
|
|
233
|
+
if (args.panelKey === "controls") {
|
|
234
|
+
const f = form(args.instanceId ?? args.panelKey)
|
|
235
|
+
if (event.text !== undefined) f.strings[event.controlId] = event.text
|
|
236
|
+
if (event.value !== undefined) f.values[event.controlId] = event.value
|
|
237
|
+
if (event.action === "spot") {
|
|
238
|
+
const call = f.strings.call.trim().toUpperCase()
|
|
239
|
+
if (!call) throw new Error("No callsign to spot")
|
|
240
|
+
f.strings.call = call
|
|
241
|
+
f.values.spotted = 1
|
|
242
|
+
f.strings.status = `Spotted ${call} on ${f.strings.band} ${f.strings.mode}, ${f.values.qrp ? 5 : f.values.power} W`
|
|
243
|
+
}
|
|
244
|
+
if (event.action === "clear") {
|
|
245
|
+
f.values.spotted = 0
|
|
246
|
+
f.strings.status = "Nothing spotted yet"
|
|
247
|
+
}
|
|
248
|
+
return { values: f.values, strings: f.strings }
|
|
249
|
+
}
|
|
250
|
+
const s = state(args.instanceId ?? args.panelKey)
|
|
218
251
|
if (event.action === "connect") {
|
|
219
252
|
s.connected = 1 - s.connected
|
|
220
253
|
s.due = 0
|
|
@@ -241,3 +274,88 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
|
|
|
241
274
|
},
|
|
242
275
|
}
|
|
243
276
|
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Every native control kind, placed by a layout around a strip of artwork. Clear sits
|
|
280
|
+
* over the strip instead: ordered among its layers, under a click-through sheen, and
|
|
281
|
+
* faded out until there is something to clear.
|
|
282
|
+
*/
|
|
283
|
+
function controlsScene(f: FormState): PanelScene {
|
|
284
|
+
return {
|
|
285
|
+
version: 1,
|
|
286
|
+
width: 640,
|
|
287
|
+
height: 60,
|
|
288
|
+
values: f.values,
|
|
289
|
+
strings: f.strings,
|
|
290
|
+
layers: [
|
|
291
|
+
{ id: "strip", x: 0, y: 0, width: 640, height: 60, svg: svg('<rect width="640" height="60" rx="10" fill="#142337"/>', 640, 60) },
|
|
292
|
+
{ id: "status", x: 16, y: 16, width: 490, height: 28, text: { value: "status", size: 16, color: "#a9efcf" } },
|
|
293
|
+
{
|
|
294
|
+
id: "sheen",
|
|
295
|
+
x: 0,
|
|
296
|
+
y: 0,
|
|
297
|
+
width: 640,
|
|
298
|
+
height: 30,
|
|
299
|
+
svg: svg('<rect width="640" height="30" rx="10" fill="#ffffff" fill-opacity="0.08"/>', 640, 30),
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
controls: [
|
|
303
|
+
{ id: "call", kind: "nativeTextField", label: "Callsign", placeholder: "W1AW", value: "call", event: "set" },
|
|
304
|
+
{
|
|
305
|
+
id: "band",
|
|
306
|
+
kind: "nativeDropdown",
|
|
307
|
+
label: "Band",
|
|
308
|
+
value: "band",
|
|
309
|
+
event: "set",
|
|
310
|
+
options: ["40m", "20m", "15m", "10m"].map((b) => ({ label: b, value: b })),
|
|
311
|
+
},
|
|
312
|
+
{ id: "mode", kind: "nativeSegmented", label: "Mode", value: "mode", event: "set", options: ["CW", "SSB", "FT8"].map((m) => ({ label: m, value: m })) },
|
|
313
|
+
{ id: "spot", kind: "nativeButton", label: "Spot", icon: "bullhorn", event: "spot" },
|
|
314
|
+
{
|
|
315
|
+
id: "clear",
|
|
316
|
+
kind: "nativeButton",
|
|
317
|
+
label: "Clear",
|
|
318
|
+
variant: "outlined",
|
|
319
|
+
event: "clear",
|
|
320
|
+
after: "status",
|
|
321
|
+
x: 520,
|
|
322
|
+
y: 10,
|
|
323
|
+
width: 110,
|
|
324
|
+
height: 40,
|
|
325
|
+
opacity: { value: "spotted", input: [0, 1], output: [0, 1] },
|
|
326
|
+
},
|
|
327
|
+
{ id: "power", kind: "nativeSlider", label: "Power, watts", value: "power", event: "set", min: 5, max: 100, step: 5, disabled: f.values.qrp === 1 },
|
|
328
|
+
{ id: "qrp", kind: "nativeSwitch", label: "QRP", value: "qrp", event: "set" },
|
|
329
|
+
{ id: "cluster", kind: "nativeCheckbox", label: "Send to cluster", value: "cluster", event: "set" },
|
|
330
|
+
{
|
|
331
|
+
id: "antenna",
|
|
332
|
+
kind: "nativeRadio",
|
|
333
|
+
label: "Antenna",
|
|
334
|
+
value: "antenna",
|
|
335
|
+
event: "set",
|
|
336
|
+
options: [
|
|
337
|
+
{ label: "Vertical", value: "vertical" },
|
|
338
|
+
{ label: "Dipole", value: "dipole" },
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
{ id: "cw", kind: "nativeChip", label: "CW only", value: "cwOnly", icon: "filter" },
|
|
342
|
+
{ id: "powerText", kind: "nativeText", label: "Power", value: "power", style: "mono", align: "end" },
|
|
343
|
+
],
|
|
344
|
+
layout: {
|
|
345
|
+
column: [
|
|
346
|
+
{ row: [{ control: "call", flex: 1 }, { control: "band", width: 110 }, { control: "mode" }], spacing: 8 },
|
|
347
|
+
{ scene: true, flex: 1 },
|
|
348
|
+
{
|
|
349
|
+
wrap: [{ control: "qrp" }, { control: "cluster" }, { control: "antenna" }, { control: "cw" }],
|
|
350
|
+
spacing: 12,
|
|
351
|
+
runSpacing: 4,
|
|
352
|
+
crossAxisAlignment: "center",
|
|
353
|
+
},
|
|
354
|
+
{ row: [{ control: "power", flex: 1 }, { control: "powerText", width: 48 }, { control: "spot" }], spacing: 8 },
|
|
355
|
+
],
|
|
356
|
+
padding: 12,
|
|
357
|
+
spacing: 8,
|
|
358
|
+
crossAxisAlignment: "stretch",
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|