@ham2k/extension-sdk 0.9.1 → 0.10.0

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 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-svg-scenes` (experimental interactive SVG) |
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.9.1
2
- /** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
3
- export interface SvgScene {
1
+ // @ham2k/extension-sdk 0.10.0
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
- layers: SvgSceneLayer[];
9
- controls?: SvgSceneControl[];
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 SvgSceneLayer {
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
- /** Exactly one of literal/value. Use text layers, not SVG <text>, for font support.
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
- export interface SvgSceneControl {
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: "button" | "slider" | "knob";
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
- x: number;
88
- y: number;
89
- width: number;
90
- height: number;
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
- /** Send coalesced change events during dragging, plus the final commit. */
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 {
@@ -818,6 +943,7 @@ export interface CommandAction {
818
943
  qsoCount?: number;
819
944
  };
820
945
  devLogCat?: Record<string, never>;
946
+ devMapInfo?: Record<string, never>;
821
947
  toggleExperiment?: {
822
948
  token: string;
823
949
  };
@@ -1411,7 +1537,7 @@ export interface PanelRadioTuneResult {
1411
1537
  state: PanelRadioState | null;
1412
1538
  }
1413
1539
  export interface PanelRenderArgs {
1414
- /** Present on environment-capable hosts. Changes automatically request a throttled render of an `svgScene` panel. */
1540
+ /** Present on environment-capable hosts. Changes automatically request a throttled render of a `scene` panel. */
1415
1541
  environment?: PanelEnvironment;
1416
1542
  /** Display time follows developer time travel; real time is for network retry/cache budgets. */
1417
1543
  clock?: {
@@ -1428,8 +1554,18 @@ export interface PanelRenderArgs {
1428
1554
  reason: string;
1429
1555
  }
1430
1556
  export type PanelContent = {
1557
+ kind: "scene";
1558
+ scene: PanelScene;
1559
+ title?: string;
1560
+ triggers?: string[];
1561
+ }
1562
+ /**
1563
+ * @deprecated The same kind as `scene`, under its name through SDK 0.9. Apps before extension
1564
+ * API 5 know only `svgScene`; `scene` needs `"api": 5`, as do native controls, `strings` and `layout`.
1565
+ */
1566
+ | {
1431
1567
  kind: "svgScene";
1432
- scene: SvgScene;
1568
+ scene: PanelScene;
1433
1569
  title?: string;
1434
1570
  triggers?: string[];
1435
1571
  } | {
@@ -1733,6 +1869,21 @@ export interface ReferenceActivityHooks {
1733
1869
  adifImportHook: AdifImportHook;
1734
1870
  }
1735
1871
  export declare function referenceActivity(config: ReferenceActivityConfig): ReferenceActivityHooks;
1872
+ export interface NearbyLookupHost {
1873
+ dbLookupSelectByLocation(category: string, lat: number, lon: number, delta: number, activeOnly?: boolean): Promise<LookupRow[]>;
1874
+ dbLookupSelectInBounds(category: string, bounds: MapBounds, options?: {
1875
+ activeOnly?: boolean;
1876
+ limit?: number;
1877
+ }): Promise<LookupRow[]>;
1878
+ }
1879
+ export declare const VIEWPORT_LIMIT = 500;
1880
+ export declare function nearbyLookupRows(host: NearbyLookupHost, category: string, { location, bounds }: Pick<SuggestArgs, "location" | "bounds">, { delta, activeOnly }: {
1881
+ delta: number;
1882
+ activeOnly?: boolean;
1883
+ }): Promise<{
1884
+ rows: LookupRow[];
1885
+ inViewport: boolean;
1886
+ }>;
1736
1887
  export declare class TemplateError extends Error {
1737
1888
  readonly cause?: unknown;
1738
1889
  constructor(message: string, options?: {
@@ -1836,6 +1987,10 @@ export declare const host: {
1836
1987
  dbLookupSelectOne(category: string, key: string): Promise<LookupRow | null>;
1837
1988
  dbLookupSelectAll(category: string, query: string, subCategory?: string, activeOnly?: boolean): Promise<LookupRow[]>;
1838
1989
  dbLookupSelectByLocation(category: string, lat: number, lon: number, delta: number, activeOnly?: boolean): Promise<LookupRow[]>;
1990
+ dbLookupSelectInBounds(category: string, bounds: MapBounds, options?: {
1991
+ activeOnly?: boolean;
1992
+ limit?: number;
1993
+ }): Promise<LookupRow[]>;
1839
1994
  showForm(form: FormDefinition, state?: Record<string, any>): Promise<Record<string, any> | null>;
1840
1995
  statusBar: {
1841
1996
  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 "./svgScene.js";
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) : location ? await host.dbLookupSelectByLocation(category, location.lat, location.lon, NEARBY_DELTA) : [];
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,
@@ -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 4)
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 under the
403
- catalog experiment is the core extensions and nothing else. `ham2k-lookup`
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
- An install that has been running the app's own extensions is offered the move
416
- at start, and again the moment the catalog experiment is switched on: that
417
- switch is the operator asking for catalog extensions, and being made to
418
- relaunch before anything is offered reads as nothing having happened. The
419
- switch withdraws nothing by itself; only a taken upgrade does, and the offer
420
- may be deferred either way. A catalog that cannot be reached says so in the
421
- footer when the operator asked, and says nothing at a launch nobody was
422
- watching.
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` —
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`' POWER command
50
- checks `gato` here; its BAND/MODE/frequency siblings don't).
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, and whatever equivalent another
477
- program publishes. Same contract as `linkForRef`: build the URL from the
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. Neither fetches a shape per search hit — a search
501
- answers with dozens of references, and at the zoom that shows dozens of them a
502
- footprint is a few pixels across. Implemented by: `sota` (both types, via
503
- SOTLAS's `az.sotl.as`, used with the operators' permission).
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). Called with no
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
@@ -1389,17 +1413,25 @@ interface PanelHook {
1389
1413
  // PanelDescriptor: {key, title, description?, icon?, preview?, on?, form?}
1390
1414
  // PanelRenderArgs: {panelKey, operation, qso?, qsoCount, config, reason}
1391
1415
  // PanelContent: {kind: 'markdown' | 'svg' | 'html', content, title?, triggers?}
1416
+ // | {kind: 'scene', scene, title?, triggers?} (see Panel scene reference)
1392
1417
  ```
