Skip to content

Commit 7fe1a3f

Browse files
committed
Use section timeline context for roving map QSO origins
Map lines and distance calculations were using the operation's final location, which made roving logs look like every contact came from the last stop. Add shared section-context timeline helpers in qsonTools and use them to derive per-QSO our.location from start/break snapshots before map rendering. Update map geometry and distance calculations to use each QSO's derived origin. Also switch ADIF traversal to the same helper so segment behavior stays aligned across features.
2 parents ff32a30 + 5a35dfb commit 7fe1a3f

8 files changed

Lines changed: 367 additions & 50 deletions

File tree

src/screens/OperationBadgeScreen/OperationBadgeScreen.jsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { loadQSOs, selectQSOs } from '../../store/qsos'
1717
import { selectSettings } from '../../store/settings'
1818
import { useThemedStyles } from '../../styles/tools/useThemedStyles'
1919
import { fmtDateTimeNiceZulu, fmtTimeBetween } from '../../tools/timeFormats'
20+
import { mapQSOsWithSectionContext } from '../../tools/qsonTools'
2021
import Color from 'color'
2122
import MapWithQSOs from '../OperationScreens/OpMapTab/components/MapWithQSOs'
2223
import { slashZeros } from '../../tools/stringTools'
@@ -108,8 +109,6 @@ export default function OperationBadgeScreen ({ navigation, route }) {
108109
const allQsosSelector = useCallback((state) => selectQSOs(state, route.params.operation.uuid), [route.params.operation.uuid])
109110
const allQsos = useSelector(allQsosSelector)
110111

111-
const qsos = useMemo(() => allQsos.filter(qso => !qso.deleted && !qso.event), [allQsos])
112-
113112
const qth = useMemo(() => {
114113
try {
115114
if (!operation?.grid) return {}
@@ -120,6 +119,30 @@ export default function OperationBadgeScreen ({ navigation, route }) {
120119
}
121120
}, [operation?.grid])
122121

122+
const qsos = useMemo(() => {
123+
const locationForGrid = (grid) => {
124+
if (!grid) return qth
125+
try {
126+
const [latitude, longitude] = gridToLocation(grid)
127+
return { latitude, longitude }
128+
} catch (e) {
129+
return qth
130+
}
131+
}
132+
133+
return mapQSOsWithSectionContext({
134+
qsos: allQsos,
135+
operation,
136+
map: ({ qso, sectionGrid }) => ({
137+
...qso,
138+
our: {
139+
...qso.our,
140+
location: locationForGrid(sectionGrid)
141+
}
142+
})
143+
})
144+
}, [allQsos, operation, qth])
145+
123146
const opDate = useMemo(() => {
124147
return `${fmtDateTimeNiceZulu(operation.startAtMillisMin)}`
125148
}, [operation])

src/screens/OperationScreens/OpMapTab/OpMapTab.jsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useThemedStyles } from '../../../styles/tools/useThemedStyles'
1919
import { selectOperation } from '../../../store/operations'
2020
import { selectQSOs } from '../../../store/qsos'
2121
import { selectSettings } from '../../../store/settings'
22+
import { mapQSOsWithSectionContext } from '../../../tools/qsonTools'
2223
import { useSelectorConditionally, useUIStateConditionally } from '../../components/useConditionally'
2324

2425
import MapWithQSOs from './components/MapWithQSOs'
@@ -56,7 +57,29 @@ export default function OpMapTab ({ navigation, route }) {
5657
const allQsosSelector = useCallback((state) => selectQSOs(state, route.params.operation.uuid), [route.params.operation.uuid])
5758
const allQsos = useSelector(allQsosSelector)
5859

59-
const qsos = useMemo(() => allQsos.filter(qso => !qso.deleted && !qso.event), [allQsos])
60+
const qsos = useMemo(() => {
61+
const locationForGrid = (grid) => {
62+
if (!grid) return qth
63+
try {
64+
const [latitude, longitude] = gridToLocation(grid)
65+
return { latitude, longitude }
66+
} catch (e) {
67+
return qth
68+
}
69+
}
70+
71+
return mapQSOsWithSectionContext({
72+
qsos: allQsos,
73+
operation,
74+
map: ({ qso, sectionGrid }) => ({
75+
...qso,
76+
our: {
77+
...qso.our,
78+
location: locationForGrid(sectionGrid)
79+
}
80+
})
81+
})
82+
}, [allQsos, operation, qth])
6083

6184
const [dismissedWarnings, setDismissedWarnings] = useState({})
6285

src/screens/OperationScreens/OpMapTab/components/MapWithQSOs.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ export default function MapWithQSOs ({ styles, operation, qth, qsos, settings, s
1717
return activeQSOs
1818
.map(qso => {
1919
const location = locationForQSONInfo(qso?.their)
20+
const ourLocation = qso?.our?.location ?? qth
2021
const strength = strengthForQSO(qso)
21-
const distance = location && qth ? distanceOnEarth(location, qth, { units: settings.distanceUnits }) : null
22+
const distance = location && ourLocation ? distanceOnEarth(location, ourLocation, { units: settings.distanceUnits }) : null
2223
const distanceStr = distance ? fmtDistance(distance, { units: settings.distanceUnits }) : ''
23-
return { qso, location, strength, distance, distanceStr }
24+
return { qso, location, ourLocation, strength, distance, distanceStr }
2425
})
2526
.filter(({ location }) => location)
2627
.sort((a, b) => b.strength - a.strength) // Weakest first

src/screens/OperationScreens/OpMapTab/components/MapboxMapWithQSOs.jsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -244,20 +244,21 @@ function _geoJSONMarkerForQSO ({ mappableQSO, qth, operation, styles }) {
244244
}
245245

246246
function _getJSONLinesForQSOs ({ mappableQSOs, qth, operation, styles }) {
247-
if (qth?.latitude !== undefined && qth?.longitude !== undefined) {
248-
const features = mappableQSOs.map(mappableQSO => _geoJSONLineForQSO({ mappableQSO, qth, operation, styles })).flat().filter(x => x)
247+
const features = mappableQSOs.map(mappableQSO => _geoJSONLineForQSO({ mappableQSO, qth, operation, styles })).flat().filter(x => x)
249248

250-
return {
251-
type: 'FeatureCollection',
252-
features
253-
}
249+
return {
250+
type: 'FeatureCollection',
251+
features
254252
}
255253
}
256254

257255
function _geoJSONLineForQSO ({ mappableQSO, qth, operation, styles }) {
258256
if (mappableQSO?.location?.latitude !== undefined && mappableQSO?.location?.longitude !== undefined) {
257+
const ourLocation = mappableQSO?.ourLocation ?? qth
258+
if (ourLocation?.latitude === undefined || ourLocation?.longitude === undefined) return null
259+
259260
const start = _coordsFromLatLon(mappableQSO.location)
260-
const end = _coordsFromLatLon(qth)
261+
const end = _coordsFromLatLon(ourLocation)
261262

262263
if (start[0] === end[0] && start[1] === end[1]) {
263264
return null

src/tools/qsonToADIF.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { basePartialTemplates, compileTemplateForOperation, extraDataForTemplate
1111
import { selectExportSettings } from '../store/settings'
1212
import { escapeToUnicodeEntities, sanitizeToISO8859 } from './stringTools'
1313
import { fmtADIFDate, fmtADIFTime, fmtISODateTime } from './timeFormats'
14+
import { forEachQSOWithSectionContext } from './qsonTools'
1415

1516
import { adifModeAndSubmodeForMode, frequencyForBand, modeForFrequency } from '@ham2k/lib-operation-data'
1617

@@ -52,8 +53,6 @@ export function qsonToADIF({ operation, settings, qsos, handler, format, title,
5253
common.operatorCall = operation.local?.operatorCall || operation.operatorCall
5354
}
5455

55-
const eventQSOs = qsos.filter(qso => qso.event && !qso.deleted)
56-
5756
let str = ''
5857
str += `ADIF for ${title || ([common.stationCall, operation?.title, operation.subTitle].filter(x => x).join(' ')) || 'Operation'} \n`
5958
str += adifField('ADIF_VER', '3.1.5', { newLine: true })
@@ -69,7 +68,7 @@ export function qsonToADIF({ operation, settings, qsos, handler, format, title,
6968

7069
str += '<EOH>\n'
7170

72-
qsos.forEach(qso => {
71+
forEachQSOWithSectionContext({ qsos, operation, withEvents: true, callback: ({ qso, sectionRefs, sectionGrid }) => {
7372
if (qso.deleted) return
7473
if (qso.event) {
7574
if (privateData) {
@@ -91,14 +90,15 @@ export function qsonToADIF({ operation, settings, qsos, handler, format, title,
9190
}
9291

9392
if (qso.event.event === 'break' || qso.event.event === 'start') {
93+
const segmentOperation = { ...qso.event.operation, refs: sectionRefs, grid: sectionGrid }
9494
if (combineSegmentRefs) {
95-
// Update all operation attributes, including regs
96-
operation = { ...operation, ...qso.event.operation }
97-
common = { ...common, ...qso.event.operation }
95+
// Update all operation attributes, including refs
96+
operation = { ...operation, ...segmentOperation }
97+
common = { ...common, ...segmentOperation }
9898
} else {
9999
// Combine other attributes, but keep refs as initialized
100-
operation = { ...operation, ...qso.event.operation, refs: operation.refs }
101-
common = { ...common, ...qso.event.operation, refs: common.refs }
100+
operation = { ...operation, ...segmentOperation, refs: operation.refs }
101+
common = { ...common, ...segmentOperation, refs: common.refs }
102102
}
103103
templates.context = templateContextForOneExport({ settings, operation, handler })
104104
}
@@ -155,7 +155,7 @@ export function qsonToADIF({ operation, settings, qsos, handler, format, title,
155155

156156
str += adifRow(fields)
157157
})
158-
})
158+
} })
159159

160160
return str
161161
}

src/tools/qsonToADIF.spec.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
* Copyright ©️ 2026 Robert Jackson <me@rwjblue.com>
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
5+
* If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
6+
*/
7+
8+
jest.mock('../extensions/registry', () => ({
9+
findBestHook: () => null
10+
}))
11+
12+
jest.mock('../store/settings', () => ({
13+
selectExportSettings: ({ settings }, key, defaults) => settings?.exports?.[key] ?? defaults ?? {}
14+
}))
15+
16+
jest.mock('../store/operations', () => ({
17+
basePartialTemplates: () => ({}),
18+
compileTemplateForOperation: () => () => '',
19+
extraDataForTemplates: () => ({}),
20+
templateContextForOneExport: ({ operation }) => ({ operation })
21+
}))
22+
23+
jest.mock('@ham2k/lib-operation-data', () => ({
24+
adifModeAndSubmodeForMode: (mode) => [mode || 'SSB'],
25+
frequencyForBand: () => 14250000,
26+
modeForFrequency: () => 'SSB'
27+
}))
28+
29+
import { qsonToADIF } from './qsonToADIF'
30+
31+
const baseSettings = {
32+
operatorCall: 'K1OP',
33+
exports: {
34+
default: {
35+
privateData: true
36+
}
37+
}
38+
}
39+
40+
function baseOperation () {
41+
return {
42+
stationCall: 'K1OP',
43+
refs: [{ type: 'pota', ref: 'US-1111' }],
44+
grid: 'FN31',
45+
state: 'CT'
46+
}
47+
}
48+
49+
function baseQSO ({ uuid, call, startAtMillis }) {
50+
return {
51+
uuid,
52+
startAtMillis,
53+
band: '20m',
54+
mode: 'SSB',
55+
their: { call },
56+
our: {}
57+
}
58+
}
59+
60+
function handlerWithContextFields () {
61+
return {
62+
key: 'test',
63+
adifFieldsForOneQSO: ({ operation, common }) => [
64+
{ X_OP_REF: operation.refs?.[0]?.ref },
65+
{ X_COMMON_REF: common.refs?.[0]?.ref },
66+
{ X_COMMON_GRID: common.grid }
67+
]
68+
}
69+
}
70+
71+
describe('qsonToADIF segment context', () => {
72+
it('keeps original refs when combineSegmentRefs is false', () => {
73+
const adif = qsonToADIF({
74+
operation: baseOperation(),
75+
settings: baseSettings,
76+
qsos: [
77+
{
78+
uuid: 'start',
79+
startAtMillis: 1000,
80+
event: {
81+
event: 'start',
82+
note: 'segment start',
83+
operation: {
84+
refs: [{ type: 'pota', ref: 'US-2222' }],
85+
grid: 'FM18'
86+
}
87+
}
88+
},
89+
baseQSO({ uuid: 'q1', call: 'K1AAA', startAtMillis: 2000 })
90+
],
91+
handler: handlerWithContextFields(),
92+
format: 'adif',
93+
combineSegmentRefs: false
94+
})
95+
96+
expect(adif).toMatch(/<X_OP_REF:\d+>US-1111/)
97+
expect(adif).toMatch(/<X_COMMON_REF:\d+>US-1111/)
98+
expect(adif).toMatch(/<X_COMMON_GRID:\d+>FM18/)
99+
})
100+
101+
it('switches refs when combineSegmentRefs is true', () => {
102+
const adif = qsonToADIF({
103+
operation: baseOperation(),
104+
settings: baseSettings,
105+
qsos: [
106+
{
107+
uuid: 'start',
108+
startAtMillis: 1000,
109+
event: {
110+
event: 'start',
111+
note: 'segment start',
112+
operation: {
113+
refs: [{ type: 'pota', ref: 'US-2222' }],
114+
grid: 'FM18'
115+
}
116+
}
117+
},
118+
baseQSO({ uuid: 'q1', call: 'K1AAA', startAtMillis: 2000 })
119+
],
120+
handler: handlerWithContextFields(),
121+
format: 'adif',
122+
combineSegmentRefs: true
123+
})
124+
125+
expect(adif).toMatch(/<X_OP_REF:\d+>US-2222/)
126+
expect(adif).toMatch(/<X_COMMON_REF:\d+>US-2222/)
127+
expect(adif).toMatch(/<X_COMMON_GRID:\d+>FM18/)
128+
})
129+
130+
it('ignores deleted start or break events when updating section context', () => {
131+
const adif = qsonToADIF({
132+
operation: baseOperation(),
133+
settings: baseSettings,
134+
qsos: [
135+
{
136+
uuid: 'deleted-break',
137+
deleted: true,
138+
startAtMillis: 1000,
139+
event: {
140+
event: 'break',
141+
note: 'deleted segment break',
142+
operation: {
143+
refs: [{ type: 'pota', ref: 'US-3333' }],
144+
grid: 'EM12'
145+
}
146+
}
147+
},
148+
baseQSO({ uuid: 'q1', call: 'K1AAA', startAtMillis: 2000 })
149+
],
150+
handler: handlerWithContextFields(),
151+
format: 'adif',
152+
combineSegmentRefs: true
153+
})
154+
155+
expect(adif).toMatch(/<X_OP_REF:\d+>US-1111/)
156+
expect(adif).toMatch(/<X_COMMON_REF:\d+>US-1111/)
157+
expect(adif).toMatch(/<X_COMMON_GRID:\d+>FN31/)
158+
expect(adif).not.toContain('US-3333')
159+
expect(adif).not.toContain('EM12')
160+
})
161+
})

0 commit comments

Comments
 (0)