@ham2k/extension-sdk 0.5.5 → 0.5.7
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 +9 -9
- package/README.md +6 -6
- package/dist/activityExports.js +38 -23
- package/dist/activityScoring.js +3 -9
- package/dist/exportNames.js +16 -15
- package/dist/exportSettings.js +11 -1
- package/dist/index.d.ts +10 -3
- package/dist/scoring.js +25 -32
- package/docs/distribution.md +3 -3
- package/docs/hooks.md +22 -7
- package/package.json +1 -1
- package/samples/k2hrc-cqww/build.mjs +3 -3
- package/samples/k2hrc-cqww/src/index.ts +6 -15
- package/samples/k2hrc-hamqth/build.mjs +3 -3
- package/samples/k2hrc-hamqth/src/index.ts +4 -8
- package/samples/k2hrc-llota/build.mjs +3 -3
- package/samples/k2hrc-llota/src/index.ts +4 -7
- package/samples/k2hrc-radio/build.mjs +3 -3
- package/samples/k2hrc-radio/src/index.ts +1 -5
- package/samples/k2hrc-svg-scenes/build.mjs +3 -3
- package/samples/k2hrc-svg-scenes/manifest.json +2 -10
- package/samples/k2hrc-svg-scenes/src/index.ts +4 -4
- package/samples/k2hrc-svg-scenes/src/scenes.ts +195 -80
package/AGENTS.md
CHANGED
|
@@ -34,24 +34,24 @@ build/ generated: index.js + manifest.json
|
|
|
34
34
|
## The smallest whole extension
|
|
35
35
|
|
|
36
36
|
```ts
|
|
37
|
-
import { defineExtension } from
|
|
38
|
-
import type { PanelHook } from
|
|
37
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
38
|
+
import type { PanelHook } from "@ham2k/extension-sdk"
|
|
39
39
|
|
|
40
|
-
import manifest from
|
|
40
|
+
import manifest from "../manifest.json"
|
|
41
41
|
|
|
42
42
|
const HelloPanel: PanelHook = {
|
|
43
43
|
async getPanels() {
|
|
44
|
-
return [{ key:
|
|
44
|
+
return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
|
|
45
45
|
},
|
|
46
46
|
async render() {
|
|
47
|
-
return { kind:
|
|
47
|
+
return { kind: "markdown", content: "# Hello, World!" }
|
|
48
48
|
},
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
defineExtension({
|
|
52
52
|
...manifest,
|
|
53
53
|
onActivation({ registerHook }) {
|
|
54
|
-
registerHook(
|
|
54
|
+
registerHook("panel", { key: manifest.key, hook: HelloPanel })
|
|
55
55
|
},
|
|
56
56
|
})
|
|
57
57
|
```
|
|
@@ -100,7 +100,7 @@ No Node, no DOM, no `fetch`, no `window`, no filesystem. One ES2020 engine
|
|
|
100
100
|
evaluating one script. Reach the outside world through `host`:
|
|
101
101
|
|
|
102
102
|
```ts
|
|
103
|
-
import { host } from
|
|
103
|
+
import { host } from "@ham2k/extension-sdk"
|
|
104
104
|
|
|
105
105
|
await host.fetch(url) // https only, and only domains your manifest lists
|
|
106
106
|
await host.kvGet('key') // storage, namespaced to your extension
|
|
@@ -114,7 +114,7 @@ host.log('…') // the app's console
|
|
|
114
114
|
|
|
115
115
|
## Traps worth knowing before you write anything
|
|
116
116
|
|
|
117
|
-
- **A panel returns content, not widgets.** `{ kind:
|
|
117
|
+
- **A panel returns content, not widgets.** `{ kind: "markdown", content }` and
|
|
118
118
|
its siblings. There is no way to reach the app's UI toolkit, by design.
|
|
119
119
|
- **`host.fetch` only reaches domains your manifest's `domains` lists**, over
|
|
120
120
|
https. An undeclared host is refused at the call, not at build.
|
|
@@ -122,7 +122,7 @@ host.log('…') // the app's console
|
|
|
122
122
|
carries `@ham2k/lib-*`, `i18next` and `liquidjs`; naming one in
|
|
123
123
|
`sharedDependencies` rewrites your imports into lookups on the app's single
|
|
124
124
|
copy, which is worth roughly thirty times the bundle size. Don't install them.
|
|
125
|
-
`inline: [
|
|
125
|
+
`inline: ["liquidjs"]` in your build ships your own copy, for when you need a
|
|
126
126
|
version the app does not carry. A scaffolded manifest already declares the
|
|
127
127
|
eight this SDK's own modules reach — importing anything from it pulls them
|
|
128
128
|
in — so deleting one from the manifest breaks a build that was working.
|
package/README.md
CHANGED
|
@@ -9,24 +9,24 @@ npm install --save-dev @ham2k/extension-tools esbuild
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
|
-
import { defineExtension } from
|
|
13
|
-
import type { PanelHook } from
|
|
12
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
13
|
+
import type { PanelHook } from "@ham2k/extension-sdk"
|
|
14
14
|
|
|
15
|
-
import manifest from
|
|
15
|
+
import manifest from "../manifest.json"
|
|
16
16
|
|
|
17
17
|
const HelloPanel: PanelHook = {
|
|
18
18
|
async getPanels() {
|
|
19
|
-
return [{ key:
|
|
19
|
+
return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
|
|
20
20
|
},
|
|
21
21
|
async render() {
|
|
22
|
-
return { kind:
|
|
22
|
+
return { kind: "markdown", content: "# Hello, World!" }
|
|
23
23
|
},
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
defineExtension({
|
|
27
27
|
...manifest,
|
|
28
28
|
onActivation({ registerHook }) {
|
|
29
|
-
registerHook(
|
|
29
|
+
registerHook("panel", { key: manifest.key, hook: HelloPanel })
|
|
30
30
|
},
|
|
31
31
|
})
|
|
32
32
|
```
|
package/dist/activityExports.js
CHANGED
|
@@ -23,14 +23,16 @@ function activityExportHook(rules) {
|
|
|
23
23
|
const prefix = `${rules.key}-adif`;
|
|
24
24
|
return {
|
|
25
25
|
async getExportTypes() {
|
|
26
|
-
return [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
...exportTypeDefinition(rules.activationType, "adif", rules.label),
|
|
29
|
+
templateCategory: "reference",
|
|
30
|
+
templateSample: rules.templateSample ?? {
|
|
31
|
+
log: { ref: "REF-1234", refName: "Example Reference" },
|
|
32
|
+
operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
|
|
33
|
+
}
|
|
32
34
|
}
|
|
33
|
-
|
|
35
|
+
];
|
|
34
36
|
},
|
|
35
37
|
async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
|
|
36
38
|
const refs = refsOfType(operation, rules.activationType);
|
|
@@ -113,21 +115,23 @@ function huntingExportHook(rules) {
|
|
|
113
115
|
if (refsOfType(operation, rules.activationType).length > 0) return [];
|
|
114
116
|
if (huntedQsos(qsos ?? []).length === 0) return [];
|
|
115
117
|
const t = tFor(ctx);
|
|
116
|
-
return [
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
exportType,
|
|
121
|
+
qsoCount: huntedQsos(qsos ?? []).length,
|
|
122
|
+
templateData: { activity: rules.label },
|
|
123
|
+
format: "adif",
|
|
124
|
+
label: t("adifForHunter", { program: rules.label }),
|
|
125
|
+
filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
|
|
126
|
+
icon: rules.icon,
|
|
127
|
+
color: rules.color,
|
|
128
|
+
selectedByDefault: true
|
|
129
|
+
// No `refType`: hunted refs live on QSOs, not on operation segments,
|
|
130
|
+
// so this isn't the segment-scoped slice `activityExportHook` uses —
|
|
131
|
+
// it spans the whole log. (See `HuntingExportRules.color` for why the
|
|
132
|
+
// accent has to be threaded explicitly instead.)
|
|
133
|
+
}
|
|
134
|
+
];
|
|
131
135
|
},
|
|
132
136
|
async generateExport(args, ctx) {
|
|
133
137
|
const requested = String(args.exportType ?? "");
|
|
@@ -163,7 +167,18 @@ function huntingExportHook(rules) {
|
|
|
163
167
|
}
|
|
164
168
|
};
|
|
165
169
|
}
|
|
166
|
-
async function adifForExport({
|
|
170
|
+
async function adifForExport({
|
|
171
|
+
operation,
|
|
172
|
+
qsos,
|
|
173
|
+
segments,
|
|
174
|
+
includePrivateData,
|
|
175
|
+
includeLookupData,
|
|
176
|
+
exportSettings,
|
|
177
|
+
exportData,
|
|
178
|
+
exportTitle,
|
|
179
|
+
mainHandler,
|
|
180
|
+
includeFieldsFrom
|
|
181
|
+
}) {
|
|
167
182
|
const { hooks } = await import("@ham2k/extension-sdk");
|
|
168
183
|
const entries = await hooks.invokeOne("export", "adif", "generateExport", {
|
|
169
184
|
operation,
|
package/dist/activityScoring.js
CHANGED
|
@@ -62,9 +62,7 @@ function activityScorer(rules) {
|
|
|
62
62
|
if (candidates.length === 0) notice ?? (notice = "mode");
|
|
63
63
|
}
|
|
64
64
|
if (unique.has("ref")) {
|
|
65
|
-
candidates = candidates.filter(
|
|
66
|
-
(c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r))
|
|
67
|
-
);
|
|
65
|
+
candidates = candidates.filter((c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r)));
|
|
68
66
|
if (candidates.length === 0 && now.refs.length > 0) notice ?? (notice = "ref");
|
|
69
67
|
}
|
|
70
68
|
if (notice) return notice;
|
|
@@ -189,14 +187,10 @@ function summarizeActivation(activatedRefs, qsos, duplicates, huntedQsos, scope,
|
|
|
189
187
|
const detail = refKeys.map(
|
|
190
188
|
(key) => activatedRefs[key] >= qsosToActivate ? `\u2705 **${key}: ${fmtInteger(activatedRefs[key])}**` : `\u274C ${key}: ${fmtInteger(activatedRefs[key])}/${fmtInteger(qsosToActivate)}`
|
|
191
189
|
);
|
|
192
|
-
const parts = [
|
|
193
|
-
qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })
|
|
194
|
-
];
|
|
190
|
+
const parts = [qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })];
|
|
195
191
|
if (huntedQsos > 0) {
|
|
196
192
|
const p2p = resolveRuleText(rules.p2pLabel, ctx, t("ref2ref"));
|
|
197
|
-
parts.push(
|
|
198
|
-
huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) })
|
|
199
|
-
);
|
|
193
|
+
parts.push(huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) }));
|
|
200
194
|
}
|
|
201
195
|
return {
|
|
202
196
|
key: "activation",
|
package/dist/exportNames.js
CHANGED
|
@@ -26,22 +26,23 @@ function exportFilename(parts) {
|
|
|
26
26
|
const millis = parts.startAtMillis && parts.startAtMillis > 0 ? parts.startAtMillis : Date.now();
|
|
27
27
|
const extension = parts.extension.replace(/^\./, "");
|
|
28
28
|
const shape = `${parts.compact ? "Compact" : "Normal"}${parts.ref ? "Ref" : parts.activity ? "Activity" : ""}Name`;
|
|
29
|
-
const modifier = [
|
|
30
|
-
(parts.modifier ?? "").trim(),
|
|
31
|
-
isTestOperation(parts.stationCall) ? "Testing" : ""
|
|
32
|
-
].filter((part) => part !== "").join(" ");
|
|
29
|
+
const modifier = [(parts.modifier ?? "").trim(), isTestOperation(parts.stationCall) ? "Testing" : ""].filter((part) => part !== "").join(" ");
|
|
33
30
|
const stem = oneLine(
|
|
34
|
-
renderTemplate(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
renderTemplate(
|
|
32
|
+
NAME_TEMPLATES[shape],
|
|
33
|
+
{
|
|
34
|
+
op: dateValues(millis),
|
|
35
|
+
log: logValues({
|
|
36
|
+
station: String(parts.stationCall ?? "").trim(),
|
|
37
|
+
ref: parts.ref,
|
|
38
|
+
activity: parts.activity,
|
|
39
|
+
modifier,
|
|
40
|
+
extension,
|
|
41
|
+
compact: parts.compact
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
shape
|
|
45
|
+
)
|
|
45
46
|
);
|
|
46
47
|
return `${sanitize(stem) || "export"}.${extension}`;
|
|
47
48
|
}
|
package/dist/exportSettings.js
CHANGED
|
@@ -118,7 +118,17 @@ function prepareExportOption(option, operation, qsos, compact) {
|
|
|
118
118
|
const rendered = oneLine(renderTemplate(template ?? "", context, "Export filename"));
|
|
119
119
|
const stem = rendered.replace(/[/\\:*?"<>|\x00-\x1f]+/g, "-").replace(/[. ]+$/g, "").trim() || "export";
|
|
120
120
|
const filename = stem.toLowerCase().endsWith(`.${extension}`) ? stem : `${stem}.${extension}`;
|
|
121
|
-
const titleContext = settings.includePrivateData === false ? {
|
|
121
|
+
const titleContext = settings.includePrivateData === false ? {
|
|
122
|
+
...context,
|
|
123
|
+
op: {
|
|
124
|
+
...context.op,
|
|
125
|
+
title: "",
|
|
126
|
+
userTitle: "",
|
|
127
|
+
userNotes: "",
|
|
128
|
+
grid: String(context.op.grid ?? "").slice(0, 6)
|
|
129
|
+
},
|
|
130
|
+
log: { ...context.log, modifier: "" }
|
|
131
|
+
} : context;
|
|
122
132
|
const title = oneLine(renderTemplate(settings.titleTemplate ?? "", titleContext, "Export title"));
|
|
123
133
|
return { ...option, filename, exportTitle: title, exportData: settings.includePrivateData === false ? { ...data, modifier: "" } : data };
|
|
124
134
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.5.
|
|
1
|
+
// @ham2k/extension-sdk 0.5.7
|
|
2
2
|
/** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
|
|
3
3
|
export interface SvgScene {
|
|
4
4
|
version: 1;
|
|
@@ -102,7 +102,7 @@ export interface SvgSceneControl {
|
|
|
102
102
|
hover?: boolean;
|
|
103
103
|
/** Accessible descriptions indexed by (value - min) / step. */
|
|
104
104
|
valueLabels?: string[];
|
|
105
|
-
/** Knobs use relative horizontal/upward dragging.
|
|
105
|
+
/** Knobs use relative horizontal/upward dragging. Value per scene unit; default 1. */
|
|
106
106
|
sensitivity?: number;
|
|
107
107
|
}
|
|
108
108
|
export interface PanelSceneEvent {
|
|
@@ -393,6 +393,7 @@ export interface RefGeojson {
|
|
|
393
393
|
export interface LoggingControlDescriptor {
|
|
394
394
|
key: string;
|
|
395
395
|
label: string;
|
|
396
|
+
shortLabel?: string;
|
|
396
397
|
icon?: string;
|
|
397
398
|
color?: string;
|
|
398
399
|
order?: number;
|
|
@@ -632,12 +633,14 @@ export interface ScoreQsosRequest {
|
|
|
632
633
|
segments?: OperationSegmentPayload[];
|
|
633
634
|
ref?: Record<string, JSONValue>;
|
|
634
635
|
resumeFrom?: JSONValue;
|
|
636
|
+
resumeDay?: number;
|
|
635
637
|
}
|
|
636
638
|
export interface ScoreQsosResult {
|
|
637
639
|
qsoScores: Record<string, QsoScoreVerdict>;
|
|
638
640
|
daySections: DayScoreSummary[];
|
|
639
641
|
operationSummary: Record<string, ScoreTally>;
|
|
640
642
|
scoresheet?: JSONValue;
|
|
643
|
+
scoresheetDay?: number;
|
|
641
644
|
}
|
|
642
645
|
export interface ScoreQsoRequest {
|
|
643
646
|
operation: Record<string, JSONValue>;
|
|
@@ -646,6 +649,8 @@ export interface ScoreQsoRequest {
|
|
|
646
649
|
ref?: Record<string, JSONValue>;
|
|
647
650
|
segments?: OperationSegmentPayload[];
|
|
648
651
|
resumeFrom?: JSONValue;
|
|
652
|
+
resumeDay?: number;
|
|
653
|
+
qsos?: Record<string, JSONValue>[];
|
|
649
654
|
}
|
|
650
655
|
export interface QsoScoreNotices {
|
|
651
656
|
notices: string[];
|
|
@@ -661,6 +666,8 @@ export interface ScoreCandidatesRequest {
|
|
|
661
666
|
ref?: Record<string, JSONValue>;
|
|
662
667
|
segments?: OperationSegmentPayload[];
|
|
663
668
|
resumeFrom?: JSONValue;
|
|
669
|
+
resumeDay?: number;
|
|
670
|
+
qsos?: Record<string, JSONValue>[];
|
|
664
671
|
}
|
|
665
672
|
export type ScoreCandidatesResult = Record<string, QsoScoreNotices>;
|
|
666
673
|
export interface ScoringHook {
|
|
@@ -1520,7 +1527,7 @@ export declare function huntingExportHook(rules: HuntingExportRules): {
|
|
|
1520
1527
|
suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
|
|
1521
1528
|
generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
|
|
1522
1529
|
};
|
|
1523
|
-
export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }: {
|
|
1530
|
+
export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom, }: {
|
|
1524
1531
|
operation: Record<string, JSONValue>;
|
|
1525
1532
|
qsos: Record<string, JSONValue>[];
|
|
1526
1533
|
segments?: OperationSegmentPayload[];
|
package/dist/scoring.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const DAY_IN_MILLIS = 1e3 * 60 * 60 * 24;
|
|
2
|
-
function
|
|
2
|
+
function copySheet(sheet) {
|
|
3
3
|
return JSON.parse(JSON.stringify(sheet));
|
|
4
4
|
}
|
|
5
|
+
function openCheckpoint(resumeFrom, resumeDay) {
|
|
6
|
+
return { sheet: copySheet(resumeFrom), day: typeof resumeDay === "number" ? resumeDay : void 0 };
|
|
7
|
+
}
|
|
5
8
|
function dayOf(qso) {
|
|
6
9
|
const millis = Number(qso.startAtMillis ?? 0);
|
|
7
10
|
return millis - millis % DAY_IN_MILLIS;
|
|
@@ -27,9 +30,9 @@ function contestScorer(scorer, options = {}) {
|
|
|
27
30
|
const refs = operation.refs ?? [];
|
|
28
31
|
return refs.find((r) => typeof r?.type === "string" && scopedRefTypes.includes(r.type));
|
|
29
32
|
}
|
|
30
|
-
function fold(scoresheet, qsos, args, ctx, onQso, onDayEnd, segments) {
|
|
33
|
+
function fold(scoresheet, qsos, args, ctx, onQso, onDayEnd, segments, startDay) {
|
|
31
34
|
let sheet = scoresheet;
|
|
32
|
-
let currentDay;
|
|
35
|
+
let currentDay = startDay;
|
|
33
36
|
let dayCount = 0;
|
|
34
37
|
const timeline = segments && segments.length > 1 ? segments : void 0;
|
|
35
38
|
let segmentIndex = 0;
|
|
@@ -60,35 +63,29 @@ function contestScorer(scorer, options = {}) {
|
|
|
60
63
|
onQso?.(typeof qso.uuid === "string" ? qso.uuid : void 0, result.score);
|
|
61
64
|
}
|
|
62
65
|
if (currentDay !== void 0) onDayEnd?.(currentDay, dayCount, sheet);
|
|
63
|
-
return sheet;
|
|
66
|
+
return { sheet, day: currentDay };
|
|
64
67
|
}
|
|
65
68
|
async function liveScoresheet(args, ctx, excludeUuid) {
|
|
66
69
|
if (args.resumeFrom !== void 0) {
|
|
67
|
-
|
|
70
|
+
const { sheet, day } = openCheckpoint(args.resumeFrom, args.resumeDay);
|
|
71
|
+
const tail = chronological(args.qsos ?? []).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
|
|
72
|
+
if (tail.length === 0) return { sheet, day };
|
|
73
|
+
return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, day);
|
|
68
74
|
}
|
|
69
75
|
const uuid = typeof args.operation.uuid === "string" ? args.operation.uuid : void 0;
|
|
70
76
|
const log = uuid ? await ctx.getQsos?.(uuid) ?? [] : [];
|
|
71
|
-
const rest = chronological(log).filter(
|
|
72
|
-
|
|
73
|
-
);
|
|
74
|
-
return fold(
|
|
75
|
-
scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx),
|
|
76
|
-
rest,
|
|
77
|
-
args,
|
|
78
|
-
ctx,
|
|
79
|
-
void 0,
|
|
80
|
-
void 0,
|
|
81
|
-
args.segments
|
|
82
|
-
);
|
|
77
|
+
const rest = chronological(log).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
|
|
78
|
+
return fold(scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx), rest, args, ctx, void 0, void 0, args.segments);
|
|
83
79
|
}
|
|
84
80
|
return {
|
|
85
81
|
scope,
|
|
86
82
|
async scoreQsos(args, ctx) {
|
|
87
83
|
const qsos = chronological(args.qsos);
|
|
88
|
-
const
|
|
84
|
+
const resumed = args.resumeFrom !== void 0 ? openCheckpoint(args.resumeFrom, args.resumeDay) : void 0;
|
|
85
|
+
const start = resumed?.sheet ?? scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
|
|
89
86
|
const qsoScores = {};
|
|
90
87
|
const daySections = [];
|
|
91
|
-
const end = fold(
|
|
88
|
+
const { sheet: end, day: endDay } = fold(
|
|
92
89
|
start,
|
|
93
90
|
qsos,
|
|
94
91
|
args,
|
|
@@ -103,27 +100,23 @@ function contestScorer(scorer, options = {}) {
|
|
|
103
100
|
scores: scorer.summarizeScore({ scoresheet: sheet, operation: args.operation, ref: args.ref, scope: "day" }, ctx)
|
|
104
101
|
});
|
|
105
102
|
},
|
|
106
|
-
args.segments
|
|
103
|
+
args.segments,
|
|
104
|
+
resumed?.day
|
|
107
105
|
);
|
|
108
106
|
return {
|
|
109
107
|
qsoScores,
|
|
110
108
|
daySections,
|
|
111
|
-
operationSummary: scorer.summarizeScore(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
),
|
|
115
|
-
scoresheet: end
|
|
109
|
+
operationSummary: scorer.summarizeScore({ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" }, ctx),
|
|
110
|
+
scoresheet: end,
|
|
111
|
+
...endDay !== void 0 && { scoresheetDay: endDay }
|
|
116
112
|
};
|
|
117
113
|
},
|
|
118
114
|
/// Live path: score the candidate against the log so far and report only
|
|
119
115
|
/// its notices/alerts. Nothing is recorded — the resulting scoresheet is
|
|
120
116
|
/// discarded, which is exactly why one `scoreQso` can serve both paths.
|
|
121
117
|
async scoreQso(args, ctx) {
|
|
122
|
-
const sheet = await liveScoresheet(args, ctx, args.excludeUuid);
|
|
123
|
-
const { score } = scorer.scoreQso(
|
|
124
|
-
{ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false },
|
|
125
|
-
ctx
|
|
126
|
-
);
|
|
118
|
+
const { sheet } = await liveScoresheet(args, ctx, args.excludeUuid);
|
|
119
|
+
const { score } = scorer.scoreQso({ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false }, ctx);
|
|
127
120
|
return { notices: score.notices ?? [], alerts: score.alerts ?? [], ...score.dupe !== void 0 && { dupe: score.dupe } };
|
|
128
121
|
},
|
|
129
122
|
/// The same live judgement as `scoreQso`, for many candidates at once —
|
|
@@ -138,12 +131,12 @@ function contestScorer(scorer, options = {}) {
|
|
|
138
131
|
/// contract that makes the batch fold linear. Without it the first
|
|
139
132
|
/// candidate's call would count as worked for every candidate after it.
|
|
140
133
|
async scoreCandidates(args, ctx) {
|
|
141
|
-
const base = await liveScoresheet(args, ctx);
|
|
134
|
+
const { sheet: base } = await liveScoresheet(args, ctx);
|
|
142
135
|
const result = {};
|
|
143
136
|
for (const candidate of args.candidates) {
|
|
144
137
|
const { score } = scorer.scoreQso(
|
|
145
138
|
{
|
|
146
|
-
scoresheet:
|
|
139
|
+
scoresheet: copySheet(base),
|
|
147
140
|
qso: candidateAsOfNow(candidate.qso),
|
|
148
141
|
operation: args.operation,
|
|
149
142
|
ref: args.ref,
|
package/docs/distribution.md
CHANGED
|
@@ -198,8 +198,8 @@ below — manifest, source, build script — so the rest of this section is what
|
|
|
198
198
|
change rather than what to type.
|
|
199
199
|
|
|
200
200
|
```js
|
|
201
|
-
import { build } from
|
|
202
|
-
import { buildExtension } from
|
|
201
|
+
import { build } from "esbuild"
|
|
202
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
203
203
|
|
|
204
204
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
205
205
|
```
|
|
@@ -224,7 +224,7 @@ own module state — and nothing anywhere that says so. If the second copy is
|
|
|
224
224
|
what you want, which is how you use a version the host does not carry, say so:
|
|
225
225
|
|
|
226
226
|
```js
|
|
227
|
-
await buildExtension(build, { dir: import.meta.dirname, inline: [
|
|
227
|
+
await buildExtension(build, { dir: import.meta.dirname, inline: ["liquidjs"] })
|
|
228
228
|
```
|
|
229
229
|
|
|
230
230
|
`extensions/samples/hello-world` is the shortest complete example. Declaring
|
package/docs/hooks.md
CHANGED
|
@@ -499,8 +499,11 @@ interface ActivityHook {
|
|
|
499
499
|
suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
|
|
500
500
|
processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
|
|
501
501
|
}
|
|
502
|
-
// LoggingControlDescriptor: {key, label, icon?, color?, order?,
|
|
503
|
-
// allowsMultiple?, editable?, input}
|
|
502
|
+
// LoggingControlDescriptor: {key, label, shortLabel?, icon?, color?, order?,
|
|
503
|
+
// optionType?, allowsMultiple?, editable?, input}
|
|
504
|
+
// `shortLabel` is the same name in fewer characters, for a field the logging
|
|
505
|
+
// row has squeezed below its label's width. The core measures and picks; the
|
|
506
|
+
// extension only says what the short form is.
|
|
504
507
|
// SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
|
|
505
508
|
// ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
|
|
506
509
|
|
|
@@ -1168,14 +1171,16 @@ interface ScoringHook {
|
|
|
1168
1171
|
scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
|
|
1169
1172
|
scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
|
|
1170
1173
|
}
|
|
1171
|
-
// ScoreQsosRequest: {operation, qsos, ref?, resumeFrom?}
|
|
1174
|
+
// ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?}
|
|
1172
1175
|
// ScoreQsosResult: {
|
|
1173
1176
|
// qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
|
|
1174
1177
|
// daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
|
|
1175
1178
|
// operationSummary: Record<tallyKey, ScoreTally>,
|
|
1176
1179
|
// scoresheet, // checkpoint to resume from
|
|
1180
|
+
// scoresheetDay?, // the day it ends on, handed back as resumeDay
|
|
1177
1181
|
// }
|
|
1178
|
-
//
|
|
1182
|
+
// ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, qsos?}
|
|
1183
|
+
// ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, qsos?}
|
|
1179
1184
|
// ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices>
|
|
1180
1185
|
```
|
|
1181
1186
|
|
|
@@ -1187,7 +1192,16 @@ both live paths for free.
|
|
|
1187
1192
|
A candidate that carries no `startAtMillis` (the draft's time is still
|
|
1188
1193
|
automatic) reaches the scorer stamped with the current time on both live
|
|
1189
1194
|
paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
|
|
1190
|
-
candidate that states a time keeps it.
|
|
1195
|
+
candidate that states a time keeps it. On both live paths a `resumeFrom`
|
|
1196
|
+
checkpoint, with the `qsos` logged since it, stands in for the whole-log read:
|
|
1197
|
+
the harness copies the checkpoint, replays that tail onto it (continuing the
|
|
1198
|
+
day `resumeDay` names, so no scorer's per-day tally restarts mid-day) and
|
|
1199
|
+
scores the candidate against the result. The host sends a checkpoint only when
|
|
1200
|
+
it can prove the log is what the checkpoint folded plus appended contacts;
|
|
1201
|
+
otherwise the harness reads the log through `ctx.getQsos` and folds it cold.
|
|
1202
|
+
The batched `scoreQsos` resumes the same way: after the first pass of a
|
|
1203
|
+
session, an append arrives as `resumeFrom` plus the appended contacts, and only
|
|
1204
|
+
their verdicts come back.
|
|
1191
1205
|
`scoreCandidates` answers the same question for many at once, which is what the
|
|
1192
1206
|
Spots Panel asks to mark the spots already worked: the log is folded once per
|
|
1193
1207
|
scorer and every candidate scored against a COPY of the result, so no candidate
|
|
@@ -1288,13 +1302,14 @@ numeric value patches; local-only controls and animation frames make no bridge
|
|
|
1288
1302
|
calls. Controls with `continuous: true` send `change` events during dragging and a
|
|
1289
1303
|
final `commit` on release; pending movements are coalesced to the newest value.
|
|
1290
1304
|
Numeric text supports `scale`, `truncate`, `modulo`, and `minIntegerDigits`
|
|
1291
|
-
for local numeric formatting
|
|
1305
|
+
for local numeric formatting; bindings accept `scale`, `truncate` and `modulo`
|
|
1306
|
+
too, applied to the value before the ramp. KI2D’s Radio Panel uses these for its Modern
|
|
1292
1307
|
frequency groups; its settings offer Modern/LCD styles and radio selection. Local sliders may use `hover` and `discrete` hourly buckets. Text `samples`
|
|
1293
1308
|
may hold numbers or preformatted strings; control `valueLabels` supplies accessible
|
|
1294
1309
|
value descriptions. Transform/opacity bindings may also supply numeric `samples`,
|
|
1295
1310
|
equally spaced over their `input` range, instead of the `output` ramp. The host
|
|
1296
1311
|
interpolates and clamps these locally; chart markers and callouts can follow
|
|
1297
|
-
individual readings without bridge calls. See [the
|
|
1312
|
+
individual readings without bridge calls. See [the design notes and open milestones](https://github.com/ham2k/halo/blob/main/docs/design/svg-scenes.md)
|
|
1298
1313
|
and the `k2hrc-svg-scenes` sample. This API is experimental.
|
|
1299
1314
|
|
|
1300
1315
|
Scene-capable hosts also supply `args.environment` on **render and event** calls:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -21,14 +21,7 @@
|
|
|
21
21
|
// for the whole thing.
|
|
22
22
|
|
|
23
23
|
import { annotateCallAgainstCountryFile, contestScorer, defineExtension } from "@ham2k/extension-sdk"
|
|
24
|
-
import type {
|
|
25
|
-
ContestScorer,
|
|
26
|
-
HookContext,
|
|
27
|
-
JSONValue,
|
|
28
|
-
LoggingControlDescriptor,
|
|
29
|
-
Ref,
|
|
30
|
-
ScoreTally,
|
|
31
|
-
} from "@ham2k/extension-sdk"
|
|
24
|
+
import type { ContestScorer, HookContext, JSONValue, LoggingControlDescriptor, Ref, ScoreTally } from "@ham2k/extension-sdk"
|
|
32
25
|
import { fmtInteger } from "@ham2k/lib-format-tools"
|
|
33
26
|
|
|
34
27
|
import manifest from "../manifest.json" with { type: "json" }
|
|
@@ -53,7 +46,9 @@ function refOfType(container: Record<string, JSONValue>, type: string): Record<s
|
|
|
53
46
|
|
|
54
47
|
/// "05" and "5" are the same multiplier and must not count twice.
|
|
55
48
|
function normalizeZone(value: JSONValue | undefined): string {
|
|
56
|
-
const digits = String(value ?? "")
|
|
49
|
+
const digits = String(value ?? "")
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/^0+(?=\d)/, "")
|
|
57
52
|
return /^([1-9]|[1-3][0-9]|40)$/.test(digits) ? digits : ""
|
|
58
53
|
}
|
|
59
54
|
|
|
@@ -154,10 +149,7 @@ const CQWWScorer: ContestScorer<Scoresheet> = {
|
|
|
154
149
|
|
|
155
150
|
const ActivityHook = {
|
|
156
151
|
/// What the operator fills in when adding this contest to an operation.
|
|
157
|
-
async operationControls(
|
|
158
|
-
_args: { operation: Record<string, JSONValue> },
|
|
159
|
-
_ctx: HookContext,
|
|
160
|
-
): Promise<LoggingControlDescriptor[]> {
|
|
152
|
+
async operationControls(_args: { operation: Record<string, JSONValue> }, _ctx: HookContext): Promise<LoggingControlDescriptor[]> {
|
|
161
153
|
return [
|
|
162
154
|
{
|
|
163
155
|
key: "k2hrc-cqww/setup",
|
|
@@ -209,8 +201,7 @@ const ActivityHook = {
|
|
|
209
201
|
const their = (args.qso?.their as Record<string, JSONValue>) ?? {}
|
|
210
202
|
const guess = (their.guess as Record<string, JSONValue>) ?? {}
|
|
211
203
|
const call = str(their.call)
|
|
212
|
-
const suggested =
|
|
213
|
-
normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
|
|
204
|
+
const suggested = normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
|
|
214
205
|
|
|
215
206
|
return [
|
|
216
207
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -46,8 +46,7 @@ async function login(ctx: HookContext): Promise<string> {
|
|
|
46
46
|
const password = ctx.account?.credentials?.password
|
|
47
47
|
if (!username || !password) throw new Error("No HamQTH account configured")
|
|
48
48
|
|
|
49
|
-
const response = await host.fetch(
|
|
50
|
-
`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
|
|
49
|
+
const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
|
|
51
50
|
const sessionId = tag(response.body, "session_id")
|
|
52
51
|
if (!sessionId) throw new Error(tag(response.body, "error") ?? `HTTP ${response.status}`)
|
|
53
52
|
|
|
@@ -62,8 +61,7 @@ async function login(ctx: HookContext): Promise<string> {
|
|
|
62
61
|
/// Note that it re-logs-in only ONCE — a second failure is a real one, and
|
|
63
62
|
/// looping on it would hammer the API behind the operator's back.
|
|
64
63
|
async function fetchCall(call: string, ctx: HookContext): Promise<string> {
|
|
65
|
-
const search = (id: string) =>
|
|
66
|
-
host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
|
|
64
|
+
const search = (id: string) => host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
|
|
67
65
|
|
|
68
66
|
const sessionId = ctx.account?.session?.sessionId ?? (await login(ctx))
|
|
69
67
|
const response = await search(sessionId)
|
|
@@ -167,13 +165,11 @@ const HamQTHAccount = {
|
|
|
167
165
|
if (!username || !credentials.password) return "Enter your HamQTH username and password"
|
|
168
166
|
|
|
169
167
|
try {
|
|
170
|
-
const response = await host.fetch(
|
|
171
|
-
`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
|
|
168
|
+
const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
|
|
172
169
|
const sessionId = tag(response.body, "session_id")
|
|
173
170
|
if (!sessionId) return tag(response.body, "error") ?? `HamQTH returned HTTP ${response.status}`
|
|
174
171
|
|
|
175
|
-
const check = await host.fetch(
|
|
176
|
-
`${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
|
|
172
|
+
const check = await host.fetch(`${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
|
|
177
173
|
const name = tag(check.body, "adr_name")
|
|
178
174
|
return `✅ ${username.toUpperCase()}${name ? `: ${name}` : ""}`
|
|
179
175
|
} catch (e) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -19,12 +19,7 @@
|
|
|
19
19
|
// API key the program issues, and the export hooks that submit a log. See
|
|
20
20
|
// `docs/hooks.md` §`spots` and §`export`.
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
activityScorer,
|
|
24
|
-
contestScorer,
|
|
25
|
-
defineExtension,
|
|
26
|
-
referenceActivity,
|
|
27
|
-
} from "@ham2k/extension-sdk"
|
|
22
|
+
import { activityScorer, contestScorer, defineExtension, referenceActivity } from "@ham2k/extension-sdk"
|
|
28
23
|
import type { DataFileDefinition, HookContext } from "@ham2k/extension-sdk"
|
|
29
24
|
import { locationToGrid6 } from "@ham2k/lib-geo-tools"
|
|
30
25
|
|
|
@@ -116,7 +111,9 @@ const referenceList: DataFileDefinition = {
|
|
|
116
111
|
category: "k2hrc-llota",
|
|
117
112
|
|
|
118
113
|
jsonToLookupEntry: (entry: Record<string, any>) => {
|
|
119
|
-
const ref = String(entry?.reference_code ?? "")
|
|
114
|
+
const ref = String(entry?.reference_code ?? "")
|
|
115
|
+
.trim()
|
|
116
|
+
.toUpperCase()
|
|
120
117
|
// Returning null drops the entry. A list of this size will contain a few
|
|
121
118
|
// rows that are not references, and throwing would abandon the rest.
|
|
122
119
|
if (!ref) return null
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -49,11 +49,7 @@ const PREVIEW = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
|
49
49
|
/// document that does not parse, and the sandbox is a mitigation, not a reason
|
|
50
50
|
/// to skip this.
|
|
51
51
|
function escapeHtml(value: string): string {
|
|
52
|
-
return value
|
|
53
|
-
.replace(/&/g, "&")
|
|
54
|
-
.replace(/</g, "<")
|
|
55
|
-
.replace(/>/g, ">")
|
|
56
|
-
.replace(/"/g, """)
|
|
52
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
/// 14074000 → "14.074.0" — the grouping a radio's own display uses, which is
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -8,16 +8,8 @@
|
|
|
8
8
|
"icon": "radio-tower",
|
|
9
9
|
"accentColor": "#4C6EF5",
|
|
10
10
|
"api": 1,
|
|
11
|
-
"keywords": [
|
|
12
|
-
|
|
13
|
-
"panel",
|
|
14
|
-
"sample",
|
|
15
|
-
"weather",
|
|
16
|
-
"radio"
|
|
17
|
-
],
|
|
18
|
-
"hooks": [
|
|
19
|
-
"panel"
|
|
20
|
-
],
|
|
11
|
+
"keywords": ["svg", "panel", "sample", "weather", "radio"],
|
|
12
|
+
"hooks": ["panel"],
|
|
21
13
|
"sharedDependencies": {
|
|
22
14
|
"@ham2k/lib-callsigns": "^1.0.0",
|
|
23
15
|
"@ham2k/lib-country-files": "^1.0.0",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import { defineExtension } from
|
|
4
|
-
import manifest from
|
|
5
|
-
import { createScenePanels } from
|
|
3
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
4
|
+
import manifest from "../manifest.json" with { type: "json" }
|
|
5
|
+
import { createScenePanels } from "./scenes.ts"
|
|
6
6
|
|
|
7
7
|
defineExtension({
|
|
8
8
|
...manifest,
|
|
9
9
|
onActivation({ registerHook }) {
|
|
10
|
-
registerHook(
|
|
10
|
+
registerHook("panel", { key: manifest.key, hook: createScenePanels() })
|
|
11
11
|
},
|
|
12
12
|
})
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from
|
|
3
|
+
import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from "@ham2k/extension-sdk"
|
|
4
4
|
|
|
5
|
-
const svg = (body: string, width = 640, height = 360) =>
|
|
6
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
|
|
5
|
+
const svg = (body: string, width = 640, height = 360) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
|
|
7
6
|
const bind = (value: string, input: [number, number], output: [number, number]): SceneBinding => ({ value, input, output })
|
|
8
|
-
const label = (x: number, y: number, text: string, size = 16, color =
|
|
7
|
+
const label = (x: number, y: number, text: string, size = 16, color = "#9aaec6") =>
|
|
9
8
|
`<text x="${x}" y="${y}" fill="${color}" font-family="sans-serif" font-size="${size}">${text}</text>`
|
|
10
|
-
const button = (x: number, text: string) => `<rect x="${x}" y="298" width="172" height="38" rx="8" fill="#283b52"/>${label(x + 12, 322, text, 14,
|
|
11
|
-
const textLayer = (id: string, value: string, x: number, y: number, width: number,
|
|
12
|
-
|
|
13
|
-
|
|
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 => ({
|
|
11
|
+
id,
|
|
12
|
+
x,
|
|
13
|
+
y,
|
|
14
|
+
width,
|
|
15
|
+
height: size + 12,
|
|
16
|
+
text: { value, prefix, suffix, decimals, size, color: "#a9efcf" },
|
|
17
|
+
})
|
|
14
18
|
|
|
15
19
|
type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
|
|
16
20
|
/** Inject time for deterministic tests; no timers or per-frame extension work. */
|
|
@@ -24,103 +28,214 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
|
|
|
24
28
|
s = { requested: 14074, reported: 14074, connected: 1, due: 0, slow: 1 }
|
|
25
29
|
radios.set(id, s)
|
|
26
30
|
}
|
|
27
|
-
if (s.connected && s.due && now() >= s.due) {
|
|
31
|
+
if (s.connected && s.due && now() >= s.due) {
|
|
32
|
+
s.reported = s.requested
|
|
33
|
+
s.due = 0
|
|
34
|
+
}
|
|
28
35
|
return s
|
|
29
36
|
}
|
|
30
37
|
return {
|
|
31
38
|
async getPanels() {
|
|
32
39
|
return [
|
|
33
|
-
{ key:
|
|
34
|
-
{ key:
|
|
40
|
+
{ key: "radio", title: "Radio Front Face · Simulation", icon: "radio-tower", multiple: true, on: ["tick:1"] },
|
|
41
|
+
{ key: "weather", title: "Weather Chart · Simulation", icon: "weather-partly-cloudy", multiple: true },
|
|
35
42
|
]
|
|
36
43
|
},
|
|
37
44
|
async render(args): Promise<PanelContent> {
|
|
38
|
-
if (args.panelKey ===
|
|
45
|
+
if (args.panelKey === "weather") {
|
|
39
46
|
const temperatures = [17, 18, 21, 24, 25, 23, 20, 18]
|
|
40
|
-
const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(
|
|
41
|
-
return {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(" ")
|
|
48
|
+
return {
|
|
49
|
+
kind: "svgScene",
|
|
50
|
+
scene: {
|
|
51
|
+
version: 1,
|
|
52
|
+
width: 640,
|
|
53
|
+
height: 360,
|
|
54
|
+
values: { hour: 0 },
|
|
55
|
+
layers: [
|
|
56
|
+
{
|
|
57
|
+
id: "chart",
|
|
58
|
+
x: 0,
|
|
59
|
+
y: 0,
|
|
60
|
+
width: 640,
|
|
61
|
+
height: 360,
|
|
62
|
+
svg: svg(`
|
|
45
63
|
<rect width="640" height="360" rx="18" fill="#142337"/>
|
|
46
|
-
${label(30, 38,
|
|
47
|
-
${[15, 20, 25].map(t => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join(
|
|
64
|
+
${label(30, 38, "FORECAST / SIMULATED DATA", 18, "#e7edf4")}
|
|
65
|
+
${[15, 20, 25].map((t) => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join("")}
|
|
48
66
|
<polyline points="${points}" fill="none" stroke="#8bdccc" stroke-width="4"/>
|
|
49
|
-
${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288,
|
|
50
|
-
${label(30, 334,
|
|
51
|
-
`)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288, "+" + i + "h", 12)}`).join("")}
|
|
68
|
+
${label(30, 334, "Drag the chart to inspect · Arrow keys to step", 14)}
|
|
69
|
+
`),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "cursor",
|
|
73
|
+
x: 59,
|
|
74
|
+
y: 100,
|
|
75
|
+
width: 2,
|
|
76
|
+
height: 172,
|
|
77
|
+
svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
|
|
78
|
+
translateX: bind("hour", [0, 7], [0, 518]),
|
|
79
|
+
},
|
|
80
|
+
textLayer("hour", "hour", 30, 52, 250, 24, "In ", " hours"),
|
|
81
|
+
{
|
|
82
|
+
...textLayer("temperature", "hour", 370, 52, 240, 24, "", " °C"),
|
|
83
|
+
text: { value: "hour", samples: temperatures, suffix: " °C", size: 24, color: "#a9efcf" },
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
controls: [
|
|
87
|
+
{ id: "inspect", kind: "slider", label: "Forecast hour", x: 60, y: 100, width: 518, height: 185, value: "hour", min: 0, max: 7, step: 1 },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
62
91
|
}
|
|
63
92
|
const s = state(args.instanceId ?? args.panelKey)
|
|
64
|
-
return {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
93
|
+
return {
|
|
94
|
+
kind: "svgScene",
|
|
95
|
+
scene: {
|
|
96
|
+
version: 1,
|
|
97
|
+
width: 640,
|
|
98
|
+
height: 360,
|
|
99
|
+
values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
|
|
100
|
+
layers: [
|
|
101
|
+
{
|
|
102
|
+
id: "face",
|
|
103
|
+
x: 0,
|
|
104
|
+
y: 0,
|
|
105
|
+
width: 640,
|
|
106
|
+
height: 360,
|
|
107
|
+
svg: svg(`
|
|
69
108
|
<rect width="640" height="360" rx="18" fill="#142337"/>
|
|
70
|
-
${label(28, 35,
|
|
109
|
+
${label(28, 35, "RADIO FRONT FACE / SIMULATED CAT", 18, "#e7edf4")}
|
|
71
110
|
<rect x="28" y="58" width="350" height="114" rx="12" fill="#0a1622"/>
|
|
72
|
-
${label(42, 78,
|
|
111
|
+
${label(42, 78, "REPORTED VFO", 12)}${label(42, 143, "REQUESTED", 12)}
|
|
73
112
|
<path d="M417 150 A90 90 0 0 1 597 150" fill="none" stroke="#52657d" stroke-width="5"/>
|
|
74
|
-
${label(423, 170,
|
|
75
|
-
${label(424, 197,
|
|
113
|
+
${label(423, 170, "14.0", 12)}${label(565, 170, "14.35", 12)}
|
|
114
|
+
${label(424, 197, "VFO POSITION", 12)}
|
|
76
115
|
<path d="M42 236 H332" stroke="#52657d" stroke-width="6"/>
|
|
77
|
-
${label(42, 268,
|
|
78
|
-
${label(548, 256,
|
|
79
|
-
${button(28,
|
|
80
|
-
`)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
${label(42, 268, "Band position / kHz", 14)}
|
|
117
|
+
${label(548, 256, "LINK", 12)}
|
|
118
|
+
${button(28, "Connect / disconnect")}${button(234, "External tune +5 kHz")}${button(440, "Toggle 2 s / 0 s delay")}
|
|
119
|
+
`),
|
|
120
|
+
},
|
|
121
|
+
textLayer("reported", "reported", 42, 78, 320, 34, "", " kHz", 1),
|
|
122
|
+
textLayer("requested", "requested", 168, 121, 210, 22, "", "", 1),
|
|
123
|
+
{
|
|
124
|
+
id: "needle",
|
|
125
|
+
x: 503,
|
|
126
|
+
y: 72,
|
|
127
|
+
width: 8,
|
|
128
|
+
height: 80,
|
|
129
|
+
pivot: [0.5, 1],
|
|
130
|
+
transitionMs: 450,
|
|
131
|
+
svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
|
|
132
|
+
rotation: bind("reported", [14000, 14350], [-80, 80]),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "knob",
|
|
136
|
+
x: 400,
|
|
137
|
+
y: 204,
|
|
138
|
+
width: 72,
|
|
139
|
+
height: 72,
|
|
140
|
+
svg: svg(
|
|
141
|
+
'<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>',
|
|
142
|
+
72,
|
|
143
|
+
72,
|
|
144
|
+
),
|
|
145
|
+
rotation: bind("requested", [14000, 14350], [-150, 150]),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "handle",
|
|
149
|
+
x: 36,
|
|
150
|
+
y: 224,
|
|
151
|
+
width: 12,
|
|
152
|
+
height: 24,
|
|
153
|
+
svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
|
|
154
|
+
translateX: bind("requested", [14000, 14350], [0, 290]),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "led",
|
|
158
|
+
x: 548,
|
|
159
|
+
y: 208,
|
|
160
|
+
width: 40,
|
|
161
|
+
height: 40,
|
|
162
|
+
svg: svg(
|
|
163
|
+
'<defs><radialGradient id="glow"><stop stop-color="#a9ffbd"/><stop offset="0.3" stop-color="#58e8a2"/><stop offset="1" stop-color="#58e8a2" stop-opacity="0"/></radialGradient></defs><circle cx="20" cy="20" r="20" fill="url(#glow)"/>',
|
|
164
|
+
40,
|
|
165
|
+
40,
|
|
166
|
+
),
|
|
167
|
+
opacity: bind("connected", [0, 1], [0.08, 1]),
|
|
168
|
+
pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true },
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
controls: [
|
|
172
|
+
{
|
|
173
|
+
id: "tune",
|
|
174
|
+
label: "Tuning knob, kHz",
|
|
175
|
+
kind: "knob",
|
|
176
|
+
x: 398,
|
|
177
|
+
y: 202,
|
|
178
|
+
width: 76,
|
|
179
|
+
height: 76,
|
|
180
|
+
value: "requested",
|
|
181
|
+
min: 14000,
|
|
182
|
+
max: 14350,
|
|
183
|
+
step: 0.1,
|
|
184
|
+
sensitivity: 0.1,
|
|
185
|
+
event: "tune",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "band",
|
|
189
|
+
label: "Band position, kHz",
|
|
190
|
+
kind: "slider",
|
|
191
|
+
x: 42,
|
|
192
|
+
y: 215,
|
|
193
|
+
width: 290,
|
|
194
|
+
height: 42,
|
|
195
|
+
value: "requested",
|
|
196
|
+
min: 14000,
|
|
197
|
+
max: 14350,
|
|
198
|
+
step: 0.1,
|
|
199
|
+
event: "tune",
|
|
200
|
+
},
|
|
201
|
+
...(["connect", "external", "delay"] as const).map((event, i) => ({
|
|
202
|
+
id: event,
|
|
203
|
+
label: ["Toggle connection", "Simulate external tuning", "Toggle CAT delay"][i],
|
|
204
|
+
kind: "button" as const,
|
|
205
|
+
x: 28 + i * 206,
|
|
206
|
+
y: 298,
|
|
207
|
+
width: 172,
|
|
208
|
+
height: 38,
|
|
209
|
+
event,
|
|
210
|
+
})),
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
}
|
|
107
214
|
},
|
|
108
215
|
async onEvent(args) {
|
|
109
216
|
const s = state(args.instanceId ?? args.panelKey)
|
|
110
217
|
const event = args.event
|
|
111
|
-
if (event.action ===
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
218
|
+
if (event.action === "connect") {
|
|
219
|
+
s.connected = 1 - s.connected
|
|
220
|
+
s.due = 0
|
|
221
|
+
s.requested = s.reported
|
|
222
|
+
}
|
|
223
|
+
if (event.action === "delay") s.slow = 1 - s.slow
|
|
224
|
+
if (event.action === "external") {
|
|
225
|
+
if (!s.connected) throw new Error("Radio disconnected")
|
|
115
226
|
s.reported = s.reported + 5 > 14350 ? 14000 : s.reported + 5
|
|
116
|
-
s.requested = s.reported
|
|
227
|
+
s.requested = s.reported
|
|
228
|
+
s.due = 0
|
|
117
229
|
}
|
|
118
|
-
if (event.action ===
|
|
119
|
-
if (!s.connected) throw new Error(
|
|
120
|
-
if (typeof event.value !==
|
|
230
|
+
if (event.action === "tune") {
|
|
231
|
+
if (!s.connected) throw new Error("Radio disconnected")
|
|
232
|
+
if (typeof event.value !== "number" || !Number.isFinite(event.value)) throw new Error("Invalid frequency")
|
|
121
233
|
s.requested = Math.max(14000, Math.min(14350, event.value))
|
|
122
234
|
s.due = now() + (s.slow ? 2000 : 0)
|
|
123
|
-
if (!s.slow) {
|
|
235
|
+
if (!s.slow) {
|
|
236
|
+
s.reported = s.requested
|
|
237
|
+
s.due = 0
|
|
238
|
+
}
|
|
124
239
|
}
|
|
125
240
|
return { values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow } }
|
|
126
241
|
},
|