1393
1418
 
1394
1419
  The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
1395
1420
  inside saved layouts — renaming a `key` drops the panel out of every
1396
1421
  arrangement holding it.
1397
1422
 
1398
- An experimental fourth kind, `svgScene`, adds native SVG layers, numeric bindings,
1399
- local controls, and host-run animations. Its payload is `{kind: 'svgScene', scene,
1400
- title?, triggers?}`; it does not have a `content` string. Scene-capable hosts pass
1423
+ An experimental fourth kind, the **panel scene**, adds SVG and text layers, numeric
1424
+ bindings, local controls, native Material controls, a layout, and host-run animations.
1425
+ Its payload is `{kind: 'scene', scene, title?, triggers?}`; it does not have a
1426
+ `content` string. `kind: 'svgScene'` is the same kind under its name through SDK 0.9,
1427
+ which the SDK keeps as a deprecated alias along with the `SvgScene*` type names.
1428
+ **`scene` and everything it adds are extension API 5**: native controls,
1429
+ `strings`, `layout`, `after` and control `opacity`. A bundle using any of them
1430
+ declares `"api": 5` ([distribution.md](distribution.md)), and an app speaking
1431
+ less refuses it at install rather than showing the panel as an unknown kind.
1432
+ A plain scene that must reach those apps keeps `svgScene` and its lower API. Scene-capable hosts pass
1401
1433
  `instanceId` on render/event calls. `onEvent` handles control events and returns
1402
- numeric value patches; local-only controls and animation frames make no bridge
1434
+ value patches (`values`, and `strings` for string controls); local-only controls and animation frames make no bridge
1403
1435
  calls. Controls with `continuous: true` send `change` events during dragging and a
1404
1436
  final `commit` on release; pending movements are coalesced to the newest value.
1405
1437
  Numeric text supports `scale`, `truncate`, `modulo`, and `minIntegerDigits`
@@ -1410,8 +1442,8 @@ may hold numbers or preformatted strings; control `valueLabels` supplies accessi
1410
1442
  value descriptions. Transform/opacity bindings may also supply numeric `samples`,
1411
1443
  equally spaced over their `input` range, instead of the `output` ramp. The host
1412
1444
  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/svg-scenes.md)
