From 7f95a7142675e5671e10b302b59af4f415b9f0b1 Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 14 Jul 2026 00:03:10 +0200 Subject: [PATCH] feat: added Worked All Italian Provinces award and FFMA --- frontend/src/App.tsx | 25 +- frontend/src/components/AwardEditor.tsx | 9 +- frontend/src/components/MainMap.tsx | 52 +- internal/award/award.go | 38 +- internal/award/award_test.go | 30 + internal/award/catalog/waip.json | 929 ++++++++++++++++++++++++ 6 files changed, 1046 insertions(+), 37 deletions(-) create mode 100644 internal/award/catalog/waip.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a784c59..5b7eddb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1180,22 +1180,29 @@ export default function App() { // Effective antenna heading(s): the rotor azimuth, transformed by the // Ultrabeam pattern when one is active — reversed (180°) points opposite, // bidirectional radiates both ways, normal is the heading itself. + // Headings are rounded to whole degrees: a rotor reports a jittering float, and + // a fraction of a degree changes nothing on a compass or a beam lobe — but it + // does invalidate every memo downstream and force the map to rebuild its whole + // overlay on each reading. + const rotorAz = useMemo(() => ( + rotatorHeading.enabled && rotatorHeading.ok + ? Math.round((((rotatorHeading.azimuth % 360) + 360) % 360)) % 360 + : null + ), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]); + const beamHeadings = useMemo(() => { - if (!(rotatorHeading.enabled && rotatorHeading.ok)) return []; - const base = ((rotatorHeading.azimuth % 360) + 360) % 360; + if (rotorAz == null) return []; if (ubStatus.enabled && ubStatus.connected) { - if (ubStatus.direction === 1) return [(base + 180) % 360]; - if (ubStatus.direction === 2) return [base, (base + 180) % 360]; + if (ubStatus.direction === 1) return [(rotorAz + 180) % 360]; + if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360]; } - return [base]; - }, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]); + return [rotorAz]; + }, [rotorAz, ubStatus.enabled, ubStatus.connected, ubStatus.direction]); // Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can // show where the antenna physically points (boom) vs where it radiates when // the Ultrabeam is reversed/bidirectional. - const boomHeading = useMemo(() => ( - rotatorHeading.enabled && rotatorHeading.ok ? ((rotatorHeading.azimuth % 360) + 360) % 360 : null - ), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]); + const boomHeading = rotorAz; const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => { if (!(ubStatus.enabled && ubStatus.connected)) return null; return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal'; diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index a251ff8..b9da51c 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -742,10 +742,11 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan patchSel({ name: e.target.value })} /> -
- patchSel({ group: e.target.value })} /> - patchSel({ subgrp: e.target.value })} /> -
+ {/* One per row: side by side, each half spends 120px of its width on + the label column and the input is left too narrow to read a group + name ("Basilicata" → "Basili"). */} + patchSel({ group: e.target.value })} /> + patchSel({ subgrp: e.target.value })} /> patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /> patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} />
diff --git a/frontend/src/components/MainMap.tsx b/frontend/src/components/MainMap.tsx index 75599b2..ec5a51e 100644 --- a/frontend/src/components/MainMap.tsx +++ b/frontend/src/components/MainMap.tsx @@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey { return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light'; } +// addBasemap (re)installs the imagery layer and, for satellite, its transparent +// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles +// down: satellite loads TWO tile layers, so its tile count — and the composited +// layers WebView2 has to hold — is double every other basemap's. +function addBasemap( + m: L.Map, + key: BasemapKey, + base: React.MutableRefObject, + labels: React.MutableRefObject, +) { + if (base.current) { m.removeLayer(base.current); base.current = null; } + if (labels.current) { m.removeLayer(labels.current); labels.current = null; } + const bm = BASEMAPS[key]; + const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 }; + base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m); + if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m); +} + function dot(color: string): L.DivIcon { return L.divIcon({ className: '', @@ -103,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b // One-time map creation. useEffect(() => { if (worldRef.current && !worldMap.current) { - const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true }) + // preferCanvas: the beam lobe is a dense FAN of translucent radials — up to + // ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120 + // composited paths re-rasterised on every pan, zoom and redraw, which is + // enough to blow WebView2's raster budget and leave the window painting in + // patches. On canvas it is a single layer. + const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true }) .setView([20, 0], 1); - const bm = BASEMAPS[basemap]; - baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m); - if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m); + addBasemap(m, basemap, baseLayer, labelsLayer); worldOverlay.current = L.layerGroup().addTo(m); worldMap.current = m; const sv = loadMapView(); @@ -115,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); }); } const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80); - return () => window.clearTimeout(t); + // Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on + // every overlay redraw (as before) forced a full repaint of the map each time + // the rotor moved a degree. + const ro = new ResizeObserver(() => worldMap.current?.invalidateSize()); + if (worldRef.current) ro.observe(worldRef.current); + return () => { window.clearTimeout(t); ro.disconnect(); }; }, []); // Swap the basemap (and its optional place-name overlay) when the operator @@ -123,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b // overlayPane, always above any tile layer, so nothing to re-stack there. useEffect(() => { const m = worldMap.current; - if (!m) return; - if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; } - if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; } - const bm = BASEMAPS[basemap]; - baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m); - if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m); + if (m) addBasemap(m, basemap, baseLayer, labelsLayer); }, [basemap]); // Redraw overlays whenever the operator/DX grids (or beam) change. @@ -224,9 +245,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b wm.setView([from.lat, from.lon], 3); } } - setTimeout(() => { wm.invalidateSize(); }, 0); + // No invalidateSize() here — a ResizeObserver handles the only case that needs + // it. Forcing a full map repaint on every redraw is what made a moving rotor + // thrash the compositor. + // Headings are rounded in the deps: a rotor reports a jittering float, and a + // tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]); + }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, + boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]); const path = pathBetween(fromGrid, toGrid); diff --git a/internal/award/award.go b/internal/award/award.go index 1d6d175..1d7719c 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -693,7 +693,15 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) // only while nothing has matched yet, and stop at the first that yields a // reference (short-circuit). So a province already found by NAME isn't also // re-derived, possibly differently, from a later city-regex rule. - found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined) + // + // The short-circuit tests what a rule REALLY yielded — the references that + // survive the predefined list — not its raw candidates. A rule can always + // produce a raw candidate and still find nothing: "the whole ADDRESS field is + // the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province. + // Testing the raw candidate would call that a hit, skip every fallback, and + // only then drop it as unlisted — leaving the QSO unmatched even though the + // next rule ("find the code inside the QTH") would have found BG. + found := keepRefs(predefined, rl, searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)) for i := 0; len(found) == 0 && i < len(d.OrRules); i++ { r := &d.OrRules[i] var rre *regexp.Regexp @@ -704,7 +712,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) } rre = c } - found = searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined) + found = keepRefs(predefined, rl, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)) } // Merge operator-assigned references (manual override, ManualRefsKey). Lets @@ -714,18 +722,26 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) // hand. Applied HERE (not just in MatchQSO) so Compute — which powers the // awards panel and the per-QSO refs editor — honours overrides too. For a // predefined award the ref is still validated against the list below. - for _, c := range manualRefs(q, d.Code) { - found = append(found, normalizeRef(c)) - } + found = append(found, keepRefs(predefined, rl, manualRefs(q, d.Code))...) + return dedupe(found) +} +// keepRefs reduces a rule's raw candidates to the references that actually count. +// For a predefined award that means the codes on the list, enabled. The +// award-level DXCCFilter already scopes which QSOs are considered (see inScope), +// so we do NOT additionally require the QSO's entity to match the reference's own +// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not +// 291). Per-reference DXCC stays metadata for the picker. +func keepRefs(predefined bool, rl refList, found []string) []string { if !predefined { - return dedupe(found) + out := make([]string, 0, len(found)) + for _, c := range found { + if c = normalizeRef(c); c != "" { + out = append(out, c) + } + } + return dedupe(out) } - // Enforce the predefined list: keep only listed, valid references. The - // award-level DXCCFilter already scopes which QSOs are considered (see - // inScope), so we do NOT additionally require the QSO's entity to match the - // reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is - // DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker. var out []string seen := map[string]struct{}{} for _, c := range found { diff --git a/internal/award/award_test.go b/internal/award/award_test.go index 875f1ca..ff3fb1a 100644 --- a/internal/award/award_test.go +++ b/internal/award/award_test.go @@ -354,6 +354,36 @@ func TestCatalogFFMA(t *testing.T) { } } +// WAIP: the primary rule takes the WHOLE address as the province code (exact +// match), which can never be one — but it does produce a raw candidate. The OR +// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works, +// and it must still run: a rule that yields nothing VALID is not a hit. +func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) { + def := Def{ + Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true, + Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true, + OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field + Confirm: []string{"lotw", "qsl"}, + } + metas := []RefMeta{ + {Code: "BG", Name: "Bergamo", Valid: true}, + {Code: "MI", Name: "Milano", Valid: true}, + } + qsos := []qso.QSO{ + {Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"}, + } + r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0] + if r.Worked != 1 { + t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked) + } + for _, rf := range r.Refs { + if rf.Ref == "BG" && rf.Worked { + return + } + } + t.Errorf("BG not worked; refs = %v", refCodes(r)) +} + func refCodes(r Result) []string { out := make([]string, 0, len(r.Refs)) for _, rf := range r.Refs { diff --git a/internal/award/catalog/waip.json b/internal/award/catalog/waip.json new file mode 100644 index 0000000..e3b8453 --- /dev/null +++ b/internal/award/catalog/waip.json @@ -0,0 +1,929 @@ +{ + "version": 1, + "exported_at": "2026-07-13T21:57:23Z", + "awards": [ + { + "def": { + "code": "WAIP", + "name": "ARI Worked All Italian Provinces", + "valid": true, + "url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html", + "ref_url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html", + "type": "REFERENCE", + "field": "qth", + "match_by": "code", + "exact_match": true, + "pattern": "", + "or_rules": [ + { + "field": "qth", + "match_by": "description" + }, + { + "field": "address", + "match_by": "code" + }, + { + "field": "address", + "match_by": "description" + } + ], + "dxcc_filter": [ + 248 + ], + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw", + "qsl" + ], + "total": 0, + "builtin": false + }, + "references": [ + { + "code": "AG", + "name": "Agrigento", + "dxcc": 248, + "group": "Sicilia", + "subgrp": "", + "valid": true + }, + { + "code": "AL", + "name": "Alessandria", + "dxcc": 248, + "group": "Piemonte", + "subgrp": "", + "valid": true + }, + { + "code": "AN", + "name": "Ancona", + "dxcc": 248, + "group": "Marche", + "subgrp": "", + "valid": true + }, + { + "code": "AO", + "name": "Aosta", + "dxcc": 248, + "group": "Val d'Aosta", + "subgrp": "", + "valid": true + }, + { + "code": "AP", + "name": "Ascoli Piceno", + "dxcc": 248, + "group": "Marche", + "subgrp": "", + "valid": true + }, + { + "code": "AQ", + "name": "L'Aquila", + "dxcc": 248, + "group": "Abruzzo", + "subgrp": "", + "valid": true + }, + { + "code": "AR", + "name": "Arezzo", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "AT", + "name": "Asti", + "dxcc": 248, + "group": "Piemonte", + "subgrp": "", + "valid": true + }, + { + "code": "AV", + "name": "Avellino", + "dxcc": 248, + "group": "Campania", + "subgrp": "", + "valid": true + }, + { + "code": "BA", + "name": "Bari", + "dxcc": 248, + "group": "Puglia", + "subgrp": "", + "valid": true + }, + { + "code": "BG", + "name": "Bergamo", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "BI", + "name": "Biella", + "dxcc": 248, + "group": "Piemonte", + "subgrp": "", + "valid": true + }, + { + "code": "BL", + "name": "Belluno", + "dxcc": 248, + "group": "Veneto", + "subgrp": "", + "valid": true + }, + { + "code": "BN", + "name": "Benevento", + "dxcc": 248, + "group": "Campania", + "subgrp": "", + "valid": true + }, + { + "code": "BO", + "name": "Bologna", + "dxcc": 248, + "group": "Emilia-Romagna", + "subgrp": "", + "valid": true + }, + { + "code": "BR", + "name": "Brindisi", + "dxcc": 248, + "group": "Puglia", + "subgrp": "", + "valid": true + }, + { + "code": "BS", + "name": "Brescia", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "BT", + "name": "Barletta-Andria-Trani", + "dxcc": 248, + "group": "Puglia", + "subgrp": "", + "valid": true + }, + { + "code": "BZ", + "name": "Bolzano/Bozen", + "dxcc": 248, + "group": "Trentino-AltoAdige/Südtirol", + "subgrp": "", + "valid": true + }, + { + "code": "CA", + "name": "Cagliari", + "dxcc": 248, + "group": "Sardegna", + "subgrp": "", + "valid": true + }, + { + "code": "CB", + "name": "Campobasso", + "dxcc": 248, + "group": "Molise", + "subgrp": "", + "valid": true + }, + { + "code": "CE", + "name": "Caserta", + "dxcc": 248, + "group": "Campania", + "subgrp": "", + "valid": true + }, + { + "code": "CH", + "name": "Chieti", + "dxcc": 248, + "group": "Abruzzo", + "subgrp": "", + "valid": true + }, + { + "code": "CI", + "name": "Carbonia-Iglesias", + "dxcc": 248, + "group": "Sardegna", + "subgrp": "", + "valid": true + }, + { + "code": "CL", + "name": "Caltanissetta", + "dxcc": 248, + "group": "Sicilia", + "subgrp": "", + "valid": true + }, + { + "code": "CN", + "name": "Cuneo", + "dxcc": 248, + "group": "Piemonte", + "subgrp": "", + "valid": true + }, + { + "code": "CO", + "name": "Como", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "CR", + "name": "Cremona", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "CS", + "name": "Cosenza", + "dxcc": 248, + "group": "Calabria", + "subgrp": "", + "valid": true + }, + { + "code": "CT", + "name": "Catania", + "dxcc": 248, + "group": "Sicilia", + "subgrp": "", + "valid": true + }, + { + "code": "CZ", + "name": "Catanzaro", + "dxcc": 248, + "group": "Calabria", + "subgrp": "", + "valid": true + }, + { + "code": "EN", + "name": "Enna", + "dxcc": 248, + "group": "Sicilia", + "subgrp": "", + "valid": true + }, + { + "code": "FC", + "name": "Forlì-Cesena", + "dxcc": 248, + "group": "Emilia-Romagna", + "subgrp": "", + "valid": true + }, + { + "code": "FE", + "name": "Ferrara", + "dxcc": 248, + "group": "Emilia-Romagna", + "subgrp": "", + "valid": true + }, + { + "code": "FG", + "name": "Foggia", + "dxcc": 248, + "group": "Puglia", + "subgrp": "", + "valid": true + }, + { + "code": "FI", + "name": "Firenze", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "FM", + "name": "Fermo", + "dxcc": 248, + "group": "Marche", + "subgrp": "", + "valid": true + }, + { + "code": "FR", + "name": "Frosinone", + "dxcc": 248, + "group": "Lazio", + "subgrp": "", + "valid": true + }, + { + "code": "GE", + "name": "Genova", + "dxcc": 248, + "group": "Liguria", + "subgrp": "", + "valid": true + }, + { + "code": "GO", + "name": "Gorizia", + "dxcc": 248, + "group": "Friuli-Venezia Giulia", + "subgrp": "", + "valid": true + }, + { + "code": "GR", + "name": "Grosseto", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "IM", + "name": "Imperia", + "dxcc": 248, + "group": "Liguria", + "subgrp": "", + "valid": true + }, + { + "code": "IS", + "name": "Isernia", + "dxcc": 248, + "group": "Molise", + "subgrp": "", + "valid": true + }, + { + "code": "KR", + "name": "Crotone", + "dxcc": 248, + "group": "Calabria", + "subgrp": "", + "valid": true + }, + { + "code": "LC", + "name": "Lecco", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "LE", + "name": "Lecce", + "dxcc": 248, + "group": "Puglia", + "subgrp": "", + "valid": true + }, + { + "code": "LI", + "name": "Livorno", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "LO", + "name": "Lodi", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "LT", + "name": "Latina", + "dxcc": 248, + "group": "Lazio", + "subgrp": "", + "valid": true + }, + { + "code": "LU", + "name": "Lucca", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "MB", + "name": "Monza e Brianza", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "MC", + "name": "Macerata", + "dxcc": 248, + "group": "Marche", + "subgrp": "", + "valid": true + }, + { + "code": "ME", + "name": "Messina", + "dxcc": 248, + "group": "Sicilia", + "subgrp": "", + "valid": true + }, + { + "code": "MI", + "name": "Milano", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "MN", + "name": "Mantova", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "MO", + "name": "Modena", + "dxcc": 248, + "group": "Emilia-Romagna", + "subgrp": "", + "valid": true + }, + { + "code": "MS", + "name": "Massa-Carrara", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "MT", + "name": "Matera", + "dxcc": 248, + "group": "Basilicata", + "subgrp": "", + "valid": true + }, + { + "code": "NA", + "name": "Napoli", + "dxcc": 248, + "group": "Campania", + "subgrp": "", + "valid": true + }, + { + "code": "NO", + "name": "Novara", + "dxcc": 248, + "group": "Piemonte", + "subgrp": "", + "valid": true + }, + { + "code": "NU", + "name": "Nuoro", + "dxcc": 248, + "group": "Sardegna", + "subgrp": "", + "valid": true + }, + { + "code": "OG", + "name": "Ogliastra", + "dxcc": 248, + "group": "Sardegna", + "subgrp": "", + "valid": true + }, + { + "code": "OR", + "name": "Oristano", + "dxcc": 248, + "group": "Sardegna", + "subgrp": "", + "valid": true + }, + { + "code": "OT", + "name": "Olbia-Tempio", + "dxcc": 248, + "group": "Sardegna", + "subgrp": "", + "valid": true + }, + { + "code": "PA", + "name": "Palermo", + "dxcc": 248, + "group": "Sicilia", + "subgrp": "", + "valid": true + }, + { + "code": "PC", + "name": "Piacenza", + "dxcc": 248, + "group": "Emilia-Romagna", + "subgrp": "", + "valid": true + }, + { + "code": "PD", + "name": "Padova", + "dxcc": 248, + "group": "Veneto", + "subgrp": "", + "valid": true + }, + { + "code": "PE", + "name": "Pescara", + "dxcc": 248, + "group": "Abruzzo", + "subgrp": "", + "valid": true + }, + { + "code": "PG", + "name": "Perugia", + "dxcc": 248, + "group": "Umbria", + "subgrp": "", + "valid": true + }, + { + "code": "PI", + "name": "Pisa", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "PN", + "name": "Pordenone", + "dxcc": 248, + "group": "Friuli-Venezia Giulia", + "subgrp": "", + "valid": true + }, + { + "code": "PO", + "name": "Prato", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "PR", + "name": "Parma", + "dxcc": 248, + "group": "Emilia-Romagna", + "subgrp": "", + "valid": true + }, + { + "code": "PT", + "name": "Pistoia", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "PU", + "name": "Pesaro-Urbino", + "dxcc": 248, + "group": "Marche", + "subgrp": "", + "valid": true + }, + { + "code": "PV", + "name": "Pavia", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "PZ", + "name": "Potenza", + "dxcc": 248, + "group": "Basilicata", + "subgrp": "", + "valid": true + }, + { + "code": "RA", + "name": "Ravenna", + "dxcc": 248, + "group": "Emilia-Romagna", + "subgrp": "", + "valid": true + }, + { + "code": "RC", + "name": "Reggio Calabria", + "dxcc": 248, + "group": "Calabria", + "subgrp": "", + "valid": true + }, + { + "code": "RE", + "name": "Reggio Emilia", + "dxcc": 248, + "group": "Emilia-Romagna", + "subgrp": "", + "valid": true + }, + { + "code": "RG", + "name": "Ragusa", + "dxcc": 248, + "group": "Sicilia", + "subgrp": "", + "valid": true + }, + { + "code": "RI", + "name": "Rieti", + "dxcc": 248, + "group": "Lazio", + "subgrp": "", + "valid": true + }, + { + "code": "RM", + "name": "Roma", + "dxcc": 248, + "group": "Lazio", + "subgrp": "", + "valid": true + }, + { + "code": "RN", + "name": "Rimini", + "dxcc": 248, + "group": "Emilia-Romagna", + "subgrp": "", + "valid": true + }, + { + "code": "RO", + "name": "Rovigo", + "dxcc": 248, + "group": "Veneto", + "subgrp": "", + "valid": true + }, + { + "code": "SA", + "name": "Salerno", + "dxcc": 248, + "group": "Campania", + "subgrp": "", + "valid": true + }, + { + "code": "SI", + "name": "Siena", + "dxcc": 248, + "group": "Toscana", + "subgrp": "", + "valid": true + }, + { + "code": "SO", + "name": "Sondrio", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "SP", + "name": "La Spezia", + "dxcc": 248, + "group": "Liguria", + "subgrp": "", + "valid": true + }, + { + "code": "SR", + "name": "Siracusa", + "dxcc": 248, + "group": "Sicilia", + "subgrp": "", + "valid": true + }, + { + "code": "SS", + "name": "Sassari", + "dxcc": 248, + "group": "Sardegna", + "subgrp": "", + "valid": true + }, + { + "code": "SV", + "name": "Savona", + "dxcc": 248, + "group": "Liguria", + "subgrp": "", + "valid": true + }, + { + "code": "TA", + "name": "Taranto", + "dxcc": 248, + "group": "Puglia", + "subgrp": "", + "valid": true + }, + { + "code": "TE", + "name": "Teramo", + "dxcc": 248, + "group": "Abruzzo", + "subgrp": "", + "valid": true + }, + { + "code": "TN", + "name": "Trento", + "dxcc": 248, + "group": "Trentino-AltoAdige/Südtirol", + "subgrp": "", + "valid": true + }, + { + "code": "TO", + "name": "Torino", + "dxcc": 248, + "group": "Piemonte", + "subgrp": "", + "valid": true + }, + { + "code": "TP", + "name": "Trapani", + "dxcc": 248, + "group": "Sicilia", + "subgrp": "", + "valid": true + }, + { + "code": "TR", + "name": "Terni", + "dxcc": 248, + "group": "Umbria", + "subgrp": "", + "valid": true + }, + { + "code": "TS", + "name": "Trieste", + "dxcc": 248, + "group": "Friuli-Venezia Giulia", + "subgrp": "", + "valid": true + }, + { + "code": "TV", + "name": "Treviso", + "dxcc": 248, + "group": "Veneto", + "subgrp": "", + "valid": true + }, + { + "code": "UD", + "name": "Udine", + "dxcc": 248, + "group": "Friuli-Venezia Giulia", + "subgrp": "", + "valid": true + }, + { + "code": "VA", + "name": "Varese", + "dxcc": 248, + "group": "Lombardia", + "subgrp": "", + "valid": true + }, + { + "code": "VB", + "name": "Verbano-Cusio-Ossola", + "dxcc": 248, + "group": "Piemonte", + "subgrp": "", + "valid": true + }, + { + "code": "VC", + "name": "Vercelli", + "dxcc": 248, + "group": "Piemonte", + "subgrp": "", + "valid": true + }, + { + "code": "VE", + "name": "Venezia", + "dxcc": 248, + "group": "Veneto", + "subgrp": "", + "valid": true + }, + { + "code": "VI", + "name": "Vicenza", + "dxcc": 248, + "group": "Veneto", + "subgrp": "", + "valid": true + }, + { + "code": "VR", + "name": "Verona", + "dxcc": 248, + "group": "Veneto", + "subgrp": "", + "valid": true + }, + { + "code": "VS", + "name": "Medio Campidano", + "dxcc": 248, + "group": "Sardegna", + "subgrp": "", + "valid": true + }, + { + "code": "VT", + "name": "Viterbo", + "dxcc": 248, + "group": "Lazio", + "subgrp": "", + "valid": true + }, + { + "code": "VV", + "name": "Vibo Valentia", + "dxcc": 248, + "group": "Calabria", + "subgrp": "", + "valid": true + } + ] + } + ] +} \ No newline at end of file