@ham2k/extension-sdk 0.12.0 → 0.12.5

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/dist/dxcc.js CHANGED
@@ -1,9 +1,22 @@
1
1
  import { parseCallsign } from "@ham2k/lib-callsigns";
2
- import { annotateFromCountryFile, useBuiltinCountryFile } from "@ham2k/lib-country-files";
2
+ import { annotateFromCountryFile, BIGCTY, useBuiltinCountryFile } from "@ham2k/lib-country-files";
3
3
  import { DXCC_BY_CODE } from "@ham2k/lib-dxcc-data";
4
4
  useBuiltinCountryFile();
5
5
  function annotateCallAgainstCountryFile(call, options) {
6
- return annotateFromCountryFile(parseCallsign(call), options);
6
+ const annotated = annotateFromCountryFile(parseCallsign(call), options);
7
+ if (!WEST_POSITIVE) return annotated;
8
+ return {
9
+ ...annotated,
10
+ ...typeof annotated.lon === "number" ? { lon: 0 - annotated.lon } : {},
11
+ ...annotated.tz ? { tz: flippedTz(annotated.tz) } : {}
12
+ };
13
+ }
14
+ const WEST_POSITIVE = (BIGCTY.entities.K?.lon ?? 0) > 0;
15
+ function flippedTz(tz) {
16
+ const match = /^GMT([+-]?\d+(?:\.\d+)?)$/.exec(tz);
17
+ if (!match) return tz;
18
+ const offset = 0 - Number(match[1]);
19
+ return offset > 0 ? `GMT+${offset}` : `GMT${offset}`;
7
20
  }
8
21
  function entityPrefixForCall(call) {
9
22
  if (!call) return void 0;
@@ -76,6 +76,7 @@ function resolveExportSettings(definition, global = {}, overrides = {}) {
76
76
  }
77
77
  return resolved;
78
78
  }