1414
- and the `k2hrc-svg-scenes` sample. This API is experimental.
1445
+ individual readings without bridge calls. See [the design notes and open milestones](https://github.com/ham2k/halo/blob/main/docs/design/panel-scenes.md)
1446
+ and the `k2hrc-panel-scenes` sample. This API is experimental.
1415
1447
 
1416
1448
  Scene-capable hosts also supply `args.environment` on **render and event** calls:
1417
1449
 
@@ -1432,7 +1464,7 @@ Scene-capable hosts also supply `args.environment` on **render and event** calls
1432
1464
  describe the resolved presentation context. Font names refer to host fonts;
1433
1465
  they are not URLs or permission to load remote fonts.
1434
1466
 
1435
- For a panel showing an `svgScene`, environment changes automatically request a
1467
+ For a panel showing a scene, environment changes automatically request a
1436
1468
  render through the existing throttle, even with no triggers declared. Hidden
1437
1469
  panels catch up on reveal; repeated resize updates coalesce; a scene laid out for
1438
1470
  an old environment cannot overwrite a newer one. Markdown and HTML panels are
@@ -1450,6 +1482,202 @@ Button controls can supply `menu: [{label, event}]` to open a native dropdown.
1450
1482
  Selecting an item emits its `event` as the action with the original control ID;
1451
1483
  dismissing the menu emits nothing.
1452
1484
 
1485
+ **Native controls.** Besides the drawn `button`, `slider` and `knob` — invisible
1486
+ hit areas over the extension's own artwork — a scene can use Material widgets
1487
+ the app themes itself: `nativeButton`, `nativeSlider`, `nativeTextField`,
1488
+ `nativeDropdown`, `nativeSwitch`, `nativeCheckbox`, `nativeRadio`,
1489
+ `nativeSegmented`, `nativeChip` and `nativeText`. `PanelSceneControl` in the SDK
1490
+ lists what each kind takes. String-valued controls (text field, dropdown, radio,
1491
+ segmented) bind to the scene's `strings` map, which sits beside `values`; a key
1492
+ is in one or the other, never both; a choice's string is `""` or one of its
1493
+ `options` (each comma-separated part, for `multi`). Their events carry `text` instead of `value`,
1494
+ and an `onEvent` result may return `strings` as well as `values`. A text layer
1495
+ whose `value` names a string shows it as-is. A text field commits on Enter, or
1496
+ on leaving it after typing; a render that lands while the operator is typing
1497
+ does not overwrite the typing. Every control's `label` is its accessible name;
1498
+ the app has no tooltips. A text field or dropdown in a panel keeps keyboard
1499
+ focus; clicking any other native control hands focus back to the callsign
1500
+ field, as clicking a button anywhere else in the Operation view does.
1501
+
1502
+ **Layout.** A native control either has an `x`/`y`/`width`/`height` rect over the
1503
+ artwork, like the drawn kinds — it takes the rect's width and its own height, up
1504
+ to the rect's, so give a field a rect tall enough for it — or omits all four and is placed by
1505
+ `scene.layout` — a tree of Flutter's `row`, `column` and `wrap`, with `flex`/`fit`
1506
+ (Expanded and Flexible), `width`/`height`, `padding`, `spacing`, the two axis
1507
+ alignments, `{spacer: flex}`, `{control: id}`, and `{scene: true}` for the
1508
+ artwork, scaled to fit. Sizes are logical pixels, like the render environment's.
1509
+ Every rect-less control appears in the layout exactly once; a layout with no
1510
+ `scene` node shows no artwork, so it may declare no layers either. A text field
1511
+ or dropdown given no width (a row child without `flex` or `width`) is 200 wide,
1512
+ and a row's children that do not fit overflow as a Flutter `Row`'s do — use
1513
+ `flex` or `wrap`. A `flex` only shares space its row or column has: in a row
1514
+ that is itself an unflexed child of a row, there is none, and its children lay
1515
+ out at their own size. The layout is at most 8 deep and 128 nodes.
1516
+
1517
+ **Controls among the layers.** Layers paint in order, and a control with a rect
1518
+ paints above all of them unless its `after` names a layer: then it paints
1519
+ directly after that one, and later layers draw over it. Layers are
1520
+ click-through: art painted over a control does not stop it working. A layer with
1521
+ `blocksPointer: true` takes the clicks that land on it while it is visible,
1522
+ covering the controls under it (keyboard focus still reaches them; mark them
1523
+ `disabled` too to shut them off). Only a pending action disables action
1524
+ controls; a value being sent does not. Any control can take an `opacity`, fixed or bound like a layer's;
1525
+ at 0 it takes no clicks, focus or screen-reader node.
1526
+
1527
+ ### Panel scene reference
1528
+
1529
+ The schema in one place; `panelScene.ts` in the SDK carries the same as types.
1530
+ Every rule below is checked when the scene arrives: a scene that breaks one is
1531
+ refused whole, and the pane shows the error naming what is wrong, rather than
1532
+ drawing part of it.
1533
+
1534
+ **The scene document**
1535
+
1536
+ | Field | |
1537
+ |---|---|
1538
+ | `version` | `1` |
1539
+ | `width`, `height` | The artwork's viewBox, in scene units. Size it to `environment.width`/`height` and a scene unit is a logical pixel. |
1540
+ | `values` | `{name: number}`: what bindings, numeric text and numeric controls read. At most 256. |
1541
+ | `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. |
1542
+ | `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. |
1543
+ | `controls` | At most 64. Drawn or native; see below. |
1544
+ | `layout` | Optional. Where rect-less native controls and the artwork go; omitted, the artwork fills the pane. |
1545
+
1546
+ **Controls.** Every control has an `id` and a `label`, which is its accessible
1547
+ name. An `event` names the action sent when it is used; without one the control
1548
+ is local-only: its value changes on screen, survives renders, and never reaches
1549
+ the extension. `value` names what it shows and sets.
1550
+
1551
+ | Kind | Binds to | Sends | Also takes |
1552
+ |---|---|---|---|
1553
+ | `button` | — | `activate` | `menu` |
1554
+ | `slider` | number | `commit` | `min`, `max`, `step`, `discrete`, `hover`, `valueLabels`, `continuous` |
1555
+ | `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `valueLabels`, `continuous` |
1556
+ | `nativeButton` | — | `activate` | `variant` (`filled`, `tonal`, `outlined`, `text`), `icon`, `menu`; needs `event` or `menu` |
1557
+ | `nativeSlider` | number | `commit` | `min`, `max`, `step`, `valueLabels`, `continuous` |
1558
+ | `nativeTextField` | string | `commit` on Enter, or on leaving it after typing | `placeholder`, `continuous` |
1559
+ | `nativeDropdown` | string | `commit` | `options`, `placeholder` |
1560
+ | `nativeRadio` | string | `commit` | `options` |
1561
+ | `nativeSegmented` | string | `commit` | `options`, `multi` |
1562
+ | `nativeSwitch`, `nativeCheckbox` | number, 1 or 0 | `commit` | — (shows `label` beside it) |
1563
+ | `nativeChip` | number, 1 or 0; or none | `commit`; `activate` without a value | `icon`; needs `event` without a value |
1564
+ | `nativeText` | number, string, or none | nothing | `style` (a typography role), `align` (`start`, `center`, `end`) |
1565
+
1566
+ The three drawn kinds are invisible hit areas over artwork the extension paints;
1567
+ the native kinds are Material widgets the app paints and themes. Every control
1568
+ may also take:
1569
+
1570
+ - `x`, `y`, `width`, `height`: its rect over the artwork, in scene units.
1571
+ Required for the drawn kinds; a native control omits all four to be placed by
1572
+ `layout` instead, and takes the rect's width and its own height when it has one.
1573
+ - `after`: with a rect, the layer it paints directly after.
1574
+ - `opacity`: 0 to 1, fixed or bound like a layer's. At 0 the control is gone.
1575
+ - `disabled`: shown, but no click, drag, key or screen-reader adjustment acts on it.
1576
+
1577
+ `options` is `[{label, value}]`, up to 64, with each `value` unique. A choice's
1578
+ string is `""` or one of them; a `multi` choice holds its values joined with
1579
+ commas in option order, so no option may contain one. `menu` is
1580
+ `[{label, event}]`, up to 32: picking an item sends its `event` as the action.
1581
+
1582
+ **Layout nodes.** Each is an object with exactly one of these keys:
1583
+
1584
+ | Node | |
1585
+ |---|---|
1586
+ | `{row: [...]}`, `{column: [...]}` | Flutter's `Row` and `Column`. `mainAxisAlignment` (`start`, `center`, `end`, `spaceBetween`, `spaceAround`, `spaceEvenly`), `crossAxisAlignment` (`start`, `center`, `end`, `stretch`), `spacing`. |
1587
+ | `{wrap: [...]}` | Flutter's `Wrap`: `spacing`, `runSpacing`, the same alignments but no `stretch`. |
1588
+ | `{control: "id"}` | A native control without a rect. Each appears exactly once. |
1589
+ | `{scene: true}` | The artwork, scaled to fit. At most once. |
1590
+ | `{spacer: n}` | `Spacer(flex: n)`. |
1591
+
1592
+ Any node may add `width`, `height` and `padding` (one number, or
1593
+ `[start, top, end, bottom]`). A row or column's direct child may add `flex`, with
1594
+ `fit: "tight"` (the default, Flutter's `Expanded`) or `"loose"` (`Flexible`). At
1595
+ most 8 deep and 128 nodes.
1596
+
1597
+ **Events and results.** `onEvent` receives, beside the render args:
1598
+
1599
+ ```ts
1600
+ event: {
1601
+ controlId: string // the control's id
1602
+ action: string // its `event`, or the menu item's
1603
+ phase: "activate" | "change" | "commit"
1604
+ sequence: number // increases with every event from this pane
1605
+ value?: number // for a control bound to `values`
1606
+ text?: string // for a control bound to `strings`
1607
+ }
1608
+ ```
1609
+
1610
+ It answers `{values, strings?}`: a patch of names already in the scene. An
1611
+ answer with neither is taken as a failure, and so is one that throws or runs out
1612
+ of time: the pane says the action failed, and the control goes back to what the
1613
+ scene last reported. The host sends one event at a time, keeps only the newest
1614
+ of a control's queued changes, ignores an answer to an event a newer one has
1615
+ superseded, and requests a render after each. Action controls are disabled while an
1616
+ 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
1617
+ action queued behind a value the extension refused is not sent.
1618
+
1619
+ **An example.** A spot form over a status strip:
1620
+
1621
+ ```ts
1622
+ const state = { call: "", band: "20m", status: "Nothing spotted yet" }
1623
+
1624
+ export const spotter: PanelHook = {
1625
+ async getPanels() {
1626
+ return [{ key: "spotter", title: "Spotter", icon: "bullhorn" }]
1627
+ },
1628
+ async render() {
1629
+ return {
1630
+ kind: "scene",
1631
+ scene: {
1632
+ version: 1,
1633
+ width: 400,
1634
+ height: 40,
1635
+ values: {},
1636
+ strings: state,
1637
+ layers: [
1638
+ {
1639
+ id: "strip", x: 0, y: 0, width: 400, height: 40,
1640
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40"><rect width="400" height="40" rx="8" fill="#142337"/></svg>',
1641
+ },
1642
+ { id: "status", x: 12, y: 8, width: 376, height: 24, text: { value: "status", size: 14, color: "#a9efcf" } },
1643
+ ],
1644
+ controls: [
1645
+ { id: "call", kind: "nativeTextField", label: "Callsign", value: "call", event: "set" },
1646
+ {
1647
+ id: "band", kind: "nativeDropdown", label: "Band", value: "band", event: "set",
1648
+ options: ["40m", "20m", "15m"].map((b) => ({ label: b, value: b })),
1649
+ },
1650
+ { id: "spot", kind: "nativeButton", label: "Spot", icon: "bullhorn", event: "spot" },
1651
+ ],
1652
+ layout: {
1653
+ column: [
1654
+ { row: [{ control: "call", flex: 1 }, { control: "band", width: 110 }, { control: "spot" }], spacing: 8 },
1655
+ { scene: true, flex: 1 },
1656
+ ],
1657
+ padding: 12,
1658
+ spacing: 8,
1659
+ crossAxisAlignment: "stretch",
1660
+ },
1661
+ },
1662
+ }
1663
+ },
1664
+ async onEvent({ event }) {
1665
+ if (event.controlId === "call" && event.text !== undefined) state.call = event.text.toUpperCase()
1666
+ if (event.controlId === "band" && event.text !== undefined) state.band = event.text
1667
+ if (event.action === "spot") {
1668
+ if (!state.call) throw new Error("No callsign to spot")
1669
+ state.status = `Spotted ${state.call} on ${state.band}`
1670
+ }
1671
+ return { values: {}, strings: state }
1672
+ },
1673
+ }
1674
+ ```
1675
+
1676
+ The callsign comes back upper-cased, and the field shows the extension's
1677
+ version once the typing is committed. A bundle carrying this declares
1678
+ `"api": 5`. The `k2hrc-panel-scenes` sample in the SDK has every kind, a
1679
+ control faded in over the artwork, and a click-through layer painted over it.
1680
+
1453
1681
  The `ki2d-weather-panel`, `ki2d-solar-panel` and `ki2d-radio-panel` reference
1454
1682
  panels live in
1455
1683
  [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.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
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-svg-scenes` | Experimental SVG controls and animations, with simulated radio and weather panels |
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
- # SVG Scene Prototypes
1
+ # Panel Scene Prototypes
2
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.
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-svg-scenes.h2kext
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/svg-scenes.md` in the
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-svg-scenes",
3
- "name": "SVG Scene Prototypes",
4
- "shortName": "SVG Scenes",
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 weather chart; simulated data only",
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": 1,
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, SvgSceneLayer } from "@ham2k/extension-sdk"
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): SvgSceneLayer => ({
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: "svgScene",
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: "svgScene",
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