79
+ const PRIVATE_STATION_FIELDS = ["name", "city", "street", "postal", "email", "qslVia", "url", "notes", "lat", "lon", "multiIdentifier"];
79
80
  function exportQso(qso, settings) {
80
81
  const copy = { ...qso };
81
82
  for (const side of ["their", "our"]) {
@@ -83,11 +84,11 @@ function exportQso(qso, settings) {
83
84
  const station = { ...copy[side] };
84
85
  if (settings.includeLookupData === false) delete station.guess;
85
86
  if (settings.includePrivateData === false) {
86
- for (const key of ["name", "city", "email", "notes", "lat", "lon", "multiIdentifier"]) delete station[key];
87
+ for (const key of PRIVATE_STATION_FIELDS) delete station[key];
87
88
  if (typeof station.grid === "string") station.grid = station.grid.slice(0, 6);
88
89
  if (station.guess && typeof station.guess === "object") {
89
90
  const guess = { ...station.guess };
90
- for (const key of ["name", "city", "email", "notes", "lat", "lon", "multiIdentifier"]) delete guess[key];
91
+ for (const key of PRIVATE_STATION_FIELDS) delete guess[key];
91
92
  if (typeof guess.grid === "string") guess.grid = guess.grid.slice(0, 6);
92
93
  station.guess = guess;
93
94
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.12.0
1
+ // @ham2k/extension-sdk 0.12.5
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -97,11 +97,193 @@ export interface PanelSceneLayer {
97
97
  * - `nativeSwitch`, `nativeCheckbox`: a number, 1 or 0. Shows `label` beside it.
98
98
  * - `nativeChip`: with `value`, a toggle (1 or 0); without, an action that sends `activate`.
99
99
  * - `nativeText`: shows `label`, or its bound value. Sends nothing.
100
+ * - `spectrum`: the radio's panadapter and waterfall, drawn by the host from sweeps that never
101
+ * reach the extension. Needs `requiresRadioRead`; a host without the feature, or a radio
102
+ * without a scope, shows why instead. A click sends `commit` with the frequency as `value`.
103
+ * Takes no `value`; see `PanelSceneSpectrum` for the rest. Extension API 6: declare `"api": 6`.
100
104
  *
101
105
  * `label` is every control's accessible name. There are no tooltips.
102
106
  */
103
- export type PanelSceneControlKind = "button" | "slider" | "knob" | "nativeButton" | "nativeSlider" | "nativeTextField" | "nativeDropdown" | "nativeSwitch" | "nativeCheckbox" | "nativeRadio" | "nativeSegmented" | "nativeChip" | "nativeText";
104
- export interface PanelSceneControl {
107
+ export type PanelSceneControlKind = "button" | "slider" | "knob" | "nativeButton" | "nativeSlider" | "nativeTextField" | "nativeDropdown" | "nativeSwitch" | "nativeCheckbox" | "nativeRadio" | "nativeSegmented" | "nativeChip" | "nativeText" | "spectrum";
108
+ /** A literal, or the name of a scene value — which a slider with no `event` retunes with no bridge call. */
109
+ export type PanelSceneSpectrumNumber = number | string;
110
+ /**
111
+ * A `spectrum` control's display. All of it happens on the host after the sweep arrives, so it
112
+ * means the same on every radio; the radio's own scope (span, reference level, sweep speed) is
113
+ * `host.setRadioSpectrum`'s. Colors are `#RRGGBB`, or `#AARRGGBB` with transparency. Defaults
114
+ * follow the app's theme.
115
+ */
116
+ export interface PanelSceneSpectrum {
117
+ /** A radio's id from `listRadios`. Omitted, the designated radio. */
118
+ radio?: string;
119
+ /** Default `both`. */
120
+ display?: "both" | "pan" | "waterfall";
121
+ /**
122
+ * Which way the waterfall moves; the panadapter sits on the side it comes from. Frequency rises
123
+ * left to right for `down` (the default) and `up`, bottom to top for `right` and `left`. Text
124
+ * stays upright; offsets and `corner`s are measured as if flowing down.
125
+ */
126
+ flow?: "down" | "up" | "right" | "left";
127
+ /** The panadapter's share of the height with `both`, 0.1–0.9. Default 0.4. */
128
+ split?: number;
129
+ /** dB added to the radio's own floor. */
130
+ floor?: PanelSceneSpectrumNumber;
131
+ /** The panadapter's height in dB. Omitted, the radio's. */
132
+ range?: PanelSceneSpectrumNumber;
133
+ /** Written as the pointer moves: the frequency and level under it. */
134
+ hoverHz?: string;
135
+ hoverDb?: string;
136
+ /** 0–99: the share of the trace each sweep keeps from the last. Default 40. The waterfall stays raw. */
137
+ average?: PanelSceneSpectrumNumber;
138
+ /** The most times a second the panadapter redraws, 1–60. Default 15. The waterfall keeps its own pace. */
139
+ fps?: PanelSceneSpectrumNumber;
140
+ /** Waterfall rows a second, 0.1–60; each row keeps the loudest reading since the last. Omitted, one per sweep. */
141
+ waterfallSpeed?: PanelSceneSpectrumNumber;
142
+ /** The waterfall's own dB window, like `floor` and `range`. Omitted, the panadapter's. */
143
+ waterfallFloor?: PanelSceneSpectrumNumber;
144
+ waterfallRange?: PanelSceneSpectrumNumber;
145
+ /** Default `classic`. */
146
+ palette?: "classic" | "gray" | "heat";
147
+ /** 2 to 8 stops, lowest level first, in place of `palette`. */
148
+ waterfallColors?: string[];
149
+ traceColor?: string;
150
+ /** 0.25–8. Default 1. */
151
+ traceWidth?: PanelSceneSpectrumNumber;
152
+ /** Under the trace. With `fillColorBottom`, a gradient from the top of the panadapter to its bottom. */
153
+ fillColor?: string;
154
+ fillColorBottom?: string;
155
+ backgroundColor?: string;
156
+ /** The panadapter's dB and frequency lines. */
157
+ gridColor?: string;
158
+ /** The frequency lines carried down through the waterfall. Omitted, none. */
159
+ waterfallGridColor?: string;
160
+ /** A click tunes to the nearest multiple of this many Hz. Omitted, wherever it lands. */
161
+ tuneStep?: PanelSceneSpectrumNumber;
162
+ /** Dashes the panadapter's grid lines. */
163
+ gridDashed?: boolean;
164
+ /** The gap between the panadapter and the waterfall, with frequency labels and ticks. `true` takes the defaults. */
165
+ scale?: boolean | PanelSceneSpectrumScale;
166
+ /** The tuned-frequency line, styled per part; a part it leaves out has none. Omitted, the theme's on both. */
167
+ center?: {
168
+ pan?: PanelSceneSpectrumStyle;
169
+ waterfall?: PanelSceneSpectrumStyle;
170
+ };
171
+ /** Up to 16 lines and areas at Hz offsets — a passband, a CW offset. */
172
+ overlays?: PanelSceneSpectrumOverlay[];
173
+ /** Up to 64 pieces of text at frequencies — a spot, a net, a beacon. */
174
+ annotations?: PanelSceneSpectrumAnnotation[];
175
+ }
176
+ /**
177
+ * The gap sits between the panadapter and the waterfall — above the waterfall with no panadapter,
178
+ * below the panadapter with no waterfall. Labels and ticks are drawn over the whole control, so an
179
+ * `offset` can place them anywhere: it is pixels from the base of the panadapter (the top of the
180
+ * waterfall where there is none), negative rising into the panadapter.
181
+ */
182
+ export interface PanelSceneSpectrumScale {
183
+ /** Pixels, 0–200. Default 20. */
184
+ gap?: PanelSceneSpectrumNumber;
185
+ background?: string;
186
+ borderTop?: {
187
+ color?: string;
188
+ width?: PanelSceneSpectrumNumber;
189
+ };
190
+ borderBottom?: {
191
+ color?: string;
192
+ width?: PanelSceneSpectrumNumber;
193
+ };
194
+ /** On by default; `false` hides them. */
195
+ labels?: boolean | {
196
+ /** Hz between labels. Omitted, the gridlines' step. */
197
+ interval?: PanelSceneSpectrumNumber;
198
+ fontFamily?: string;
199
+ size?: PanelSceneSpectrumNumber;
200
+ /** 100–900. */
201
+ weight?: PanelSceneSpectrumNumber;
202
+ italic?: boolean;
203
+ color?: string;
204
+ /** To the top of the text. Omitted, centered in the gap. */
205
+ offset?: PanelSceneSpectrumNumber;
206
+ };
207
+ /** Off by default. */
208
+ ticks?: boolean | {
209
+ /** Hz between ticks. Omitted, the labels'. */
210
+ interval?: PanelSceneSpectrumNumber;
211
+ color?: string;
212
+ width?: PanelSceneSpectrumNumber;
213
+ /** To the top of the tick. Default 0. */
214
+ offset?: PanelSceneSpectrumNumber;
215
+ /** Default 4. */
216
+ length?: PanelSceneSpectrumNumber;
217
+ };
218
+ }
219
+ /**
220
+ * Text at a frequency, drawn by the host. With an `event`, a click sends `activate` with the
221
+ * frequency as `value` and this `id` as `annotation`, and does not tune; without one, a click
222
+ * on it tunes like anywhere else.
223
+ */
224
+ export interface PanelSceneSpectrumAnnotation {
225
+ /** Unique within the control. */
226
+ id: string;
227
+ /** At most 256 characters. */
228
+ text: string;
229
+ /** A frequency, or an offset from the tuned frequency or the sweep's middle with `anchor`. */
230
+ hz: PanelSceneSpectrumNumber;
231
+ /** Default `absolute`. */
232
+ anchor?: "vfo" | "center" | "absolute";
233
+ /** Default `pan`. */
234
+ part?: "pan" | "waterfall";
235
+ /** Pixels from the panadapter's bottom up to the text's, or from the waterfall's top down to the text's. Default 4. */
236
+ offset?: PanelSceneSpectrumNumber;
237
+ /** Which end of the text sits at `hz`. Default `center`. */
238
+ align?: "start" | "center" | "end";
239
+ /**
240
+ * The point the box makes at `hz`: a drawn-out corner for `start`/`end`, a notch for `center`.
241
+ * With the default `corner`, its tip sits at `offset`; turned the other way, the box's edge
242
+ * does. Shown by default (size 4, pointing toward the panadapter's bottom
243
+ * or the waterfall's top); `false` or `show: false` hides it.
244
+ */
245
+ pointer?: boolean | {
246
+ show?: boolean;
247
+ size?: PanelSceneSpectrumNumber;
248
+ corner?: "top" | "bottom";
249
+ };
250
+ fontFamily?: string;
251
+ size?: PanelSceneSpectrumNumber;
252
+ weight?: PanelSceneSpectrumNumber;
253
+ italic?: boolean;
254
+ color?: string;
255
+ background?: string;
256
+ borderColor?: string;
257
+ borderWidth?: PanelSceneSpectrumNumber;
258
+ /** Default 2. */
259
+ padding?: PanelSceneSpectrumNumber;
260
+ /** Default 2. */
261
+ radius?: PanelSceneSpectrumNumber;
262
+ event?: string;
263
+ }
264
+ /** `color` and `width` draw a line, or an area's edges; `fill` fills an area. */
265
+ export interface PanelSceneSpectrumStyle {
266
+ color?: string;
267
+ width?: PanelSceneSpectrumNumber;
268
+ fill?: string;
269
+ }
270
+ /**
271
+ * A line at `at`, or an area between `from` and `to`, in Hz from the tuned frequency — from the
272
+ * middle of the sweep with `anchor: "center"` (a Flex's panadapter is not centered on its slice),
273
+ * or as frequencies with `anchor: "absolute"` (a band edge, a segment). On the panadapter an area
274
+ * draws behind the trace and a line in front of it unless `behind` — which absolute ones default to.
275
+ * On the waterfall all of it draws over the rows. A part with no style is not drawn on.
276
+ */
277
+ export interface PanelSceneSpectrumOverlay {
278
+ at?: PanelSceneSpectrumNumber;
279
+ from?: PanelSceneSpectrumNumber;
280
+ to?: PanelSceneSpectrumNumber;
281
+ anchor?: "vfo" | "center" | "absolute";
282
+ behind?: boolean;
283
+ pan?: PanelSceneSpectrumStyle;
284
+ waterfall?: PanelSceneSpectrumStyle;
285
+ }
286
+ export interface PanelSceneControl extends PanelSceneSpectrum {
105
287
  id: string;
106
288
  label: string;
107
289
  kind: PanelSceneControlKind;
@@ -177,6 +359,8 @@ export interface PanelSceneEvent {
177
359
  value?: number;
178
360
  /** The control's string, for a control bound to `strings`. */
179
361
  text?: string;
362
+ /** The `id` of the spectrum annotation clicked; `value` is its frequency. */
363
+ annotation?: string;
180
364
  }
181
365
  /** Patch only named scene values and strings. The host ignores stale responses. */
182
366
  export interface PanelSceneEventResult {
@@ -492,6 +676,7 @@ export interface Ref {
492
676
  program?: string;
493
677
  activationRadiusInMeters?: number;
494
678
  activationRadiusInFeet?: number;
679
+ largeArea?: boolean;
495
680
  retired?: boolean;
496
681
  for?: "operation" | "qso";
497
682
  [key: string]: JSONValue | undefined;
@@ -1544,6 +1729,40 @@ export interface PanelRadioState {
1544
1729
  transmitting: boolean | null;
1545
1730
  meters: Partial<Record<"signalDbm" | "powerOut" | "swr" | "alc" | "supplyVolts", number>>;
1546
1731
  canTune: boolean;
1732
+ /**
1733
+ * The radio's scope, on hosts with the panadapter enabled: absent there otherwise, and null
1734
+ * for a radio without one. The sweeps are never sent — a `spectrum` scene control draws them.
1735
+ */
1736
+ spectrum?: PanelRadioSpectrum | null;
1737
+ }
1738
+ export type PanelRadioSpectrumSpeed = "slow" | "mid" | "fast";
1739
+ export interface PanelRadioSpectrum {
1740
+ /** Why nothing can be drawn, in the operator's language, or null. */
1741
+ problem: string | null;
1742
+ mode: "center" | "fixed" | null;
1743
+ /** Half the width shown while centered — what the radio's menu shows as ±. */
1744
+ spanHz: number | null;
1745
+ /** The edges while fixed. */
1746
+ lowHz: number | null;
1747
+ highHz: number | null;
1748
+ refLevelDb: number | null;
1749
+ speed: PanelRadioSpectrumSpeed | null;
1750
+ /** What `setRadioSpectrum` accepts. A span between two is snapped to the nearer. */
1751
+ spansHz: number[];
1752
+ refMinDb: number;
1753
+ refMaxDb: number;
1754
+ refStepDb: number;
1755
+ modes: ("center" | "fixed")[];
1756
+ speeds: PanelRadioSpectrumSpeed[];
1757
+ }
1758
+ export interface PanelRadioSpectrumSet {
1759
+ /** Reject if this identity no longer matches the selected radio. */
1760
+ id: string;
1761
+ selection?: string;
1762
+ mode?: "center" | "fixed";
1763
+ spanHz?: number;
1764
+ refLevelDb?: number;
1765
+ speed?: PanelRadioSpectrumSpeed;
1547
1766
  }
1548
1767
  export interface PanelRadioTune {
1549
1768
  /** Reject if this identity no longer matches the selected radio. */
@@ -1556,7 +1775,8 @@ export interface PanelRadioTune {
1556
1775
  export interface PanelRadioTuneResult {
1557
1776
  /** Accepted by the host; confirmation still comes from reported state. */
1558
1777
  accepted: boolean;
1559
- reason?: "changed" | "disconnected" | "transmitting" | "invalid";
1778
+ /** `stopped`: a scope change while no display has the scope running — nothing was sent. */
1779
+ reason?: "changed" | "disconnected" | "transmitting" | "invalid" | "stopped";
1560
1780
  state: PanelRadioState | null;
1561
1781
  }
1562
1782
  export interface PanelRenderArgs {
@@ -1650,7 +1870,8 @@ export interface ParsedCallsign {
1650
1870
  indicators?: string[];
1651
1871
  ssid?: string;
1652
1872
  }
1653
- export type CombinedCallInfo = ParsedCallsign & {
1873
+ export type CombinedCallInfo = Omit<ParsedCallsign, "call"> & {
1874
+ call?: string;
1654
1875
  isoPrefix?: string;
1655
1876
  dxccCode?: number;
1656
1877
  regionCode?: string;
@@ -1998,6 +2219,8 @@ export declare const host: {
1998
2219
  connected: boolean;
1999
2220
  }): Promise<PanelRadioTuneResult>;
2000
2221
  tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
2222
+ /** Changes the radio's scope. Needs `requiresRadioWrite` and extension API 6; the result's state reports what the radio took. */
2223
+ setRadioSpectrum(args: PanelRadioSpectrumSet): Promise<PanelRadioTuneResult>;
2001
2224
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
2002
2225
  webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
2003
2226
  kvGet(key: string): Promise<JSONValue | null>;
package/dist/index.js CHANGED
@@ -172,6 +172,10 @@ const host = {
172
172
  async tuneRadio(args) {
173
173
  return await hostCallFn()("tuneRadio", { ...args });
174
174
  },
175
+ /** Changes the radio's scope. Needs `requiresRadioWrite` and extension API 6; the result's state reports what the radio took. */
176
+ async setRadioSpectrum(args) {
177
+ return await hostCallFn()("setRadioSpectrum", { ...args });
178
+ },
175
179
  async fetch(url, options) {
176
180
  return await hostCallFn()("fetch", { url, ...options });
177
181
  },
@@ -262,7 +262,7 @@ It reports every problem at once rather than one per run:
262
262
  h2kext-pack: ./build is not ready to package:
263
263
  manifest.json: missing required field 'name'
264
264
  manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
265
- manifest.json: missing 'api' — declare the extension API this was built against (currently 5)
265
+ manifest.json: missing 'api' — declare the extension API this was built against (currently 6)
266
266
  ```
267
267
 
268
268
  `--force-name` waives the naming convention below. It exists for Ham2K's own
package/docs/hooks.md CHANGED
@@ -209,9 +209,11 @@ interface LookupServiceHook {
209
209
  **locationScope merge rules** (`CallInfo.locationScope`, ranked
210
210
  `qth < prefixed < portable`, most general to most specific):
211
211
  - A result whose `locationScope` is strictly more specific than the current
212
- guess's clears all location fields (`locSource, lat, lon, grid, city,
213
- state, county, country`) before merging the result in, and the guess
214
- adopts the more specific scope.
212
+ guess's clears all location fields (`locSource, lat, lon, grid, street,
213
+ city, state, postal, county, country`) before merging the result in, and
214
+ the guess adopts the more specific scope. `street` and `postal` are the
215
+ mailing address's own lines, grouped with the town they complete so an
216
+ address never puts one source's street on another source's town.
215
217
  - Otherwise, priority order applies as usual: a field already present in the
216
218
  guess is never overwritten by a later (lower-priority) result. A
217
219
  location field additionally requires the result's own scope to be at
@@ -231,19 +233,31 @@ interface LookupServiceHook {
231
233
  - Grid/lat-lon reconciliation, scoped to fields the result actually set:
232
234
  grid-only → lat/lon computed from the grid; lat/lon-only → a 6-digit grid
233
235
  computed from them; both given → taken verbatim. Setting grid/lat/lon
234
- clears city/state/country/county unless the *same* result also supplies
235
- them. The converse clause — a town write clearing grid/lat/lon — can only
236
+ clears street/city/state/postal/country/county unless the *same* result
237
+ also supplies them. The converse clause — a town write clearing grid/lat/lon — can only
236
238
  sweep a leftover half lat/lon pair: the group rule above blocks a town
237
239
  from landing at all where a full pin already stands.
238
240
  - `notes`/`history` are per-`CallInfoLookup` fields, not part of the merged
239
241
  `guess` — they simply accumulate, unconditionally, in the returned
240
242
  `lookups[]` array.
243
+ - **`cqZone`, `ituZone` and `tz` are the exception to first-answer-wins.** The
244
+ pre-step can only answer them for a whole entity or call area (a W6 call
245
+ reads CQ zone 3 wherever the station lives), so once every hook has run,
246
+ a value the pre-step still holds goes to the first hook result that
247
+ carried one (`preferStationValues`) — only while the guess's scope is
248
+ `qth`, since on a portable or prefixed contact a source describes the
249
+ home QTH. `tz` is a UTC offset with its ordinary sign, `"GMT-5"` being
250
+ five hours behind. lib-country-files states it that way from 1.1; the
251
+ SDK's `annotateCallAgainstCountryFile` answers with the ordinary signs
252
+ whichever version the app ships, flipping an older one's west-positive
253
+ `tz` and `lon`.
241
254
  - **`locSource: 'prefix'` is reserved**, and a `lat`/`lon` merged under it is
242
255
  never read as a coordinate (`locationFromGuess`, halo_core). It marks
243
- lib-country-files' DXCC entity centroid, whose longitude follows cty.dat's
244
- west-positive convention — the opposite of every other coordinate here. A
245
- source reporting a real position must leave `locSource` unset or name
246
- itself; tagging one `'prefix'` gets its coordinate discarded.
256
+ lib-country-files' DXCC entity centroid, which synced records carry signed
257
+ either way — west-positive from versions before 1.1 — with nothing to tell
258
+ the two apart. A source reporting a real position must leave `locSource`
259
+ unset or name itself; tagging one `'prefix'` gets its coordinate
260
+ discarded.
247
261
 
248
262
  Implemented by: `lookups` (key `core`). The merge algorithm itself
249
263
  (`mergeLookupIntoGuess`/`runLookupPipeline` in
@@ -430,7 +444,7 @@ interface RefHandlerHook {
430
444
  geojsonUrlForRef?(args: { ref: Ref }, ctx): Promise<RefGeojson | null>
431
445
  }
432
446
  // Ref: {type, ref?, name?, label?, shortLabel?, grid?, lat?, lon?, location?, program?,
433
- // activationRadiusInMeters?, activationRadiusInFeet?, ...}
447
+ // activationRadiusInMeters?, activationRadiusInFeet?, largeArea?, ...}
434
448
  // TitleSuggestion: {title?, for?, at?, subtitle?, priority?} — a fragment of
435
449
  // the operation's title/subtitle sentence (ham2k/halo-dist#174). Only
436
450
  // return one for the ref type that means "this operation is happening AT/FOR
@@ -518,6 +532,18 @@ SOTLAS's `az.sotl.as`, used with the operators' permission) and `pota` (both
518
532
  types, via `services.ham2k.net/lookups/outlines/pota/`, which links parks to
519
533
  OpenStreetMap shapes; a park not yet linked answers 404).
520
534
 
535
+ **POTA is the one exception, and the core makes it, not the hook.** For a
536
+ POTA suggestion's dashed outline the core skips `geojsonUrlForRef` and reads
537
+ `services.ham2k.net`'s grid bundles instead — every outlined park touching a
538
+ Maidenhead cell, simplified and clipped to it, one cached document per cell
539
+ (500 m blocks below GL zoom 9, 10 m squares from 9). A long trail's whole
540
+ outline runs to megabytes; its pieces in a cell, to kilobytes. The same
541
+ bundles add to `suggest`'s answers the parks whose outline, not just whose
542
+ point, is near: within 5 km for the nearby list, touching the view for a pan
543
+ close in (from about GL zoom 10; see `suggest` below). An open card's POTA suggestion is drawn solid from the
544
+ same pieces; the operation's own references still come from
545
+ `geojsonUrlForRef`.
546
+
521
547
  **An activation radius is a circle, drawn the same way.** A reference that
522
548
  carries `activationRadiusInMeters` or `activationRadiusInFeet` — how far from
523
549
  its point the program lets an activator be — is circled on both maps, in the
@@ -535,6 +561,15 @@ and is not offered for an outline report. Programs built on the SDK's
535
561
  `referenceActivity` factory get this from one `activationRadius` config line —
536
562
  a value, or a function of the list row for rules that vary.
537
563
 
564
+ **A reference too large to stand for one place says so.** An operation with
565
+ no location of its own takes the first of its references that has one
566
+ (docs/design/locations.md), which is right for a summit or a park a few
567
+ kilometres across and wrong for a 3,500 km trail. A reference whose point is
568
+ representative rather than where an activator stands — POTA's parks that span
569
+ several states — carries `largeArea: true`, and that reference never places
570
+ the operation. Nothing else reads it: the maps draw it, and the nearby search
571
+ finds it, exactly as before.
572
+
538
573
  `suggestOperationTitle` lets an activity build a natural-language operation
539
574
  title/subtitle from its refs — "at Yosemite NP" (POTA), "for Field Day"
540
575
  (contest programs, when ported) — mirroring app-polo's
@@ -665,7 +700,12 @@ an app that sends `bounds` answers that call, so an extension that calls it
665
700
  only then runs on older apps unchanged, and one that predates `bounds` answers
666
701
  with what is nearest the view's centre. Measure `distance` from the `location`
667
702
  given either way; the core re-measures a panned answer from the operation's own
668
- place before any card shows it. Called with no
703
+ place before any card shows it. For POTA only, the core also adds the parks
704
+ whose OUTLINE is near (from the grid bundles above) to the nearby list and to a
705
+ pan, labelled by POTA's own `decorateRef`, and gives a park POTA already
706
+ suggested the nearer of its point's and its outline's distance — so `pota`'s
707
+ `suggest` keeps answering by point, and a trail whose point is a state away
708
+ still reaches an operator standing on it. Called with no
669
709
  `searchTerm` for nearby suggestions, or with one for name/code search — an
670
710
  extension may honor either, both, or neither (returning `[]`). `scoped` is
671
711
  true when THIS extension alone was asked — the operator tapped its "Activity
@@ -1619,6 +1659,7 @@ the extension. `value` names what it shows and sets.
1619
1659
  | `nativeSwitch`, `nativeCheckbox` | number, 1 or 0 | `commit` | — (shows `label` beside it) |
1620
1660
  | `nativeChip` | number, 1 or 0; or none | `commit`; `activate` without a value | `icon`; needs `event` without a value |
1621
1661
  | `nativeText` | number, string, or none | nothing | `style` (a typography role), `align` (`start`, `center`, `end`) |
1662
+ | `spectrum` | — | `commit` with the frequency clicked; `activate` from an annotation | see *Spectrum* below |
1622
1663
 
1623
1664
  The three drawn kinds are invisible hit areas over artwork the extension paints;
1624
1665
  the native kinds are Material widgets the app paints and themes. Every control
@@ -1730,6 +1771,38 @@ export const spotter: PanelHook = {
1730
1771
  }
1731
1772
  ```
1732
1773
 
1774
+ #### Spectrum
1775
+
1776
+ A `spectrum` control is a radio's panadapter and waterfall, drawn by the host
1777
+ from sweeps that never reach the extension, on a host with the panadapter
1778
+ enabled. It is **extension API 6**, as is `host.setRadioSpectrum`: a bundle
1779
+ using either declares `"api": 6`, and an app speaking less refuses it at install
1780
+ rather than failing to render the scene. It needs `requiresRadioRead`; without it, or on a host or radio
1781
+ without a spectrum, the control says why instead. It is placed like a native
1782
+ control and takes the fields of `PanelSceneSpectrum` in the SDK:
1783
+
1784
+ - `radio` (an id from `listRadios`; omitted, the designated radio), `display`
1785
+ (`both`, `pan`, `waterfall`), `split`, and `flow` — which way the waterfall
1786
+ moves (`down`, `up`, `right`, `left`), the panadapter on the side it comes
1787
+ from.
1788
+ - `floor`, `range`, `average` (0–99, default 40), `fps` (default 15),
1789
+ `waterfallSpeed`, `waterfallFloor`, `waterfallRange`: numbers, or the names
1790
+ of scene values the control reads on every sweep.
1791
+ - Colors (`#RRGGBB` or `#AARRGGBB`): `palette` or `waterfallColors`,
1792
+ `traceColor`/`traceWidth`, `fillColor`/`fillColorBottom`, `backgroundColor`,
1793
+ `gridColor`/`gridDashed`, `waterfallGridColor`.
1794
+ - `center`: the tuned-frequency line, styled per part.
1795
+ - `overlays`: up to 16 lines (`at`) and areas (`from`, `to`) in Hz from the
1796
+ tuned frequency, the sweep's middle, or absolute, each styled per part.
1797
+ - `scale`: the gap between the panadapter and the waterfall, its fill and
1798
+ borders, and frequency `labels` and `ticks`.
1799
+ - `annotations`: up to 64 pieces of text at frequencies, with a pointer to
1800
+ where they sit. One with an `event` sends `activate` when clicked, its
1801
+ frequency as `value` and its `id` as `annotation`, and does not tune.
1802
+ - `hoverHz`, `hoverDb`: scene values the pointer writes, for a text layer.
1803
+ - `tuneStep`: a click elsewhere sends `commit` with the frequency under it,
1804
+ rounded to this many Hz. The extension decides whether to tune.
1805
+
1733
1806
  The callsign comes back upper-cased, and the field shows the extension's
1734
1807
  version once the typing is committed. A bundle carrying this declares
1735
1808
  `"api": 5`. The `k2hrc-panel-scenes` sample in the SDK has every kind, a
@@ -1745,8 +1818,8 @@ repository's dashboard README for building them.
1745
1818
  The radio host calls are manifest capabilities, refused unless declared — the
1746
1819
  same shape as `requiresLocation`, and shown to the operator at install:
1747
1820
  `"requiresRadioRead": true` allows `host.listRadios()` and `host.readRadio()`;
1748
- `"requiresRadioWrite": true` allows `host.tuneRadio()` and
1749
- `host.setRadioConnection()`, and implies read, since a command answers with the
1821
+ `"requiresRadioWrite": true` allows `host.tuneRadio()`,
1822
+ `host.setRadioConnection()` and `host.setRadioSpectrum()`, and implies read, since a command answers with the
1750
1823
  radio's state. An undeclared call rejects rather than answering null.
1751
1824
  A capability belongs to the bundle that declared it, and to the sub-extensions
1752
1825
  that bundle defines under its own key. A separately installed bundle never
@@ -1777,7 +1850,17 @@ reported state confirms it. Drivers retain their own protocol/echo handling.
1777
1850
  `host.setRadioConnection({id, selection?, connected})` starts or ends a local CAT
1778
1851
  connection using the same selection and identity checks. Acceptance starts the
1779
1852
  operation; follow reported connection state for completion. It does not switch
1780
- the hardware's mains power or initiate transmission. The reference panel samples
1853
+ the hardware's mains power or initiate transmission.
1854
+ On a host with the panadapter enabled, `readRadio()` carries `spectrum`: null
1855
+ for a radio without a scope, otherwise its settings as the radio reports them
1856
+ (`mode`, `spanHz`, `lowHz`/`highHz`, `refLevelDb`, `speed`), what it accepts
1857
+ (`spansHz`, `refMinDb`…`refStepDb`, `modes`, `speeds`) and `problem`, why it
1858
+ cannot run, in the operator's language. The settings are known only while a
1859
+ `spectrum` control has the scope running, and null otherwise.
1860
+ `host.setRadioSpectrum({id, selection?, mode?, spanHz?, refLevelDb?, speed?})`
1861
+ changes them; a span between two the radio offers is snapped to the nearer. It
1862
+ answers `stopped` when no control is running the scope: nothing is queued for
1863
+ later. The reference panel samples
1781
1864
  state once per second while visible; knob motion and LCD digit updates run locally,
1782
1865
  and tuning is sent continuously during dragging, paced by the CAT adapter. The
1783
1866
  main readout follows the requested value immediately; confirmation status distinguishes pending requests from the radio
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.12.0",
3
+ "version": "0.12.5",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",