Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"iaru_r1": {
|
||||
"name": "IARU Region 1",
|
||||
"bands": [
|
||||
{
|
||||
"name": "2200m", "low_hz": 135700, "high_hz": 137800,
|
||||
"segments": [
|
||||
{ "low_hz": 135700, "high_hz": 137800, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "630m", "low_hz": 472000, "high_hz": 479000,
|
||||
"segments": [
|
||||
{ "low_hz": 472000, "high_hz": 479000, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "160m", "low_hz": 1810000, "high_hz": 2000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1810000, "high_hz": 1838000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 1838000, "high_hz": 1840000, "mode": "Narrow", "label": "Narrow" },
|
||||
{ "low_hz": 1840000, "high_hz": 2000000, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "80m", "low_hz": 3500000, "high_hz": 3800000,
|
||||
"segments": [
|
||||
{ "low_hz": 3500000, "high_hz": 3570000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 3570000, "high_hz": 3600000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 3600000, "high_hz": 3620000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 3620000, "high_hz": 3800000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "60m", "low_hz": 5351500, "high_hz": 5366500,
|
||||
"segments": [
|
||||
{ "low_hz": 5351500, "high_hz": 5354000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 5354000, "high_hz": 5366500, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "40m", "low_hz": 7000000, "high_hz": 7200000,
|
||||
"segments": [
|
||||
{ "low_hz": 7000000, "high_hz": 7040000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 7040000, "high_hz": 7060000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 7060000, "high_hz": 7100000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 7100000, "high_hz": 7200000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "30m", "low_hz": 10100000, "high_hz": 10150000,
|
||||
"segments": [
|
||||
{ "low_hz": 10100000, "high_hz": 10140000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 10140000, "high_hz": 10150000, "mode": "Narrow", "label": "Narrow/Digi" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "20m", "low_hz": 14000000, "high_hz": 14350000,
|
||||
"segments": [
|
||||
{ "low_hz": 14000000, "high_hz": 14070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 14070000, "high_hz": 14099000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 14099000, "high_hz": 14101000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 14101000, "high_hz": 14112000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 14112000, "high_hz": 14350000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "17m", "low_hz": 18068000, "high_hz": 18168000,
|
||||
"segments": [
|
||||
{ "low_hz": 18068000, "high_hz": 18095000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 18095000, "high_hz": 18109000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 18109000, "high_hz": 18111000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 18111000, "high_hz": 18168000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "15m", "low_hz": 21000000, "high_hz": 21450000,
|
||||
"segments": [
|
||||
{ "low_hz": 21000000, "high_hz": 21070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 21070000, "high_hz": 21149000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 21149000, "high_hz": 21151000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 21151000, "high_hz": 21450000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "12m", "low_hz": 24890000, "high_hz": 24990000,
|
||||
"segments": [
|
||||
{ "low_hz": 24890000, "high_hz": 24915000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 24915000, "high_hz": 24929000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 24929000, "high_hz": 24931000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 24931000, "high_hz": 24990000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "10m", "low_hz": 28000000, "high_hz": 29700000,
|
||||
"segments": [
|
||||
{ "low_hz": 28000000, "high_hz": 28070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 28070000, "high_hz": 28190000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 28190000, "high_hz": 28225000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 28225000, "high_hz": 28320000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 28320000, "high_hz": 29100000, "mode": "Phone", "label": "Phone" },
|
||||
{ "low_hz": 29100000, "high_hz": 29510000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 29510000, "high_hz": 29700000, "mode": "Satellite", "label": "Satellite" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "6m", "low_hz": 50000000, "high_hz": 54000000,
|
||||
"segments": [
|
||||
{ "low_hz": 50000000, "high_hz": 50100000, "mode": "CW", "label": "CW/Beacon" },
|
||||
{ "low_hz": 50100000, "high_hz": 50500000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 50500000, "high_hz": 51000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 51000000, "high_hz": 52000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 52000000, "high_hz": 54000000, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "4m", "low_hz": 70000000, "high_hz": 70500000,
|
||||
"segments": [
|
||||
{ "low_hz": 70000000, "high_hz": 70100000, "mode": "CW", "label": "CW/Beacon" },
|
||||
{ "low_hz": 70100000, "high_hz": 70250000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 70250000, "high_hz": 70300000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 70300000, "high_hz": 70500000, "mode": "FM", "label": "FM" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "FM Broadcast", "low_hz": 87500000, "high_hz": 108000000,
|
||||
"segments": [
|
||||
{ "low_hz": 87500000, "high_hz": 108000000, "mode": "FM", "label": "FM Broadcasting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2m", "low_hz": 144000000, "high_hz": 146000000,
|
||||
"segments": [
|
||||
{ "low_hz": 144000000, "high_hz": 144150000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 144150000, "high_hz": 144400000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 144400000, "high_hz": 144490000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 144490000, "high_hz": 144500000, "mode": "Beacon", "label": "NCDXF Beacon" },
|
||||
{ "low_hz": 144500000, "high_hz": 144794000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 144794000, "high_hz": 144990000, "mode": "Narrow", "label": "Digital/APRS" },
|
||||
{ "low_hz": 144990000, "high_hz": 145194000, "mode": "FM", "label": "FM Simplex" },
|
||||
{ "low_hz": 145194000, "high_hz": 145806000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 145806000, "high_hz": 146000000, "mode": "Satellite", "label": "Satellite" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "70cm", "low_hz": 430000000, "high_hz": 440000000,
|
||||
"segments": [
|
||||
{ "low_hz": 430000000, "high_hz": 432000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 432000000, "high_hz": 432150000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 432150000, "high_hz": 432500000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 432500000, "high_hz": 432800000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 432800000, "high_hz": 433000000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 433000000, "high_hz": 435000000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 435000000, "high_hz": 438000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 438000000, "high_hz": 440000000, "mode": "FM", "label": "FM" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "23cm", "low_hz": 1240000000, "high_hz": 1300000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1240000000, "high_hz": 1243000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 1243000000, "high_hz": 1260000000, "mode": "Narrow", "label": "Digital/ATV" },
|
||||
{ "low_hz": 1260000000, "high_hz": 1270000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 1270000000, "high_hz": 1300000000, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"iaru_r2": {
|
||||
"name": "IARU Region 2",
|
||||
"bands": [
|
||||
{
|
||||
"name": "2200m", "low_hz": 135700, "high_hz": 137800,
|
||||
"segments": [
|
||||
{ "low_hz": 135700, "high_hz": 137800, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "630m", "low_hz": 472000, "high_hz": 479000,
|
||||
"segments": [
|
||||
{ "low_hz": 472000, "high_hz": 479000, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "160m", "low_hz": 1800000, "high_hz": 2000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1800000, "high_hz": 1840000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 1840000, "high_hz": 1850000, "mode": "Narrow", "label": "CW/Digi" },
|
||||
{ "low_hz": 1850000, "high_hz": 2000000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "80m", "low_hz": 3500000, "high_hz": 4000000,
|
||||
"segments": [
|
||||
{ "low_hz": 3500000, "high_hz": 3570000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 3570000, "high_hz": 3600000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 3600000, "high_hz": 3700000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 3700000, "high_hz": 4000000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "60m", "low_hz": 5330500, "high_hz": 5403500,
|
||||
"segments": [
|
||||
{ "low_hz": 5330500, "high_hz": 5403500, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "40m", "low_hz": 7000000, "high_hz": 7300000,
|
||||
"segments": [
|
||||
{ "low_hz": 7000000, "high_hz": 7040000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 7040000, "high_hz": 7060000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 7060000, "high_hz": 7100000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 7100000, "high_hz": 7125000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 7125000, "high_hz": 7300000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "30m", "low_hz": 10100000, "high_hz": 10150000,
|
||||
"segments": [
|
||||
{ "low_hz": 10100000, "high_hz": 10140000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 10140000, "high_hz": 10150000, "mode": "Narrow", "label": "Narrow/Digi" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "20m", "low_hz": 14000000, "high_hz": 14350000,
|
||||
"segments": [
|
||||
{ "low_hz": 14000000, "high_hz": 14070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 14070000, "high_hz": 14099000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 14099000, "high_hz": 14101000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 14101000, "high_hz": 14112000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 14112000, "high_hz": 14350000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "17m", "low_hz": 18068000, "high_hz": 18168000,
|
||||
"segments": [
|
||||
{ "low_hz": 18068000, "high_hz": 18095000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 18095000, "high_hz": 18109000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 18109000, "high_hz": 18111000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 18111000, "high_hz": 18168000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "15m", "low_hz": 21000000, "high_hz": 21450000,
|
||||
"segments": [
|
||||
{ "low_hz": 21000000, "high_hz": 21070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 21070000, "high_hz": 21149000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 21149000, "high_hz": 21151000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 21151000, "high_hz": 21450000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "12m", "low_hz": 24890000, "high_hz": 24990000,
|
||||
"segments": [
|
||||
{ "low_hz": 24890000, "high_hz": 24915000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 24915000, "high_hz": 24929000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 24929000, "high_hz": 24931000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 24931000, "high_hz": 24990000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "10m", "low_hz": 28000000, "high_hz": 29700000,
|
||||
"segments": [
|
||||
{ "low_hz": 28000000, "high_hz": 28070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 28070000, "high_hz": 28190000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 28190000, "high_hz": 28225000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 28225000, "high_hz": 28300000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 28300000, "high_hz": 29100000, "mode": "Phone", "label": "Phone" },
|
||||
{ "low_hz": 29100000, "high_hz": 29510000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 29510000, "high_hz": 29700000, "mode": "Satellite", "label": "Satellite" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "6m", "low_hz": 50000000, "high_hz": 54000000,
|
||||
"segments": [
|
||||
{ "low_hz": 50000000, "high_hz": 50100000, "mode": "CW", "label": "CW/Beacon" },
|
||||
{ "low_hz": 50100000, "high_hz": 50300000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 50300000, "high_hz": 50600000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 50600000, "high_hz": 51000000, "mode": "Narrow", "label": "Digital" },
|
||||
{ "low_hz": 51000000, "high_hz": 54000000, "mode": "FM", "label": "FM" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "FM Broadcast", "low_hz": 87500000, "high_hz": 108000000,
|
||||
"segments": [
|
||||
{ "low_hz": 87500000, "high_hz": 108000000, "mode": "FM", "label": "FM Broadcasting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2m", "low_hz": 144000000, "high_hz": 148000000,
|
||||
"segments": [
|
||||
{ "low_hz": 144000000, "high_hz": 144100000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 144100000, "high_hz": 144275000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 144275000, "high_hz": 144400000, "mode": "Beacon", "label": "Beacon/Packet" },
|
||||
{ "low_hz": 144400000, "high_hz": 145500000, "mode": "FM", "label": "FM Simplex" },
|
||||
{ "low_hz": 145500000, "high_hz": 146000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 146000000, "high_hz": 148000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "1.25m", "low_hz": 222000000, "high_hz": 225000000,
|
||||
"segments": [
|
||||
{ "low_hz": 222000000, "high_hz": 222150000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 222150000, "high_hz": 222250000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 222250000, "high_hz": 223380000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 223380000, "high_hz": 223520000, "mode": "Narrow", "label": "Digital" },
|
||||
{ "low_hz": 223520000, "high_hz": 225000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "70cm", "low_hz": 420000000, "high_hz": 450000000,
|
||||
"segments": [
|
||||
{ "low_hz": 420000000, "high_hz": 426000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 426000000, "high_hz": 432000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 432000000, "high_hz": 432100000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 432100000, "high_hz": 433000000, "mode": "Phone", "label": "SSB/All" },
|
||||
{ "low_hz": 433000000, "high_hz": 435000000, "mode": "FM", "label": "FM/Links" },
|
||||
{ "low_hz": 435000000, "high_hz": 438000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 438000000, "high_hz": 444000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 444000000, "high_hz": 450000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "33cm", "low_hz": 902000000, "high_hz": 928000000,
|
||||
"segments": [
|
||||
{ "low_hz": 902000000, "high_hz": 903000000, "mode": "Narrow", "label": "Narrowband/Digital" },
|
||||
{ "low_hz": 903000000, "high_hz": 906000000, "mode": "Narrow", "label": "Digital/Spread Spectrum" },
|
||||
{ "low_hz": 906000000, "high_hz": 909000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 909000000, "high_hz": 915000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 915000000, "high_hz": 921000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 921000000, "high_hz": 927000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 927000000, "high_hz": 928000000, "mode": "FM", "label": "FM Simplex" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "23cm", "low_hz": 1240000000, "high_hz": 1300000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1240000000, "high_hz": 1260000000, "mode": "All", "label": "All Modes/ATV" },
|
||||
{ "low_hz": 1260000000, "high_hz": 1270000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 1270000000, "high_hz": 1295000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 1295000000, "high_hz": 1300000000, "mode": "Narrow", "label": "Narrowband" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"iaru_r3": {
|
||||
"name": "IARU Region 3",
|
||||
"bands": [
|
||||
{
|
||||
"name": "2200m", "low_hz": 135700, "high_hz": 137800,
|
||||
"segments": [
|
||||
{ "low_hz": 135700, "high_hz": 137800, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "630m", "low_hz": 472000, "high_hz": 479000,
|
||||
"segments": [
|
||||
{ "low_hz": 472000, "high_hz": 479000, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "160m", "low_hz": 1800000, "high_hz": 2000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1800000, "high_hz": 1840000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 1840000, "high_hz": 1850000, "mode": "Narrow", "label": "CW/Digi" },
|
||||
{ "low_hz": 1850000, "high_hz": 2000000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "80m", "low_hz": 3500000, "high_hz": 3900000,
|
||||
"segments": [
|
||||
{ "low_hz": 3500000, "high_hz": 3570000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 3570000, "high_hz": 3600000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 3600000, "high_hz": 3620000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 3620000, "high_hz": 3900000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "40m", "low_hz": 7000000, "high_hz": 7300000,
|
||||
"segments": [
|
||||
{ "low_hz": 7000000, "high_hz": 7040000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 7040000, "high_hz": 7060000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 7060000, "high_hz": 7100000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 7100000, "high_hz": 7300000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "30m", "low_hz": 10100000, "high_hz": 10150000,
|
||||
"segments": [
|
||||
{ "low_hz": 10100000, "high_hz": 10140000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 10140000, "high_hz": 10150000, "mode": "Narrow", "label": "Narrow/Digi" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "20m", "low_hz": 14000000, "high_hz": 14350000,
|
||||
"segments": [
|
||||
{ "low_hz": 14000000, "high_hz": 14070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 14070000, "high_hz": 14099000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 14099000, "high_hz": 14101000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 14101000, "high_hz": 14112000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 14112000, "high_hz": 14350000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "17m", "low_hz": 18068000, "high_hz": 18168000,
|
||||
"segments": [
|
||||
{ "low_hz": 18068000, "high_hz": 18095000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 18095000, "high_hz": 18109000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 18109000, "high_hz": 18111000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 18111000, "high_hz": 18168000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "15m", "low_hz": 21000000, "high_hz": 21450000,
|
||||
"segments": [
|
||||
{ "low_hz": 21000000, "high_hz": 21070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 21070000, "high_hz": 21149000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 21149000, "high_hz": 21151000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 21151000, "high_hz": 21450000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "12m", "low_hz": 24890000, "high_hz": 24990000,
|
||||
"segments": [
|
||||
{ "low_hz": 24890000, "high_hz": 24915000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 24915000, "high_hz": 24929000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 24929000, "high_hz": 24931000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 24931000, "high_hz": 24990000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "10m", "low_hz": 28000000, "high_hz": 29700000,
|
||||
"segments": [
|
||||
{ "low_hz": 28000000, "high_hz": 28070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 28070000, "high_hz": 28190000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 28190000, "high_hz": 28225000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 28225000, "high_hz": 28300000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 28300000, "high_hz": 29100000, "mode": "Phone", "label": "Phone" },
|
||||
{ "low_hz": 29100000, "high_hz": 29510000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 29510000, "high_hz": 29700000, "mode": "Satellite", "label": "Satellite" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "6m", "low_hz": 50000000, "high_hz": 54000000,
|
||||
"segments": [
|
||||
{ "low_hz": 50000000, "high_hz": 50100000, "mode": "CW", "label": "CW/Beacon" },
|
||||
{ "low_hz": 50100000, "high_hz": 50300000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 50300000, "high_hz": 50600000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 50600000, "high_hz": 51000000, "mode": "Narrow", "label": "Digital" },
|
||||
{ "low_hz": 51000000, "high_hz": 54000000, "mode": "FM", "label": "FM" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "FM Broadcast", "low_hz": 87500000, "high_hz": 108000000,
|
||||
"segments": [
|
||||
{ "low_hz": 87500000, "high_hz": 108000000, "mode": "FM", "label": "FM Broadcasting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2m", "low_hz": 144000000, "high_hz": 148000000,
|
||||
"segments": [
|
||||
{ "low_hz": 144000000, "high_hz": 144100000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 144100000, "high_hz": 144400000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 144400000, "high_hz": 144500000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 144500000, "high_hz": 145000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 145000000, "high_hz": 146000000, "mode": "FM", "label": "FM Simplex" },
|
||||
{ "low_hz": 146000000, "high_hz": 148000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "70cm", "low_hz": 430000000, "high_hz": 450000000,
|
||||
"segments": [
|
||||
{ "low_hz": 430000000, "high_hz": 432000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 432000000, "high_hz": 432100000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 432100000, "high_hz": 432400000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 432400000, "high_hz": 432500000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 432500000, "high_hz": 435000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 435000000, "high_hz": 438000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 438000000, "high_hz": 440000000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 440000000, "high_hz": 450000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "23cm", "low_hz": 1240000000, "high_hz": 1300000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1240000000, "high_hz": 1260000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 1260000000, "high_hz": 1270000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 1270000000, "high_hz": 1300000000, "mode": "FM", "label": "FM/ATV" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||
|
||||
function decodeCborUint(view, bytes, state, additional) {
|
||||
const offset = state.offset;
|
||||
if (additional < 24) return additional;
|
||||
if (additional === 24) {
|
||||
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
state.offset += 1;
|
||||
return bytes[offset];
|
||||
}
|
||||
if (additional === 25) {
|
||||
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
state.offset += 2;
|
||||
return view.getUint16(offset);
|
||||
}
|
||||
if (additional === 26) {
|
||||
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
state.offset += 4;
|
||||
return view.getUint32(offset);
|
||||
}
|
||||
if (additional === 27) {
|
||||
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const value = view.getBigUint64(offset);
|
||||
state.offset += 8;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
|
||||
return numeric;
|
||||
}
|
||||
throw new Error("Unsupported CBOR additional info");
|
||||
}
|
||||
|
||||
function decodeCborFloat16(bits) {
|
||||
const sign = (bits & 0x8000) ? -1 : 1;
|
||||
const exponent = (bits >> 10) & 0x1f;
|
||||
const fraction = bits & 0x03ff;
|
||||
if (exponent === 0) {
|
||||
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
|
||||
}
|
||||
if (exponent === 0x1f) {
|
||||
return fraction === 0 ? sign * Infinity : Number.NaN;
|
||||
}
|
||||
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
|
||||
}
|
||||
|
||||
function decodeCborItem(view, bytes, state) {
|
||||
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||||
const initial = bytes[state.offset++];
|
||||
const major = initial >> 5;
|
||||
const additional = initial & 0x1f;
|
||||
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
||||
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
|
||||
if (major === 2) {
|
||||
const length = decodeCborUint(view, bytes, state, additional);
|
||||
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const chunk = bytes.slice(state.offset, state.offset + length);
|
||||
state.offset += length;
|
||||
return Array.from(chunk);
|
||||
}
|
||||
if (major === 3) {
|
||||
const length = decodeCborUint(view, bytes, state, additional);
|
||||
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const chunk = bytes.subarray(state.offset, state.offset + length);
|
||||
state.offset += length;
|
||||
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
|
||||
}
|
||||
if (major === 4) {
|
||||
const length = decodeCborUint(view, bytes, state, additional);
|
||||
const items = new Array(length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
items[i] = decodeCborItem(view, bytes, state);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (major === 5) {
|
||||
const length = decodeCborUint(view, bytes, state, additional);
|
||||
const value = {};
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const key = decodeCborItem(view, bytes, state);
|
||||
value[String(key)] = decodeCborItem(view, bytes, state);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (major === 6) {
|
||||
decodeCborUint(view, bytes, state, additional);
|
||||
return decodeCborItem(view, bytes, state);
|
||||
}
|
||||
if (major === 7) {
|
||||
if (additional === 20) return false;
|
||||
if (additional === 21) return true;
|
||||
if (additional === 22) return null;
|
||||
if (additional === 23) return undefined;
|
||||
if (additional === 25) {
|
||||
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const bits = view.getUint16(state.offset);
|
||||
state.offset += 2;
|
||||
return decodeCborFloat16(bits);
|
||||
}
|
||||
if (additional === 26) {
|
||||
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const value = view.getFloat32(state.offset);
|
||||
state.offset += 4;
|
||||
return value;
|
||||
}
|
||||
if (additional === 27) {
|
||||
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const value = view.getFloat64(state.offset);
|
||||
state.offset += 8;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new Error("Unsupported CBOR major type");
|
||||
}
|
||||
|
||||
function decodeCborPayload(buffer) {
|
||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const state = { offset: 0 };
|
||||
const value = decodeCborItem(view, bytes, state);
|
||||
if (state.offset !== bytes.length) {
|
||||
throw new Error("Unexpected trailing bytes in decode history payload");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function fetchAndDecodeHistory(url, batchLimit) {
|
||||
self.postMessage({ type: "status", phase: "fetching" });
|
||||
const resp = await fetch(url, { credentials: "same-origin" });
|
||||
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
|
||||
const payload = await resp.arrayBuffer();
|
||||
if (!payload || payload.byteLength === 0) {
|
||||
self.postMessage({ type: "start", total: 0 });
|
||||
self.postMessage({ type: "done", total: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
self.postMessage({ type: "status", phase: "decoding" });
|
||||
const history = decodeCborPayload(payload);
|
||||
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
||||
const items = history && Array.isArray(history[key]) ? history[key] : [];
|
||||
return sum + items.length;
|
||||
}, 0);
|
||||
self.postMessage({ type: "start", total });
|
||||
|
||||
let processed = 0;
|
||||
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
||||
|
||||
for (const kind of HISTORY_GROUP_KEYS) {
|
||||
const items = history && Array.isArray(history[kind]) ? history[kind] : [];
|
||||
if (items.length === 0) continue;
|
||||
for (let index = 0; index < items.length; index += safeLimit) {
|
||||
const messages = items.slice(index, index + safeLimit);
|
||||
processed += messages.length;
|
||||
self.postMessage({
|
||||
type: "group",
|
||||
kind,
|
||||
messages,
|
||||
processed,
|
||||
total,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.postMessage({ type: "done", total });
|
||||
}
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const data = event?.data || {};
|
||||
if (data?.type !== "fetch-history") return;
|
||||
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
|
||||
.catch((err) => {
|
||||
self.postMessage({
|
||||
type: "error",
|
||||
message: err && err.message ? err.message : String(err || "unknown worker failure"),
|
||||
});
|
||||
});
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
(function() {
|
||||
if (typeof L === "undefined") return;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function finiteAngle(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const normalized = ((Number(value) % 360) + 360) % 360;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function svgColor(value, fallback) {
|
||||
const text = String(value || fallback || "");
|
||||
return text.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function buildSymbolHtml(options, zoom) {
|
||||
const heading = finiteAngle(options.heading);
|
||||
const course = finiteAngle(options.course);
|
||||
const angle = heading != null ? heading : course;
|
||||
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
||||
const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
|
||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||||
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
|
||||
const color = svgColor(options.color, "#ff7559");
|
||||
const outline = svgColor(options.outline, "#6b2118");
|
||||
|
||||
const body = angle != null
|
||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
|
||||
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
|
||||
`</g>`
|
||||
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
||||
|
||||
const courseLine = course != null
|
||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
|
||||
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
|
||||
`</g>`
|
||||
: "";
|
||||
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
|
||||
courseLine +
|
||||
body +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
L.TrxAisTrackSymbol = L.Marker.extend({
|
||||
options: {
|
||||
heading: null,
|
||||
course: null,
|
||||
speed: null,
|
||||
color: "#ff7559",
|
||||
outline: "#6b2118",
|
||||
size: 22,
|
||||
interactive: true,
|
||||
keyboard: true,
|
||||
riseOnHover: true,
|
||||
},
|
||||
|
||||
initialize: function(latlng, options) {
|
||||
const merged = L.Util.extend({}, this.options, options || {});
|
||||
merged.icon = L.divIcon({
|
||||
className: "trx-ais-track-symbol-icon",
|
||||
html: "",
|
||||
iconSize: [merged.size, merged.size],
|
||||
iconAnchor: [merged.size / 2, merged.size / 2],
|
||||
});
|
||||
L.Marker.prototype.initialize.call(this, latlng, merged);
|
||||
},
|
||||
|
||||
onAdd: function(map) {
|
||||
L.Marker.prototype.onAdd.call(this, map);
|
||||
this._refreshIcon();
|
||||
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
||||
map.on("zoomend", this._boundZoomRefresh);
|
||||
},
|
||||
|
||||
onRemove: function(map) {
|
||||
if (this._boundZoomRefresh) {
|
||||
map.off("zoomend", this._boundZoomRefresh);
|
||||
this._boundZoomRefresh = null;
|
||||
}
|
||||
L.Marker.prototype.onRemove.call(this, map);
|
||||
},
|
||||
|
||||
setAisState: function(next) {
|
||||
if (next && typeof next === "object") {
|
||||
if ("heading" in next) this.options.heading = next.heading;
|
||||
if ("course" in next) this.options.course = next.course;
|
||||
if ("speed" in next) this.options.speed = next.speed;
|
||||
if ("color" in next) this.options.color = next.color;
|
||||
if ("outline" in next) this.options.outline = next.outline;
|
||||
}
|
||||
this._refreshIcon();
|
||||
return this;
|
||||
},
|
||||
|
||||
_refreshIcon: function() {
|
||||
if (!this._icon) return;
|
||||
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
|
||||
const html = buildSymbolHtml(this.options, zoom);
|
||||
this._icon.innerHTML = html;
|
||||
const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
|
||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||||
this._icon.style.width = `${size}px`;
|
||||
this._icon.style.height = `${size}px`;
|
||||
this._icon.style.marginLeft = `${-size / 2}px`;
|
||||
this._icon.style.marginTop = `${-size / 2}px`;
|
||||
},
|
||||
});
|
||||
|
||||
L.trxAisTrackSymbol = function(latlng, options) {
|
||||
return new L.TrxAisTrackSymbol(latlng, options);
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- AIS Decoder Plugin (server-side decode) ---
|
||||
const aisStatus = document.getElementById("ais-status");
|
||||
const aisMessagesEl = document.getElementById("ais-messages");
|
||||
const aisFilterInput = document.getElementById("ais-filter");
|
||||
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||
const aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
||||
const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
const AIS_DEFAULT_A_HZ = 161_975_000;
|
||||
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
||||
let aisFilterText = "";
|
||||
let aisMessageHistory = [];
|
||||
|
||||
function currentAisHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneAisMessageHistory() {
|
||||
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleAisUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleAisHistoryRender() {
|
||||
scheduleAisUi("ais-history", () => renderAisHistory());
|
||||
}
|
||||
|
||||
function scheduleAisBarUpdate() {
|
||||
scheduleAisUi("ais-bar", () => updateAisBar());
|
||||
}
|
||||
|
||||
function formatAisMhz(freqHz) {
|
||||
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
||||
}
|
||||
|
||||
function currentAisChannelPlan() {
|
||||
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||
return {
|
||||
aHz: safeAHz,
|
||||
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ,
|
||||
};
|
||||
}
|
||||
|
||||
function aisChannelInfo(channel) {
|
||||
const plan = currentAisChannelPlan();
|
||||
const ch = String(channel || "").trim().toUpperCase();
|
||||
if (ch === "B") {
|
||||
return {
|
||||
label: "AIS-B",
|
||||
badgeClass: "ais-badge ais-badge-channel-b",
|
||||
freqText: formatAisMhz(plan.bHz),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "AIS-A",
|
||||
badgeClass: "ais-badge ais-badge-channel-a",
|
||||
freqText: formatAisMhz(plan.aHz),
|
||||
};
|
||||
}
|
||||
|
||||
function aisDisplayName(msg) {
|
||||
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||
}
|
||||
|
||||
function aisDisplayNameHtml(msg) {
|
||||
const label = escapeMapHtml(aisDisplayName(msg));
|
||||
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
||||
if (!url) return label;
|
||||
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||
}
|
||||
|
||||
function aisTypeLabel(type) {
|
||||
switch (Number(type)) {
|
||||
case 1:
|
||||
case 2:
|
||||
case 3:
|
||||
return "Class A Position";
|
||||
case 4:
|
||||
return "Base Station";
|
||||
case 5:
|
||||
return "Static/Voyage";
|
||||
case 18:
|
||||
return "Class B Position";
|
||||
case 19:
|
||||
return "Class B Extended";
|
||||
case 21:
|
||||
return "Aid to Nav";
|
||||
case 24:
|
||||
return "Class B Static";
|
||||
default:
|
||||
return `Type ${type ?? "--"}`;
|
||||
}
|
||||
}
|
||||
|
||||
function aisAgeText(tsMs) {
|
||||
if (!Number.isFinite(tsMs)) return "just now";
|
||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||
const seconds = Math.round(deltaMs / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function aisMotionText(msg) {
|
||||
const parts = [
|
||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
|
||||
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function aisRouteText(msg) {
|
||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||
}
|
||||
|
||||
function aisDistanceText(msg) {
|
||||
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
||||
return "";
|
||||
}
|
||||
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
}
|
||||
|
||||
function aisLatestByVessel(messages) {
|
||||
const byMmsi = new Map();
|
||||
for (const msg of messages) {
|
||||
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
||||
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
||||
}
|
||||
return Array.from(byMmsi.values());
|
||||
}
|
||||
|
||||
function updateAisSummary() {
|
||||
const plan = currentAisChannelPlan();
|
||||
if (aisChannelSummaryEl) {
|
||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||
}
|
||||
|
||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||
if (aisVesselCountEl) {
|
||||
const count = vessels.length;
|
||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
if (aisLatestSeenEl) {
|
||||
const latest = aisMessageHistory[0];
|
||||
if (!latest) {
|
||||
aisLatestSeenEl.textContent = "No traffic yet";
|
||||
} else {
|
||||
const channel = aisChannelInfo(latest.channel);
|
||||
aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderAisRow(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ais-message";
|
||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
const name = aisDisplayName(msg);
|
||||
const nameHtml = aisDisplayNameHtml(msg);
|
||||
const channel = aisChannelInfo(msg.channel);
|
||||
const motion = aisMotionText(msg);
|
||||
const route = aisRouteText(msg);
|
||||
const distance = aisDistanceText(msg);
|
||||
const pos = msg.lat != null && msg.lon != null
|
||||
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
|
||||
: "";
|
||||
row.dataset.filterText = [
|
||||
name,
|
||||
msg.mmsi,
|
||||
msg.channel,
|
||||
channel.label,
|
||||
msg.vessel_name,
|
||||
msg.callsign,
|
||||
msg.destination,
|
||||
aisTypeLabel(msg.message_type),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
row.innerHTML =
|
||||
`<div class="ais-row-head">` +
|
||||
`<span class="ais-time">${ts}</span>` +
|
||||
`<span class="ais-call">${nameHtml}</span>` +
|
||||
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
|
||||
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||
`</div>` +
|
||||
`<div class="ais-row-meta">` +
|
||||
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
|
||||
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") +
|
||||
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="ais-row-detail">` +
|
||||
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
||||
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
|
||||
(pos ? `<span>${pos}</span>` : "") +
|
||||
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||
`</div>`;
|
||||
applyAisFilterToRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function applyAisFilterToRow(row) {
|
||||
if (!aisFilterText) {
|
||||
row.style.display = "";
|
||||
return;
|
||||
}
|
||||
const message = row.dataset.filterText || "";
|
||||
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||
}
|
||||
|
||||
function applyAisFilterToAll() {
|
||||
if (!aisMessagesEl) return;
|
||||
const rows = aisMessagesEl.querySelectorAll(".ais-message");
|
||||
rows.forEach((row) => applyAisFilterToRow(row));
|
||||
}
|
||||
|
||||
function updateAisBar() {
|
||||
if (!aisBarOverlay) return;
|
||||
updateAisSummary();
|
||||
|
||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||
if (!isAis || messages.length === 0) {
|
||||
aisBarOverlay.style.display = "none";
|
||||
aisBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
||||
for (const msg of messages) {
|
||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||
const pin = msg.lat != null && msg.lon != null
|
||||
? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>`
|
||||
: "";
|
||||
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
||||
const channel = aisChannelInfo(msg.channel);
|
||||
const distance = aisDistanceText(msg);
|
||||
const details = [
|
||||
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
||||
escapeMapHtml(channel.label),
|
||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
||||
distance ? escapeMapHtml(distance) : null,
|
||||
escapeMapHtml(aisAgeText(msg._tsMs)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
html += `<div class="aprs-bar-frame">` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
aisBarOverlay.innerHTML = html;
|
||||
aisBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateAisBar = updateAisBar;
|
||||
window.clearAisBar = function() {
|
||||
window.resetAisHistoryView();
|
||||
};
|
||||
|
||||
window.resetAisHistoryView = function() {
|
||||
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||
aisMessageHistory = [];
|
||||
updateAisBar();
|
||||
renderAisHistory();
|
||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
||||
};
|
||||
|
||||
function renderAisHistory() {
|
||||
pruneAisMessageHistory();
|
||||
if (!aisMessagesEl) {
|
||||
updateAisSummary();
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < aisMessageHistory.length; i += 1) {
|
||||
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
|
||||
}
|
||||
aisMessagesEl.replaceChildren(fragment);
|
||||
updateAisSummary();
|
||||
}
|
||||
|
||||
function addAisMessage(msg) {
|
||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
msg._tsMs = tsMs;
|
||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
aisMessageHistory.unshift(msg);
|
||||
pruneAisMessageHistory();
|
||||
scheduleAisBarUpdate();
|
||||
scheduleAisHistoryRender();
|
||||
|
||||
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
||||
window.aisMapAddVessel(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerAisMessage(msg) {
|
||||
return {
|
||||
rig_id: msg.rig_id || null,
|
||||
channel: msg.channel,
|
||||
message_type: msg.message_type,
|
||||
mmsi: msg.mmsi,
|
||||
lat: msg.lat,
|
||||
lon: msg.lon,
|
||||
sog_knots: msg.sog_knots,
|
||||
cog_deg: msg.cog_deg,
|
||||
heading_deg: msg.heading_deg,
|
||||
vessel_name: msg.vessel_name,
|
||||
callsign: msg.callsign,
|
||||
destination: msg.destination,
|
||||
ts_ms: msg.ts_ms,
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerAisBatch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerAisMessage(msg);
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
|
||||
window.aisMapAddVessel(next);
|
||||
}
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
aisMessageHistory = normalized.concat(aisMessageHistory);
|
||||
pruneAisMessageHistory();
|
||||
scheduleAisBarUpdate();
|
||||
scheduleAisHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreAisHistory = function(messages) {
|
||||
window.onServerAisBatch(messages);
|
||||
};
|
||||
|
||||
window.pruneAisHistoryView = function() {
|
||||
pruneAisMessageHistory();
|
||||
updateAisBar();
|
||||
renderAisHistory();
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_ais_decode");
|
||||
window.resetAisHistoryView();
|
||||
} catch (e) {
|
||||
console.error("AIS history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (aisFilterInput) {
|
||||
aisFilterInput.addEventListener("input", () => {
|
||||
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
||||
renderAisHistory();
|
||||
});
|
||||
}
|
||||
|
||||
window.onServerAis = function(msg) {
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(normalizeServerAisMessage(msg));
|
||||
};
|
||||
|
||||
updateAisSummary();
|
||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
|
||||
@@ -0,0 +1,498 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- APRS Decoder Plugin (server-side decode) ---
|
||||
const aprsStatus = document.getElementById("aprs-status");
|
||||
const aprsPacketsEl = document.getElementById("aprs-packets");
|
||||
const aprsFilterInput = document.getElementById("aprs-filter");
|
||||
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
||||
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
||||
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
||||
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
|
||||
const aprsTotalCountEl = document.getElementById("aprs-total-count");
|
||||
const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
||||
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
||||
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
let aprsFilterText = "";
|
||||
let aprsPacketHistory = [];
|
||||
let aprsBarDismissedAtMs = 0;
|
||||
let aprsOnlyPos = false;
|
||||
let aprsHideCrc = false;
|
||||
let aprsCollapseDup = false;
|
||||
let aprsTypeFilter = "all";
|
||||
|
||||
function currentAprsHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneAprsPacketHistory() {
|
||||
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
||||
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleAprsUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleAprsHistoryRender() {
|
||||
scheduleAprsUi("aprs-history", () => renderAprsHistory());
|
||||
}
|
||||
|
||||
function scheduleAprsBarUpdate() {
|
||||
scheduleAprsUi("aprs-bar", () => updateAprsBar());
|
||||
}
|
||||
|
||||
function renderAprsInfo(pkt) {
|
||||
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||
if (bytes && bytes.length > 0) {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i];
|
||||
if (b >= 0x20 && b <= 0x7e) {
|
||||
const ch = String.fromCharCode(b);
|
||||
if (ch === "<") out += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
else out += ch;
|
||||
} else {
|
||||
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const str = pkt.info || "";
|
||||
let out = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code >= 0x20 && code <= 0x7e) {
|
||||
const ch = str[i];
|
||||
if (ch === "<") out += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
else out += ch;
|
||||
} else {
|
||||
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function aprsPacketCategory(pkt) {
|
||||
const type = String(pkt.type || "").toLowerCase();
|
||||
const info = String(pkt.info || "").toLowerCase();
|
||||
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function aprsCategoryLabel(category) {
|
||||
switch (category) {
|
||||
case "position": return "Position";
|
||||
case "message": return "Message";
|
||||
case "weather": return "Weather";
|
||||
case "telemetry": return "Telemetry";
|
||||
default: return "Other";
|
||||
}
|
||||
}
|
||||
|
||||
function aprsAgeText(tsMs) {
|
||||
if (!Number.isFinite(tsMs)) return "just now";
|
||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||
const seconds = Math.round(deltaMs / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function aprsDistanceText(pkt) {
|
||||
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
}
|
||||
|
||||
function aprsPacketSignature(pkt) {
|
||||
return [
|
||||
pkt.srcCall || "",
|
||||
pkt.destCall || "",
|
||||
pkt.path || "",
|
||||
pkt.info || "",
|
||||
pkt.type || "",
|
||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function aprsHexBytes(bytes) {
|
||||
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||
}
|
||||
|
||||
function aprsFilterMatch(pkt) {
|
||||
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||
if (aprsHideCrc && !pkt.crcOk) return false;
|
||||
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
||||
if (!aprsFilterText) return true;
|
||||
const haystack = [
|
||||
pkt.srcCall,
|
||||
pkt.destCall,
|
||||
pkt.path,
|
||||
pkt.info,
|
||||
pkt.type,
|
||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||
aprsPacketCategory(pkt),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
return haystack.includes(aprsFilterText);
|
||||
}
|
||||
|
||||
function aprsVisiblePackets() {
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||
return packets.filter(aprsFilterMatch);
|
||||
}
|
||||
|
||||
function collapseAprsDuplicates(packets) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pkt of packets) {
|
||||
const key = aprsPacketSignature(pkt);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(pkt);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function updateAprsSummary() {
|
||||
const visible = aprsVisiblePackets();
|
||||
if (aprsTotalCountEl) {
|
||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||
}
|
||||
if (aprsVisibleCountEl) {
|
||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (aprsLatestSeenEl) {
|
||||
const latest = aprsPacketHistory[0];
|
||||
if (!latest) {
|
||||
aprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateAprsChipState() {
|
||||
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
|
||||
});
|
||||
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
|
||||
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||
}
|
||||
|
||||
function renderAprsRow(pkt, isFresh) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "aprs-packet";
|
||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||
if (isFresh) row.classList.add("aprs-packet-new");
|
||||
|
||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const age = aprsAgeText(pkt._tsMs);
|
||||
const category = aprsPacketCategory(pkt);
|
||||
const categoryLabel = aprsCategoryLabel(category);
|
||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||
let symbolHtml = "";
|
||||
if (pkt.symbolTable && pkt.symbolCode) {
|
||||
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||
const col = code % 16;
|
||||
const row2 = Math.floor(code / 16);
|
||||
const bgX = -(col * 24);
|
||||
const bgY = -(row2 * 24);
|
||||
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||
}
|
||||
const posLink = pkt.lat != null && pkt.lon != null
|
||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
||||
: "";
|
||||
const distance = aprsDistanceText(pkt);
|
||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||
|
||||
row.innerHTML =
|
||||
`<div class="aprs-row-head">` +
|
||||
`<span class="aprs-time">${ts}</span>` +
|
||||
symbolHtml +
|
||||
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
||||
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
||||
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
||||
pathBadge +
|
||||
crcBadge +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-meta">` +
|
||||
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
||||
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
||||
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-detail">` +
|
||||
`<span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
||||
(posLink ? `<span>${posLink}</span>` : "") +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-actions">` +
|
||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||
`</div>` +
|
||||
`<details class="aprs-details">` +
|
||||
`<summary>Details</summary>` +
|
||||
`<div class="aprs-details-grid">` +
|
||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
||||
`</div>` +
|
||||
`</details>`;
|
||||
|
||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||
el.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
const raw = String(el.dataset.aprsMap || "");
|
||||
const [lat, lon] = raw.split(",").map(Number);
|
||||
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
window.navigateToAprsMap(lat, lon);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
showHint("Coordinates copied", 1200);
|
||||
}
|
||||
} catch (_e) {
|
||||
showHint("Copy failed", 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderAprsHistory() {
|
||||
pruneAprsPacketHistory();
|
||||
if (!aprsPacketsEl) {
|
||||
updateAprsSummary();
|
||||
updateAprsChipState();
|
||||
return;
|
||||
}
|
||||
const visible = aprsVisiblePackets();
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
fragment.appendChild(renderAprsRow(visible[i], i === 0));
|
||||
}
|
||||
aprsPacketsEl.replaceChildren(fragment);
|
||||
updateAprsSummary();
|
||||
updateAprsChipState();
|
||||
}
|
||||
|
||||
function updateAprsBar() {
|
||||
if (!aprsBarOverlay) return;
|
||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
|
||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||
aprsBarOverlay.style.display = "none";
|
||||
aprsBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>';
|
||||
for (const pkt of frames) {
|
||||
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
||||
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
|
||||
const dest = escapeMapHtml(pkt.destCall || "");
|
||||
const info = escapeMapHtml(pkt.info || "");
|
||||
const pin = pkt.lat != null && pkt.lon != null
|
||||
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
|
||||
: "";
|
||||
html += `<div class="aprs-bar-frame">` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
aprsBarOverlay.innerHTML = html;
|
||||
aprsBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateAprsBar = updateAprsBar;
|
||||
window.clearAprsBar = function() {
|
||||
window.resetAprsHistoryView();
|
||||
};
|
||||
window.closeAprsBar = function() {
|
||||
aprsBarDismissedAtMs = Date.now();
|
||||
if (aprsBarOverlay) {
|
||||
aprsBarOverlay.style.display = "none";
|
||||
aprsBarOverlay.innerHTML = "";
|
||||
}
|
||||
};
|
||||
|
||||
window.resetAprsHistoryView = function() {
|
||||
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
||||
aprsPacketHistory = [];
|
||||
updateAprsBar();
|
||||
renderAprsHistory();
|
||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
|
||||
};
|
||||
|
||||
window.pruneAprsHistoryView = function() {
|
||||
pruneAprsPacketHistory();
|
||||
updateAprsBar();
|
||||
renderAprsHistory();
|
||||
};
|
||||
|
||||
function addAprsPacket(pkt) {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
|
||||
aprsPacketHistory.unshift(pkt);
|
||||
pruneAprsPacketHistory();
|
||||
|
||||
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
||||
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
|
||||
}
|
||||
|
||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||
|
||||
scheduleAprsHistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerAprsPacket(pkt) {
|
||||
return {
|
||||
rig_id: pkt.rig_id || null,
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
srcCall: pkt.src_call,
|
||||
destCall: pkt.dest_call,
|
||||
path: pkt.path,
|
||||
info: pkt.info,
|
||||
info_bytes: pkt.info_bytes,
|
||||
type: pkt.packet_type,
|
||||
crcOk: pkt.crc_ok,
|
||||
ts_ms: pkt.ts_ms,
|
||||
lat: pkt.lat,
|
||||
lon: pkt.lon,
|
||||
symbolTable: pkt.symbol_table,
|
||||
symbolCode: pkt.symbol_code,
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerAprsBatch = function(packets) {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
aprsStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
let hasCrcOk = false;
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerAprsPacket(pkt);
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
|
||||
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
|
||||
}
|
||||
if (next.crcOk) hasCrcOk = true;
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
aprsPacketHistory = normalized.concat(aprsPacketHistory);
|
||||
pruneAprsPacketHistory();
|
||||
if (hasCrcOk) scheduleAprsBarUpdate();
|
||||
scheduleAprsHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreAprsHistory = function(packets) {
|
||||
window.onServerAprsBatch(packets);
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_aprs_decode");
|
||||
window.resetAprsHistoryView();
|
||||
} catch (e) {
|
||||
console.error("APRS history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (aprsOnlyPosBtn) {
|
||||
aprsOnlyPosBtn.addEventListener("click", () => {
|
||||
aprsOnlyPos = !aprsOnlyPos;
|
||||
renderAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (aprsHideCrcBtn) {
|
||||
aprsHideCrcBtn.addEventListener("click", () => {
|
||||
aprsHideCrc = !aprsHideCrc;
|
||||
renderAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (aprsCollapseDupBtn) {
|
||||
aprsCollapseDupBtn.addEventListener("click", () => {
|
||||
aprsCollapseDup = !aprsCollapseDup;
|
||||
renderAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||
const btn = document.getElementById(`aprs-type-${type}`);
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
aprsTypeFilter = type;
|
||||
renderAprsHistory();
|
||||
});
|
||||
});
|
||||
|
||||
if (aprsFilterInput) {
|
||||
aprsFilterInput.addEventListener("input", () => {
|
||||
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
|
||||
renderAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Server-side APRS decode handler ---
|
||||
window.onServerAprs = function(pkt) {
|
||||
aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||
};
|
||||
|
||||
renderAprsHistory();
|
||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function bgdSupportedIds() {
|
||||
return (window.decoderRegistry || [])
|
||||
.filter(function (d) { return d.background_decode; })
|
||||
.map(function (d) { return d.id; });
|
||||
}
|
||||
|
||||
let backgroundDecodeRole = null;
|
||||
let currentRigId = null;
|
||||
let currentConfig = null;
|
||||
let bookmarkList = [];
|
||||
let statusInterval = null;
|
||||
let bgdDirty = false;
|
||||
|
||||
function initBackgroundDecode(rigId, role) {
|
||||
backgroundDecodeRole = role;
|
||||
currentRigId = rigId || null;
|
||||
if (currentRigId) loadBackgroundDecode();
|
||||
startStatusPolling();
|
||||
}
|
||||
|
||||
function setBackgroundDecodeRig(rigId) {
|
||||
const nextRigId = rigId || null;
|
||||
if (nextRigId === currentRigId) return;
|
||||
currentRigId = nextRigId;
|
||||
if (!currentRigId) return;
|
||||
loadBackgroundDecode();
|
||||
}
|
||||
|
||||
function apiGetConfig(rigId) {
|
||||
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiPutConfig(rigId, config) {
|
||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiResetConfig(rigId) {
|
||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||
method: "DELETE",
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiGetStatus(rigId) {
|
||||
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiGetBookmarks() {
|
||||
return fetch("/bookmarks").then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function loadBackgroundDecode() {
|
||||
const rigId = currentRigId;
|
||||
if (!rigId) return;
|
||||
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
|
||||
.then(function ([config, bookmarks]) {
|
||||
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
|
||||
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
||||
renderBackgroundDecode();
|
||||
clearBgdDirty();
|
||||
pollBackgroundDecodeStatus();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("background decode load failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
function supportedBookmarks() {
|
||||
return bookmarkList.filter(function (bookmark) {
|
||||
return bookmarkDecoderKinds(bookmark).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function bookmarkDecoderKinds(bookmark) {
|
||||
var ids = bgdSupportedIds();
|
||||
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
|
||||
var explicit = decoders
|
||||
.map(function (item) { return String(item || "").trim().toLowerCase(); })
|
||||
.filter(function (item, index, arr) {
|
||||
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
||||
});
|
||||
if (explicit.length > 0) return explicit;
|
||||
// Fall back: infer from mode via mode-bound entries in the registry.
|
||||
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
|
||||
return (window.decoderRegistry || [])
|
||||
.filter(function (d) {
|
||||
return d.activation === "mode_bound" && d.background_decode
|
||||
&& d.active_modes.indexOf(mode) >= 0;
|
||||
})
|
||||
.map(function (d) { return d.id; });
|
||||
}
|
||||
|
||||
function renderBackgroundDecode() {
|
||||
if (!currentConfig) {
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
setCheckbox("background-decode-enabled", !!currentConfig.enabled);
|
||||
renderBookmarkChecklist();
|
||||
|
||||
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
|
||||
const panel = document.getElementById("background-decode-panel");
|
||||
if (panel) {
|
||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
|
||||
el.disabled = !isControl;
|
||||
});
|
||||
}
|
||||
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||
}
|
||||
|
||||
function renderBookmarkChecklist(filterText) {
|
||||
const container = document.getElementById("bgd-bookmark-checklist");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
const selectedIds = new Set(
|
||||
currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
|
||||
);
|
||||
const all = supportedBookmarks();
|
||||
const filter = (filterText || "").trim().toLowerCase();
|
||||
|
||||
const filtered = filter
|
||||
? all.filter(function (bm) {
|
||||
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
||||
return text.indexOf(filter) >= 0;
|
||||
})
|
||||
: all;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<div class="bgd-checklist-empty">' +
|
||||
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach(function (bookmark) {
|
||||
var row = document.createElement("label");
|
||||
row.className = "bgd-checklist-row";
|
||||
var decoders = bookmarkDecoderKinds(bookmark);
|
||||
var checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||
row.innerHTML =
|
||||
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
||||
row.querySelector("input").addEventListener("change", function (e) {
|
||||
onChecklistToggle(bookmark.id, e.target.checked);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function onChecklistToggle(bookmarkId, checked) {
|
||||
if (!currentConfig) {
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
|
||||
if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
|
||||
currentConfig.bookmark_ids.push(bookmarkId);
|
||||
} else if (!checked) {
|
||||
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (id) { return id !== bookmarkId; });
|
||||
}
|
||||
markBgdDirty();
|
||||
}
|
||||
|
||||
function saveBackgroundDecode() {
|
||||
const rigId = currentRigId;
|
||||
if (!rigId) return;
|
||||
const payload = {
|
||||
remote: rigId,
|
||||
enabled: !!document.getElementById("background-decode-enabled").checked,
|
||||
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
|
||||
};
|
||||
const btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.disabled = true;
|
||||
apiPutConfig(rigId, payload)
|
||||
.then(function (saved) {
|
||||
currentConfig = saved;
|
||||
renderBackgroundDecode();
|
||||
clearBgdDirty();
|
||||
pollBackgroundDecodeStatus();
|
||||
showToast("Background decode saved.");
|
||||
})
|
||||
.catch(function (err) {
|
||||
showToast("Save failed: " + err.message, true);
|
||||
})
|
||||
.finally(function () {
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetBackgroundDecode() {
|
||||
const rigId = currentRigId;
|
||||
if (!rigId) return;
|
||||
if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
|
||||
apiResetConfig(rigId)
|
||||
.then(function (saved) {
|
||||
currentConfig = saved;
|
||||
renderBackgroundDecode();
|
||||
clearBgdDirty();
|
||||
pollBackgroundDecodeStatus();
|
||||
showToast("Background decode reset.");
|
||||
})
|
||||
.catch(function (err) {
|
||||
showToast("Reset failed: " + err.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
if (statusInterval) clearInterval(statusInterval);
|
||||
statusInterval = setInterval(pollBackgroundDecodeStatus, 15000);
|
||||
}
|
||||
|
||||
function pollBackgroundDecodeStatus() {
|
||||
const rigId = currentRigId;
|
||||
if (!rigId) return;
|
||||
apiGetStatus(rigId)
|
||||
.then(renderStatus)
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
const card = document.getElementById("background-decode-status-card");
|
||||
if (!card) return;
|
||||
const entries = Array.isArray(status && status.entries) ? status.entries : [];
|
||||
if (!entries.length) {
|
||||
card.textContent = "No background decode bookmarks configured.";
|
||||
return;
|
||||
}
|
||||
const summary = [];
|
||||
if (status.active_rig) {
|
||||
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||
if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||
} else {
|
||||
summary.push("This rig is not currently selected for audio.");
|
||||
}
|
||||
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||
html += '<div class="bgd-status-list">';
|
||||
entries.forEach(function (entry) {
|
||||
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||
const parts = [];
|
||||
if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||
if (entry.mode) parts.push(entry.mode);
|
||||
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||
}
|
||||
html +=
|
||||
'<div class="bgd-status-row">' +
|
||||
'<div>' +
|
||||
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
|
||||
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
|
||||
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
|
||||
escHtml(prettyState(entry.state)) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += "</div>";
|
||||
card.innerHTML = html;
|
||||
}
|
||||
|
||||
function prettyState(state) {
|
||||
switch (state) {
|
||||
case "active": return "\u2713 Active";
|
||||
case "out_of_span": return "\u25B3 Out of span";
|
||||
case "waiting_for_spectrum": return "\u25B3 Waiting";
|
||||
case "waiting_for_user": return "\u25B3 No user";
|
||||
case "missing_bookmark": return "\u2717 Missing";
|
||||
case "no_supported_decoders": return "\u2717 Unsupported";
|
||||
case "disabled": return "\u25B3 Disabled";
|
||||
case "handled_by_scheduler": return "\u25B3 Scheduler";
|
||||
case "scheduler_has_control": return "\u25B3 Scheduler";
|
||||
case "handled_by_virtual_channel": return "\u25B3 VChan";
|
||||
default: return "\u25B3 Inactive";
|
||||
}
|
||||
}
|
||||
|
||||
function setCheckbox(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.checked = !!value;
|
||||
}
|
||||
|
||||
function formatFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||
return hz + " Hz";
|
||||
}
|
||||
|
||||
function escHtml(value) {
|
||||
return String(value == null ? "" : value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function markBgdDirty() {
|
||||
if (bgdDirty) return;
|
||||
bgdDirty = true;
|
||||
var btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.classList.add("sch-dirty");
|
||||
}
|
||||
|
||||
function clearBgdDirty() {
|
||||
bgdDirty = false;
|
||||
var btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.classList.remove("sch-dirty");
|
||||
}
|
||||
|
||||
function showToast(msg, isError) {
|
||||
const el = document.getElementById("background-decode-toast");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
|
||||
el.style.display = "block";
|
||||
setTimeout(function () {
|
||||
el.style.display = "none";
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function selectAllBookmarks() {
|
||||
if (!currentConfig) {
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
var ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||
currentConfig.bookmark_ids = ids;
|
||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||
markBgdDirty();
|
||||
}
|
||||
|
||||
function deselectAllBookmarks() {
|
||||
if (!currentConfig) {
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
currentConfig.bookmark_ids = [];
|
||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||
markBgdDirty();
|
||||
}
|
||||
|
||||
function wireBackgroundDecodeEvents() {
|
||||
const filterInput = document.getElementById("bgd-bookmark-filter");
|
||||
if (filterInput && !filterInput._wired) {
|
||||
filterInput._wired = true;
|
||||
filterInput.addEventListener("input", function () {
|
||||
renderBookmarkChecklist(filterInput.value);
|
||||
});
|
||||
}
|
||||
|
||||
const enabledCb = document.getElementById("background-decode-enabled");
|
||||
if (enabledCb && !enabledCb._wired) {
|
||||
enabledCb._wired = true;
|
||||
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
||||
}
|
||||
|
||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||
if (selectAllBtn && !selectAllBtn._wired) {
|
||||
selectAllBtn._wired = true;
|
||||
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
||||
}
|
||||
|
||||
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
|
||||
if (deselectAllBtn && !deselectAllBtn._wired) {
|
||||
deselectAllBtn._wired = true;
|
||||
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||
if (saveBtn && !saveBtn._wired) {
|
||||
saveBtn._wired = true;
|
||||
saveBtn.addEventListener("click", saveBackgroundDecode);
|
||||
}
|
||||
|
||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||
if (resetBtn && !resetBtn._wired) {
|
||||
resetBtn._wired = true;
|
||||
resetBtn.addEventListener("click", resetBackgroundDecode);
|
||||
}
|
||||
}
|
||||
|
||||
window.initBackgroundDecode = initBackgroundDecode;
|
||||
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
||||
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
||||
})();
|
||||
@@ -0,0 +1,792 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- Bookmarks Tab ---
|
||||
|
||||
/** Current bookmark scope: "general" or a rig remote name. */
|
||||
let bmScope = "general";
|
||||
|
||||
/** Build the ?scope= query string for a given or current bookmark scope. */
|
||||
function bmScopeParam(prefix, scope) {
|
||||
const sep = prefix ? "&" : "?";
|
||||
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
||||
}
|
||||
|
||||
var bmList = [];
|
||||
var bmRevision = 0;
|
||||
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
|
||||
var bmOverlayList = [];
|
||||
var bmOverlayRevision = 0;
|
||||
let bmFilteredList = [];
|
||||
let bmEditId = null;
|
||||
let bmEditScope = null;
|
||||
let bmCurrentPage = 1;
|
||||
const BM_PAGE_SIZE = 25;
|
||||
const bmSelected = new Set();
|
||||
|
||||
function bmFmtFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||
return hz + "\u202fHz";
|
||||
}
|
||||
|
||||
function bmEsc(str) {
|
||||
const d = document.createElement("div");
|
||||
d.appendChild(document.createTextNode(String(str)));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function bmCanControl() {
|
||||
return (
|
||||
(typeof authEnabled !== "undefined" && !authEnabled) ||
|
||||
(typeof authRole !== "undefined" && authRole === "control")
|
||||
);
|
||||
}
|
||||
|
||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||
function bmSyncAccess() {
|
||||
const canCtrl = bmCanControl();
|
||||
const addBtn = document.getElementById("bm-add-btn");
|
||||
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
||||
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||
}
|
||||
|
||||
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
||||
function bmListScope() {
|
||||
const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null;
|
||||
return rig || "general";
|
||||
}
|
||||
|
||||
async function bmFetchOverlay() {
|
||||
const overlayScope = bmListScope();
|
||||
try {
|
||||
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
bmOverlayList = await resp.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch overlay bookmarks:", e);
|
||||
bmOverlayList = [];
|
||||
}
|
||||
bmOverlayRevision++;
|
||||
if (typeof window.syncBookmarkMapLocators === "function") {
|
||||
window.syncBookmarkMapLocators(bmOverlayList);
|
||||
}
|
||||
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
|
||||
}
|
||||
|
||||
async function bmFetch(categoryFilter) {
|
||||
let url = "/bookmarks";
|
||||
let hasQuery = false;
|
||||
if (categoryFilter && categoryFilter !== "") {
|
||||
url += "?category=" + encodeURIComponent(categoryFilter);
|
||||
hasQuery = true;
|
||||
}
|
||||
url += bmScopeParam(hasQuery);
|
||||
const overlayPromise = bmFetchOverlay();
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
bmList = await resp.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch bookmarks:", e);
|
||||
bmList = [];
|
||||
}
|
||||
bmRevision++;
|
||||
bmSelected.clear();
|
||||
bmUpdateSelectionUi();
|
||||
bmSyncAccess();
|
||||
bmApplyFilters();
|
||||
bmRefreshCategoryFilter(categoryFilter);
|
||||
await overlayPromise;
|
||||
}
|
||||
|
||||
function bmApplyFilters() {
|
||||
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
|
||||
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
|
||||
let filtered = modeFilter
|
||||
? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter)
|
||||
: bmList;
|
||||
filtered = text
|
||||
? filtered.filter((bm) =>
|
||||
(bm.name || "").toLowerCase().includes(text) ||
|
||||
(bm.locator || "").toLowerCase().includes(text) ||
|
||||
(bm.category || "").toLowerCase().includes(text) ||
|
||||
(bm.comment || "").toLowerCase().includes(text)
|
||||
)
|
||||
: filtered;
|
||||
bmFilteredList = filtered;
|
||||
bmCurrentPage = 1;
|
||||
bmRender(filtered);
|
||||
}
|
||||
|
||||
async function bmRefreshCategoryFilter(keepValue) {
|
||||
const sel = document.getElementById("bm-category-filter");
|
||||
const modeSel = document.getElementById("bm-mode-filter");
|
||||
if (!sel && !modeSel) return;
|
||||
try {
|
||||
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
||||
if (!resp.ok) return;
|
||||
const all = await resp.json();
|
||||
if (sel) {
|
||||
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
cats.forEach((cat) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = cat;
|
||||
opt.textContent = cat;
|
||||
sel.add(opt);
|
||||
});
|
||||
if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
|
||||
}
|
||||
if (modeSel) {
|
||||
const keepMode = modeSel.value;
|
||||
const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
||||
while (modeSel.options.length > 1) modeSel.remove(1);
|
||||
modes.forEach((mode) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = mode;
|
||||
opt.textContent = mode;
|
||||
modeSel.add(opt);
|
||||
});
|
||||
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function bmRender(list) {
|
||||
const tbody = document.getElementById("bm-tbody");
|
||||
const emptyEl = document.getElementById("bm-empty");
|
||||
const paginatorEl = document.getElementById("bm-paginator");
|
||||
const pageSummaryEl = document.getElementById("bm-page-summary");
|
||||
const pageIndicatorEl = document.getElementById("bm-page-indicator");
|
||||
const prevBtn = document.getElementById("bm-page-prev");
|
||||
const nextBtn = document.getElementById("bm-page-next");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (list.length === 0) {
|
||||
if (emptyEl) emptyEl.style.display = "";
|
||||
if (paginatorEl) paginatorEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (emptyEl) emptyEl.style.display = "none";
|
||||
|
||||
const canControl = bmCanControl();
|
||||
const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
|
||||
const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
|
||||
bmCurrentPage = page;
|
||||
const startIndex = (page - 1) * BM_PAGE_SIZE;
|
||||
const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
|
||||
const pageItems = list.slice(startIndex, endIndex);
|
||||
|
||||
const showScope = bmScope !== "general";
|
||||
pageItems.forEach((bm) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.bmId = bm.id;
|
||||
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
|
||||
const locatorCell = bm.locator || "--";
|
||||
const catCell = bm.category || "Uncategorised";
|
||||
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
||||
const commentCell = bm.comment || "";
|
||||
const checked = bmSelected.has(bm.id) ? " checked" : "";
|
||||
const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
|
||||
tr.innerHTML =
|
||||
`<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td>` +
|
||||
`<td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td>` +
|
||||
`<td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
|
||||
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
|
||||
`<td class="bm-col-bw">${bwCell}</td>` +
|
||||
`<td class="bm-col-loc">${bmEsc(locatorCell)}</td>` +
|
||||
`<td class="bm-col-cat">${bmEsc(catCell)}</td>` +
|
||||
`<td class="bm-col-dec">${bmEsc(decoderCell)}</td>` +
|
||||
`<td class="bm-col-cmt">${bmEsc(commentCell)}</td>` +
|
||||
`<td class="bm-col-act">` +
|
||||
`<button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` +
|
||||
(canControl
|
||||
? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button>` +
|
||||
`<button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>`
|
||||
: "") +
|
||||
`</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
bmSyncSelectAllCheckbox();
|
||||
|
||||
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
|
||||
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
|
||||
if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
|
||||
if (prevBtn) prevBtn.disabled = page <= 1;
|
||||
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
||||
}
|
||||
|
||||
function bmChangePage(delta) {
|
||||
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
||||
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
||||
if (nextPage === bmCurrentPage) return;
|
||||
bmCurrentPage = nextPage;
|
||||
bmRender(bmFilteredList);
|
||||
}
|
||||
|
||||
// Read decoder checkboxes and return an array of selected decoder names.
|
||||
function bmReadDecoders() {
|
||||
return (window.decoderRegistry || [])
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked)
|
||||
.map(d => d.id);
|
||||
}
|
||||
|
||||
// Set decoder checkboxes to match the given array.
|
||||
function bmWriteDecoders(decoders) {
|
||||
const set = new Set(decoders || []);
|
||||
(window.decoderRegistry || [])
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.forEach(d => {
|
||||
const el = document.getElementById("bm-dec-" + d.id);
|
||||
if (el) el.checked = set.has(d.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Build decoder checkboxes dynamically from the registry.
|
||||
function bmBuildDecoderCheckboxes() {
|
||||
const container = document.getElementById("bm-decoder-checkboxes");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
(window.decoderRegistry || [])
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.forEach(d => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "bm-decoder-check";
|
||||
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
||||
container.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
function bmOpenForm(bm) {
|
||||
const wrap = document.getElementById("bm-form-wrap");
|
||||
if (!wrap) return;
|
||||
bmEditId = bm ? bm.id : null;
|
||||
bmEditScope = bm ? (bm.scope || bmScope) : null;
|
||||
|
||||
// Rebuild decoder checkboxes from registry (handles race where registry
|
||||
// loaded after initial build).
|
||||
bmBuildDecoderCheckboxes();
|
||||
|
||||
document.getElementById("bm-id").value = bm ? bm.id : "";
|
||||
document.getElementById("bm-name").value = bm ? bm.name : "";
|
||||
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
|
||||
document.getElementById("bm-mode").value = bm ? bm.mode : "";
|
||||
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
|
||||
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
|
||||
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
|
||||
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
|
||||
bmWriteDecoders(bm ? bm.decoders : []);
|
||||
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||
|
||||
wrap.style.display = "flex";
|
||||
document.getElementById("bm-name").focus();
|
||||
}
|
||||
|
||||
function bmCloseForm() {
|
||||
const wrap = document.getElementById("bm-form-wrap");
|
||||
if (wrap) wrap.style.display = "none";
|
||||
bmEditId = null;
|
||||
}
|
||||
|
||||
function bmPrefillFromStatus() {
|
||||
// Use globals maintained by app.js (updated by SSE stream)
|
||||
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
|
||||
document.getElementById("bm-freq").value = Math.round(lastFreqHz);
|
||||
}
|
||||
if (typeof lastModeName === "string" && lastModeName) {
|
||||
document.getElementById("bm-mode").value = lastModeName;
|
||||
}
|
||||
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
|
||||
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
|
||||
}
|
||||
// Prefill decoder checkboxes from current toggle button state.
|
||||
const activeDecoders = (window.decoderRegistry || [])
|
||||
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
||||
.filter(d => {
|
||||
const btn = document.getElementById(d.id + "-decode-toggle-btn");
|
||||
return btn && btn.dataset.enabled === "true";
|
||||
})
|
||||
.map(d => d.id);
|
||||
bmWriteDecoders(activeDecoders);
|
||||
}
|
||||
|
||||
async function bmSave(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById("bm-id").value;
|
||||
const name = document.getElementById("bm-name").value.trim();
|
||||
const freqStr = document.getElementById("bm-freq").value;
|
||||
const freq_hz = parseInt(freqStr, 10);
|
||||
const mode = document.getElementById("bm-mode").value.trim();
|
||||
const bwStr = document.getElementById("bm-bw").value;
|
||||
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
||||
const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
|
||||
const category = document.getElementById("bm-category-input").value.trim();
|
||||
const comment = document.getElementById("bm-comment").value.trim();
|
||||
const decoders = bmReadDecoders();
|
||||
|
||||
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||
alert("Name, Frequency, and Mode are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
name,
|
||||
freq_hz,
|
||||
mode,
|
||||
bandwidth_hz,
|
||||
locator: locator || null,
|
||||
category,
|
||||
comment,
|
||||
decoders,
|
||||
};
|
||||
|
||||
try {
|
||||
let resp;
|
||||
if (id) {
|
||||
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
resp = await fetch("/bookmarks" + bmScopeParam(false), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
if (resp.status === 409) {
|
||||
throw new Error("A bookmark for that frequency already exists.");
|
||||
}
|
||||
throw new Error(text || "HTTP " + resp.status);
|
||||
}
|
||||
bmCloseForm();
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to save bookmark:", err);
|
||||
alert("Failed to save bookmark: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function bmDelete(id) {
|
||||
if (!confirm("Delete this bookmark?")) return;
|
||||
const bm = bmList.find((b) => b.id === id);
|
||||
const scope = bm ? bm.scope : undefined;
|
||||
try {
|
||||
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete bookmark:", err);
|
||||
alert("Failed to delete bookmark: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function bmApply(bm) {
|
||||
try {
|
||||
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
||||
if (typeof modeEl !== "undefined" && modeEl) {
|
||||
modeEl.value = String(bm.mode || "").toUpperCase();
|
||||
}
|
||||
if (bm.bandwidth_hz) {
|
||||
if (typeof currentBandwidthHz !== "undefined") {
|
||||
currentBandwidthHz = bm.bandwidth_hz;
|
||||
}
|
||||
window.currentBandwidthHz = bm.bandwidth_hz;
|
||||
if (typeof syncBandwidthInput === "function") {
|
||||
syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
}
|
||||
if (typeof applyLocalTunedFrequency === "function") {
|
||||
// Set optimistic guard before applying so SSE cannot snap back.
|
||||
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||
++_freqOptimisticSeq;
|
||||
_freqOptimisticHz = bm.freq_hz;
|
||||
}
|
||||
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||
applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
}
|
||||
if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) {
|
||||
scheduleSpectrumDraw();
|
||||
}
|
||||
|
||||
// Take scheduler control up front, then apply mode before bandwidth so a
|
||||
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
||||
const tunePromise = (async () => {
|
||||
if (typeof vchanTakeSchedulerControl === "function") {
|
||||
await vchanTakeSchedulerControl();
|
||||
}
|
||||
|
||||
const onVirtual = typeof vchanInterceptMode === "function"
|
||||
&& await vchanInterceptMode(bm.mode);
|
||||
if (!onVirtual) {
|
||||
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
}
|
||||
|
||||
if (bm.bandwidth_hz) {
|
||||
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
|
||||
&& await vchanInterceptBandwidth(bm.bandwidth_hz);
|
||||
if (!bwHandledByVchan) {
|
||||
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
|
||||
}
|
||||
}
|
||||
|
||||
// setRigFrequency is wrapped by vchan.js to redirect to the channel API
|
||||
// when on a virtual channel, so this call works correctly in both cases.
|
||||
// It also does its own optimistic update (applyLocalTunedFrequency) but
|
||||
// that's a no-op since we already set the same value above.
|
||||
if (typeof setRigFrequency === "function") {
|
||||
await setRigFrequency(bm.freq_hz);
|
||||
} else {
|
||||
await postPath("/set_freq?hz=" + bm.freq_hz);
|
||||
}
|
||||
})();
|
||||
// Decoder toggles — fire-and-forget.
|
||||
// - Decoders incompatible with the new mode are always turned off
|
||||
// (even when the bookmark has no explicit decoder selection).
|
||||
// - For compatible decoders, if the bookmark specifies a set, the
|
||||
// toggles are driven to match that set; otherwise they're left
|
||||
// alone.
|
||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||
const modeUp = (bm.mode || "").toUpperCase();
|
||||
const allToggleDecoders = (window.decoderRegistry || []).filter(d =>
|
||||
d.activation === "toggle"
|
||||
);
|
||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||
let statusUrl = "/status";
|
||||
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
|
||||
}
|
||||
const statusResp = await fetch(statusUrl);
|
||||
if (!statusResp.ok) return;
|
||||
const st = await statusResp.json();
|
||||
const toggles = [];
|
||||
for (const d of allToggleDecoders) {
|
||||
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||
const currentlyOn = !!st[statusKey];
|
||||
const compatible = Array.isArray(d.active_modes)
|
||||
&& d.active_modes.includes(modeUp);
|
||||
let wanted;
|
||||
if (!compatible) {
|
||||
// Always disable decoders that don't apply to the new mode.
|
||||
wanted = false;
|
||||
} else if (hasDecoders) {
|
||||
wanted = bm.decoders.includes(d.id);
|
||||
} else {
|
||||
// Mode-compatible and no bookmark selection: leave as-is.
|
||||
wanted = currentlyOn;
|
||||
}
|
||||
if (wanted !== currentlyOn) {
|
||||
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
}
|
||||
}
|
||||
if (toggles.length) await Promise.all(toggles);
|
||||
})() : Promise.resolve();
|
||||
// Don't await — let the network calls settle in the background.
|
||||
// Errors are logged but don't block the UI.
|
||||
Promise.all([tunePromise, decoderPromise]).catch(
|
||||
(err) => console.error("Bookmark apply background error:", err)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to apply bookmark:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function bmUpdateSelectionUi() {
|
||||
const count = bmSelected.size;
|
||||
const canCtrl = bmCanControl();
|
||||
const visible = count > 0 && canCtrl;
|
||||
const btn = document.getElementById("bm-del-selected-btn");
|
||||
const countEl = document.getElementById("bm-del-selected-count");
|
||||
if (btn) btn.style.display = visible ? "" : "none";
|
||||
if (countEl) countEl.textContent = count;
|
||||
const moveWrap = document.getElementById("bm-move-selected-wrap");
|
||||
const moveCountEl = document.getElementById("bm-move-selected-count");
|
||||
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
||||
if (moveCountEl) moveCountEl.textContent = count;
|
||||
if (visible) bmPopulateMoveTarget();
|
||||
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||
if (selectAllBtn && bmCanControl()) {
|
||||
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||
}
|
||||
}
|
||||
|
||||
/** Populate the move-target dropdown with all scopes except the current one. */
|
||||
function bmPopulateMoveTarget() {
|
||||
const sel = document.getElementById("bm-move-target");
|
||||
if (!sel) return;
|
||||
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
||||
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = "";
|
||||
if (bmScope !== "general") {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "general";
|
||||
opt.textContent = "General";
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
rigIds.forEach((id) => {
|
||||
if (id === bmScope) return;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = displayNames[id] || id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
|
||||
sel.value = prev;
|
||||
}
|
||||
}
|
||||
|
||||
async function bmMoveSelected() {
|
||||
const ids = Array.from(bmSelected);
|
||||
if (ids.length === 0) return;
|
||||
const target = document.getElementById("bm-move-target")?.value;
|
||||
if (!target) return;
|
||||
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
|
||||
try {
|
||||
// Group selected IDs by their owning scope (skip if already in target).
|
||||
const byScope = {};
|
||||
for (const id of ids) {
|
||||
const bm = bmList.find((b) => b.id === id);
|
||||
const scope = bm?.scope || bmScope;
|
||||
if (scope === target) continue;
|
||||
(byScope[scope] ||= []).push(id);
|
||||
}
|
||||
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
|
||||
fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: scopeIds, to: target }),
|
||||
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
||||
));
|
||||
bmSelected.clear();
|
||||
bmUpdateSelectionUi();
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to move bookmarks:", err);
|
||||
alert("Failed to move bookmarks: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function bmSyncSelectAllCheckbox() {
|
||||
const selectAll = document.getElementById("bm-select-all");
|
||||
if (!selectAll) return;
|
||||
const checkboxes = document.querySelectorAll(".bm-row-sel");
|
||||
if (checkboxes.length === 0) {
|
||||
selectAll.checked = false;
|
||||
selectAll.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
|
||||
selectAll.checked = checkedCount === checkboxes.length;
|
||||
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
|
||||
}
|
||||
|
||||
async function bmDeleteSelected() {
|
||||
const ids = Array.from(bmSelected);
|
||||
if (ids.length === 0) return;
|
||||
if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
|
||||
try {
|
||||
// Group selected IDs by their owning scope.
|
||||
const byScope = {};
|
||||
for (const id of ids) {
|
||||
const bm = bmList.find((b) => b.id === id);
|
||||
const scope = bm?.scope || bmScope;
|
||||
(byScope[scope] ||= []).push(id);
|
||||
}
|
||||
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
|
||||
fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: scopeIds }),
|
||||
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
||||
));
|
||||
bmSelected.clear();
|
||||
bmUpdateSelectionUi();
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete bookmarks:", err);
|
||||
alert("Failed to delete bookmarks: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Populate the scope picker with "General" + one option per rig. */
|
||||
function bmPopulateScopePicker() {
|
||||
const picker = document.getElementById("bm-scope-picker");
|
||||
if (!picker) return;
|
||||
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
||||
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
||||
// Preserve current selection if still valid.
|
||||
const prev = picker.value;
|
||||
while (picker.options.length > 1) picker.remove(1);
|
||||
rigIds.forEach((id) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = displayNames[id] || id;
|
||||
picker.appendChild(opt);
|
||||
});
|
||||
if (prev && (prev === "general" || rigIds.includes(prev))) {
|
||||
picker.value = prev;
|
||||
} else {
|
||||
picker.value = "general";
|
||||
}
|
||||
bmScope = picker.value;
|
||||
}
|
||||
|
||||
// --- Event wiring ---
|
||||
(function initBookmarks() {
|
||||
// Set initial button visibility (auth may already be resolved by the time
|
||||
// scripts run if auth is disabled; otherwise bmFetch() will sync it).
|
||||
bmSyncAccess();
|
||||
|
||||
// Build decoder checkboxes from registry. The registry is fetched async
|
||||
// so we rebuild once it arrives to ensure checkboxes are present.
|
||||
bmBuildDecoderCheckboxes();
|
||||
if (typeof window.onDecoderRegistryReady === "function") {
|
||||
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
}
|
||||
|
||||
// Scope picker
|
||||
bmPopulateScopePicker();
|
||||
const scopePicker = document.getElementById("bm-scope-picker");
|
||||
if (scopePicker) {
|
||||
scopePicker.addEventListener("change", (e) => {
|
||||
bmScope = e.target.value;
|
||||
bmFetch(document.getElementById("bm-category-filter")?.value || "");
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh list and sync access when the Bookmarks tab is activated
|
||||
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest('.tab[data-tab="bookmarks"]');
|
||||
if (!btn) return;
|
||||
bmFetch(document.getElementById("bm-category-filter").value);
|
||||
});
|
||||
|
||||
// Add Bookmark button — open form and prefill from current rig state
|
||||
document.getElementById("bm-add-btn").addEventListener("click", () => {
|
||||
bmOpenForm(null);
|
||||
bmPrefillFromStatus();
|
||||
});
|
||||
|
||||
// Category filter dropdown
|
||||
document.getElementById("bm-category-filter").addEventListener("change", (e) => {
|
||||
bmFetch(e.target.value);
|
||||
});
|
||||
|
||||
// Mode filter dropdown (client-side, no re-fetch)
|
||||
document.getElementById("bm-mode-filter").addEventListener("change", () => {
|
||||
bmApplyFilters();
|
||||
});
|
||||
|
||||
// Text search filter (client-side, no re-fetch)
|
||||
document.getElementById("bm-text-filter").addEventListener("input", () => {
|
||||
bmApplyFilters();
|
||||
});
|
||||
|
||||
document.getElementById("bm-page-prev").addEventListener("click", () => {
|
||||
bmChangePage(-1);
|
||||
});
|
||||
|
||||
document.getElementById("bm-page-next").addEventListener("click", () => {
|
||||
bmChangePage(1);
|
||||
});
|
||||
|
||||
// Form submit
|
||||
document.getElementById("bm-form").addEventListener("submit", bmSave);
|
||||
|
||||
// Form cancel
|
||||
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||
|
||||
const formWrap = document.getElementById("bm-form-wrap");
|
||||
if (formWrap) {
|
||||
formWrap.addEventListener("click", (e) => {
|
||||
if (e.target === formWrap) bmCloseForm();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
|
||||
bmCloseForm();
|
||||
}
|
||||
});
|
||||
|
||||
// Select-all checkbox
|
||||
document.getElementById("bm-select-all").addEventListener("change", (e) => {
|
||||
const checked = e.target.checked;
|
||||
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||
cb.checked = checked;
|
||||
if (checked) bmSelected.add(cb.dataset.bmId);
|
||||
else bmSelected.delete(cb.dataset.bmId);
|
||||
});
|
||||
bmUpdateSelectionUi();
|
||||
});
|
||||
|
||||
// Select All (across all pages) button
|
||||
document.getElementById("bm-select-all-btn").addEventListener("click", () => {
|
||||
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||
if (allSelected) {
|
||||
bmSelected.clear();
|
||||
} else {
|
||||
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
||||
}
|
||||
// Sync visible page checkboxes
|
||||
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||
cb.checked = bmSelected.has(cb.dataset.bmId);
|
||||
});
|
||||
bmSyncSelectAllCheckbox();
|
||||
bmUpdateSelectionUi();
|
||||
});
|
||||
|
||||
// Delete Selected button
|
||||
document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
|
||||
bmDeleteSelected();
|
||||
});
|
||||
|
||||
// Move Selected button
|
||||
document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
|
||||
bmMoveSelected();
|
||||
});
|
||||
|
||||
// Table action buttons and row checkboxes (event delegation)
|
||||
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
||||
const checkbox = e.target.closest(".bm-row-sel");
|
||||
if (checkbox) {
|
||||
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
|
||||
else bmSelected.delete(checkbox.dataset.bmId);
|
||||
bmSyncSelectAllCheckbox();
|
||||
bmUpdateSelectionUi();
|
||||
return;
|
||||
}
|
||||
|
||||
const tuneBtn = e.target.closest(".bm-tune-btn");
|
||||
const editBtn = e.target.closest(".bm-edit-btn");
|
||||
const delBtn = e.target.closest(".bm-del-btn");
|
||||
|
||||
if (tuneBtn) {
|
||||
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
||||
if (bm) await bmApply(bm);
|
||||
} else if (editBtn) {
|
||||
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
||||
if (bm) bmOpenForm(bm);
|
||||
} else if (delBtn) {
|
||||
await bmDelete(delBtn.dataset.bmId);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load bookmarks so spectrum markers are visible immediately.
|
||||
bmFetch("");
|
||||
})();
|
||||
@@ -0,0 +1,451 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
||||
const cwStatusEl = document.getElementById("cw-status");
|
||||
const cwOutputEl = document.getElementById("cw-output");
|
||||
const cwAutoInput = document.getElementById("cw-auto");
|
||||
const cwWpmInput = document.getElementById("cw-wpm");
|
||||
const cwToneInput = document.getElementById("cw-tone");
|
||||
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
||||
const cwToneGl = typeof createTrxWebGlRenderer === "function"
|
||||
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
||||
: null;
|
||||
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||
const cwBarOverlay = document.getElementById("cw-bar-overlay");
|
||||
const CW_MAX_LINES = 200;
|
||||
const CW_TONE_MIN_HZ = 100;
|
||||
const CW_TONE_MAX_HZ = 10_000;
|
||||
const CW_WPM_MIN = 5;
|
||||
const CW_WPM_MAX = 40;
|
||||
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
const CW_BAR_LINE_GAP_MS = 5000;
|
||||
let cwLastAppendTime = 0;
|
||||
let cwTonePickerRaf = null;
|
||||
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
|
||||
let cwBarCurrentLine = null; // accumulates chars until gap/newline
|
||||
let cwBarDismissedAtMs = 0;
|
||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||
// acknowledged). While set, server-state updates must not override the
|
||||
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
||||
// does not immediately undo the user's choice.
|
||||
let cwAutoLocalOverride = null;
|
||||
|
||||
function applyCwAutoUi(enabled) {
|
||||
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||
if (cwWpmInput) {
|
||||
cwWpmInput.disabled = enabled;
|
||||
cwWpmInput.readOnly = enabled;
|
||||
}
|
||||
if (cwToneInput) {
|
||||
cwToneInput.disabled = enabled;
|
||||
cwToneInput.readOnly = enabled;
|
||||
}
|
||||
if (cwTonePickerEl) {
|
||||
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||
}
|
||||
}
|
||||
window.applyCwAutoUi = applyCwAutoUi;
|
||||
|
||||
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
||||
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
||||
window.applyCwAutoUiFromServer = function(enabled) {
|
||||
if (cwAutoLocalOverride !== null) return;
|
||||
applyCwAutoUi(enabled);
|
||||
};
|
||||
|
||||
function cwBarFlushCurrentLine() {
|
||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||
cwBarHistory.unshift(cwBarCurrentLine);
|
||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||
}
|
||||
cwBarCurrentLine = null;
|
||||
}
|
||||
|
||||
function updateCwBar() {
|
||||
if (!cwBarOverlay) return;
|
||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const isCw = mode === "CW" || mode === "CWR";
|
||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||
// Prepend the in-progress line so characters appear immediately
|
||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
|
||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||
cwBarOverlay.style.display = "none";
|
||||
cwBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
let html =
|
||||
'<div class="aprs-bar-header">' +
|
||||
'<span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span>' +
|
||||
'<span class="aprs-bar-actions">' +
|
||||
'<span class="aprs-bar-window">Last 15 minutes</span>' +
|
||||
'<span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0"' +
|
||||
' onclick="window.clearCwBar()"' +
|
||||
' onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearCwBar();}"' +
|
||||
' aria-label="Clear CW overlay">Clear</span></span>' +
|
||||
'<button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button>' +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
for (const line of liveLines.slice(0, 8)) {
|
||||
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||
const meta = [
|
||||
line.wpm ? `${line.wpm} WPM` : null,
|
||||
line.tone_hz ? `${line.tone_hz} Hz` : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
html += `<div class="aprs-bar-frame">` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
|
||||
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
|
||||
`</div></div>`;
|
||||
}
|
||||
cwBarOverlay.innerHTML = html;
|
||||
cwBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateCwBar = updateCwBar;
|
||||
window.clearCwBar = function() {
|
||||
window.resetCwHistoryView();
|
||||
};
|
||||
window.closeCwBar = function() {
|
||||
cwBarDismissedAtMs = Date.now();
|
||||
if (cwBarOverlay) {
|
||||
cwBarOverlay.style.display = "none";
|
||||
cwBarOverlay.innerHTML = "";
|
||||
}
|
||||
};
|
||||
|
||||
function clampCwWpm(wpm) {
|
||||
const numeric = Number(wpm);
|
||||
if (!Number.isFinite(numeric)) return 15;
|
||||
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||
}
|
||||
|
||||
function clampCwTone(tone) {
|
||||
const numeric = Number(tone);
|
||||
if (!Number.isFinite(numeric)) return 700;
|
||||
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||
}
|
||||
|
||||
function currentCwToneRange() {
|
||||
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
||||
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
||||
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||
return null;
|
||||
}
|
||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const lowerSideband = mode === "CWR";
|
||||
const upperSideband = mode === "CW";
|
||||
if (!lowerSideband && !upperSideband) return null;
|
||||
|
||||
const toneMinHz = CW_TONE_MIN_HZ;
|
||||
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||
if (toneMaxHz < toneMinHz) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tunedHz,
|
||||
bandwidthHz,
|
||||
toneMinHz,
|
||||
toneMaxHz,
|
||||
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
|
||||
lowerSideband,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
function cwToneToRfHz(range, toneHz) {
|
||||
if (!range) return NaN;
|
||||
return range.lowerSideband
|
||||
? range.tunedHz - toneHz
|
||||
: range.tunedHz + toneHz;
|
||||
}
|
||||
|
||||
function toneClampForRange(tone, range) {
|
||||
const clamped = clampCwTone(tone);
|
||||
if (!range) return clamped;
|
||||
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||
}
|
||||
|
||||
function ensureCwToneCanvasResolution() {
|
||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||
const rect = cwToneCanvas.getBoundingClientRect();
|
||||
const cssWidth = Math.round(rect.width);
|
||||
const cssHeight = Math.round(rect.height);
|
||||
if (cssWidth < 8 || cssHeight < 8) {
|
||||
return false;
|
||||
}
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||
}
|
||||
|
||||
function drawCwTonePicker() {
|
||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||
ensureCwToneCanvasResolution();
|
||||
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
||||
const width = cwToneCanvas.width;
|
||||
const height = cwToneCanvas.height;
|
||||
cwToneGl.clear([0, 0, 0, 0]);
|
||||
|
||||
const range = currentCwToneRange();
|
||||
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
||||
if (cwToneRangeEl) {
|
||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||
if (mode !== "CW" && mode !== "CWR") {
|
||||
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
||||
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||
}
|
||||
}
|
||||
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cwToneRangeEl) {
|
||||
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
|
||||
}
|
||||
|
||||
const bins = window.lastSpectrumData.bins;
|
||||
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
||||
const centerHz = Number(window.lastSpectrumData.center_hz);
|
||||
const maxIdx = Math.max(1, bins.length - 1);
|
||||
const fullLoHz = centerHz - sampleRate / 2;
|
||||
const tones = new Array(width).fill(-140);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||
const rfHz = cwToneToRfHz(range, toneHz);
|
||||
const idx = Math.max(0, Math.min(maxIdx, Math.round((((rfHz - fullLoHz) / sampleRate) * maxIdx))));
|
||||
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
|
||||
tones[x] = power;
|
||||
}
|
||||
|
||||
const smoothed = new Array(width).fill(-140);
|
||||
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||
if (i < 0 || i >= width) continue;
|
||||
sum += tones[i];
|
||||
count += 1;
|
||||
}
|
||||
smoothed[x] = count > 0 ? sum / count : tones[x];
|
||||
}
|
||||
|
||||
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
||||
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
|
||||
const floorDb = Math.min(q20 - 2, q95 - 10);
|
||||
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
||||
const dbSpan = Math.max(1, ceilDb - floorDb);
|
||||
const yForDb = (db) => {
|
||||
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
||||
return Math.round((1 - n) * (height - 1));
|
||||
};
|
||||
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||
const parseColor = typeof window.trxParseCssColor === "function"
|
||||
? window.trxParseCssColor
|
||||
: null;
|
||||
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||
|
||||
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||
|
||||
const hGridCount = 4;
|
||||
const gridSegments = [];
|
||||
for (let i = 1; i <= hGridCount; i += 1) {
|
||||
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
||||
gridSegments.push(0, y, width, y);
|
||||
}
|
||||
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
||||
|
||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
||||
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||
const tickSegments = [];
|
||||
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
||||
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
||||
tickSegments.push(x, 0, x, height);
|
||||
}
|
||||
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||
|
||||
const linePoints = [];
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
linePoints.push(x, yForDb(smoothed[x]));
|
||||
}
|
||||
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||
|
||||
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
||||
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
||||
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||
|
||||
if (cwAutoInput?.checked) {
|
||||
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
||||
}
|
||||
}
|
||||
|
||||
async function setCwTone(tone, { syncInput = true } = {}) {
|
||||
const range = currentCwToneRange();
|
||||
const clamped = toneClampForRange(tone, range);
|
||||
if (cwToneInput && syncInput) {
|
||||
cwToneInput.value = clamped;
|
||||
}
|
||||
try {
|
||||
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||
} catch (e) {
|
||||
console.error("CW tone set failed", e);
|
||||
}
|
||||
drawCwTonePicker();
|
||||
}
|
||||
|
||||
if (cwAutoInput) {
|
||||
cwAutoInput.addEventListener("change", async () => {
|
||||
const enabled = cwAutoInput.checked;
|
||||
cwAutoLocalOverride = enabled;
|
||||
applyCwAutoUi(enabled);
|
||||
try {
|
||||
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||
drawCwTonePicker();
|
||||
} catch (e) {
|
||||
console.error("CW auto toggle failed", e);
|
||||
} finally {
|
||||
cwAutoLocalOverride = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cwWpmInput) {
|
||||
cwWpmInput.addEventListener("change", async () => {
|
||||
if (cwAutoInput && cwAutoInput.checked) return;
|
||||
const wpm = clampCwWpm(cwWpmInput.value);
|
||||
cwWpmInput.value = wpm;
|
||||
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
||||
catch (e) { console.error("CW WPM set failed", e); }
|
||||
});
|
||||
}
|
||||
|
||||
if (cwToneInput) {
|
||||
cwToneInput.addEventListener("change", async () => {
|
||||
if (cwAutoInput?.checked) return;
|
||||
await setCwTone(cwToneInput.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (cwToneCanvas) {
|
||||
cwToneCanvas.addEventListener("click", async (event) => {
|
||||
if (cwAutoInput?.checked) return;
|
||||
const rect = cwToneCanvas.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const range = currentCwToneRange();
|
||||
if (!range) return;
|
||||
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||
await setCwTone(tone);
|
||||
});
|
||||
}
|
||||
|
||||
window.resetCwHistoryView = function() {
|
||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||
cwLastAppendTime = 0;
|
||||
cwBarHistory = [];
|
||||
cwBarCurrentLine = null;
|
||||
updateCwBar();
|
||||
drawCwTonePicker();
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_cw_decode");
|
||||
window.resetCwHistoryView();
|
||||
} catch (e) {
|
||||
console.error("CW history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Server-side CW decode handler ---
|
||||
window.onServerCw = function(evt) {
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl) {
|
||||
// Append decoded text to output
|
||||
const now = Date.now();
|
||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
||||
const line = document.createElement("div");
|
||||
line.className = "cw-line";
|
||||
cwOutputEl.appendChild(line);
|
||||
}
|
||||
cwLastAppendTime = now;
|
||||
const lastLine = cwOutputEl.lastElementChild;
|
||||
if (lastLine) {
|
||||
lastLine.textContent += evt.text;
|
||||
}
|
||||
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
||||
}
|
||||
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||
}
|
||||
// Bar history accumulation (regardless of pause state)
|
||||
if (evt.text) {
|
||||
const now = Date.now();
|
||||
if (evt.text === "\n") {
|
||||
cwBarFlushCurrentLine();
|
||||
} else {
|
||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine();
|
||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
}
|
||||
cwBarCurrentLine.text += evt.text;
|
||||
cwBarCurrentLine.lastMs = now;
|
||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||
}
|
||||
updateCwBar();
|
||||
}
|
||||
if (cwSignalIndicator) {
|
||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||
}
|
||||
if (!cwAutoInput || cwAutoInput.checked) {
|
||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||
cwWpmInput.value = clampCwWpm(evt.wpm);
|
||||
}
|
||||
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
||||
}
|
||||
}
|
||||
if (cwTonePickerRaf != null) return;
|
||||
cwTonePickerRaf = requestAnimationFrame(() => {
|
||||
cwTonePickerRaf = null;
|
||||
drawCwTonePicker();
|
||||
});
|
||||
};
|
||||
|
||||
window.restoreCwHistory = function(events) {
|
||||
if (!Array.isArray(events) || events.length === 0) return;
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
for (const evt of events) {
|
||||
window.onServerCw(evt);
|
||||
}
|
||||
};
|
||||
|
||||
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||
ensureCwToneCanvasResolution();
|
||||
drawCwTonePicker();
|
||||
};
|
||||
window.addEventListener("resize", () => {
|
||||
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
||||
});
|
||||
applyCwAutoUi(!!cwAutoInput?.checked);
|
||||
updateCwBar();
|
||||
ensureCwToneCanvasResolution();
|
||||
drawCwTonePicker();
|
||||
@@ -0,0 +1,207 @@
|
||||
// --- FT2 Decoder Plugin (server-side decode) ---
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
function ft8RenderMessageFt2(message) {
|
||||
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
||||
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
const ft2Status = document.getElementById("ft2-status");
|
||||
const ft2PeriodEl = document.getElementById("ft2-period");
|
||||
const ft2MessagesEl = document.getElementById("ft2-messages");
|
||||
const ft2FilterInput = document.getElementById("ft2-filter");
|
||||
const FT2_PERIOD_MS = 3750;
|
||||
const FT2_MAX_DOM_ROWS = 200;
|
||||
let ft2FilterText = "";
|
||||
let ft2MessageHistory = [];
|
||||
|
||||
function currentFt2HistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneFt2MessageHistory() {
|
||||
const cutoffMs = Date.now() - currentFt2HistoryRetentionMs();
|
||||
ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleFt2Ui(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); }
|
||||
|
||||
function normalizeFt2DisplayFreqHz(freqHz) {
|
||||
const rawHz = Number(freqHz);
|
||||
if (!Number.isFinite(rawHz)) return null;
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
||||
return baseHz + rawHz;
|
||||
}
|
||||
return rawHz;
|
||||
}
|
||||
|
||||
function updateFt2PeriodTimer() {
|
||||
if (!ft2PeriodEl) return;
|
||||
const nowMs = Date.now();
|
||||
const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000;
|
||||
ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
updateFt2PeriodTimer();
|
||||
setInterval(updateFt2PeriodTimer, 250);
|
||||
|
||||
function renderFt2Row(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ft8-row";
|
||||
const rawMessage = (msg.message || "").toString();
|
||||
row.dataset.message = rawMessage.toUpperCase();
|
||||
row.dataset.decoder = "ft2";
|
||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||
const renderedMessage = ft8RenderMessageFt2(rawMessage);
|
||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
||||
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFt2History() {
|
||||
pruneFt2MessageHistory();
|
||||
if (!ft2MessagesEl) return;
|
||||
const filter = ft2FilterText;
|
||||
const fragment = document.createDocumentFragment();
|
||||
let rendered = 0;
|
||||
for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) {
|
||||
const msg = ft2MessageHistory[i];
|
||||
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
||||
fragment.appendChild(renderFt2Row(msg));
|
||||
rendered++;
|
||||
}
|
||||
ft2MessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addFt2Message(msg) {
|
||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
ft2MessageHistory.unshift(msg);
|
||||
pruneFt2MessageHistory();
|
||||
window.setFt8FamilyBarDecoder?.("ft2");
|
||||
window.updateFt8Bar?.();
|
||||
scheduleFt2HistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerFt2Message(msg) {
|
||||
const raw = (msg.message || "").toString();
|
||||
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
||||
const grids = locatorDetails.length > 0
|
||||
? locatorDetails.map((d) => d.grid)
|
||||
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
||||
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
||||
const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||
return {
|
||||
raw, grids, station, rfHz, locatorDetails,
|
||||
history: {
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||
message: msg.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerFt2Batch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (ft2Status) ft2Status.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerFt2Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
normalized.reverse();
|
||||
ft2MessageHistory = normalized.concat(ft2MessageHistory);
|
||||
pruneFt2MessageHistory();
|
||||
window.setFt8FamilyBarDecoder?.("ft2");
|
||||
window.updateFt8Bar?.();
|
||||
scheduleFt2HistoryRender();
|
||||
};
|
||||
|
||||
window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); };
|
||||
window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); };
|
||||
|
||||
window.resetFt2HistoryView = function() {
|
||||
if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
|
||||
ft2MessageHistory = [];
|
||||
window.updateFt8Bar?.();
|
||||
renderFt2History();
|
||||
};
|
||||
|
||||
function buildFt2BarFrames() {
|
||||
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
||||
const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
||||
if (messages.length === 0) {
|
||||
return { count: 0, newestTsMs: 0, html: "" };
|
||||
}
|
||||
let html = "";
|
||||
for (const msg of messages) {
|
||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||
const text = ft8RenderMessageFt2((msg.message || "").toString());
|
||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
return { count: messages.length, newestTsMs, html };
|
||||
}
|
||||
window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames);
|
||||
|
||||
if (ft2FilterInput) {
|
||||
ft2FilterInput.addEventListener("input", () => {
|
||||
ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
|
||||
renderFt2History();
|
||||
});
|
||||
}
|
||||
|
||||
const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
|
||||
ft2DecodeToggleBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn);
|
||||
await postPath("/toggle_ft2_decode");
|
||||
} catch (e) {
|
||||
console.error("FT2 toggle failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_ft2_decode");
|
||||
window.resetFt2HistoryView();
|
||||
} catch (e) { console.error("FT2 history clear failed", e); }
|
||||
});
|
||||
|
||||
window.onServerFt2 = function(msg) {
|
||||
if (ft2Status) ft2Status.textContent = "Receiving";
|
||||
const next = normalizeServerFt2Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||
}
|
||||
addFt2Message(next.history);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
// --- FT4 Decoder Plugin (server-side decode) ---
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
function ft8RenderMessage(message) {
|
||||
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
||||
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
const ft4Status = document.getElementById("ft4-status");
|
||||
const ft4PeriodEl = document.getElementById("ft4-period");
|
||||
const ft4MessagesEl = document.getElementById("ft4-messages");
|
||||
const ft4FilterInput = document.getElementById("ft4-filter");
|
||||
const FT4_PERIOD_MS = 7500;
|
||||
const FT4_MAX_DOM_ROWS = 200;
|
||||
let ft4FilterText = "";
|
||||
let ft4MessageHistory = [];
|
||||
|
||||
function currentFt4HistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneFt4MessageHistory() {
|
||||
const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
|
||||
ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleFt4Ui(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); }
|
||||
|
||||
function normalizeFt4DisplayFreqHz(freqHz) {
|
||||
const rawHz = Number(freqHz);
|
||||
if (!Number.isFinite(rawHz)) return null;
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
||||
return baseHz + rawHz;
|
||||
}
|
||||
return rawHz;
|
||||
}
|
||||
|
||||
function updateFt4PeriodTimer() {
|
||||
if (!ft4PeriodEl) return;
|
||||
const nowMs = Date.now();
|
||||
const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000;
|
||||
ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
updateFt4PeriodTimer();
|
||||
setInterval(updateFt4PeriodTimer, 250);
|
||||
|
||||
function renderFt4Row(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ft8-row";
|
||||
const rawMessage = (msg.message || "").toString();
|
||||
row.dataset.message = rawMessage.toUpperCase();
|
||||
row.dataset.decoder = "ft4";
|
||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||
const renderedMessage = ft8RenderMessage(rawMessage);
|
||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
||||
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFt4History() {
|
||||
pruneFt4MessageHistory();
|
||||
if (!ft4MessagesEl) return;
|
||||
const filter = ft4FilterText;
|
||||
const fragment = document.createDocumentFragment();
|
||||
let rendered = 0;
|
||||
for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) {
|
||||
const msg = ft4MessageHistory[i];
|
||||
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
||||
fragment.appendChild(renderFt4Row(msg));
|
||||
rendered++;
|
||||
}
|
||||
ft4MessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addFt4Message(msg) {
|
||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
ft4MessageHistory.unshift(msg);
|
||||
pruneFt4MessageHistory();
|
||||
window.setFt8FamilyBarDecoder?.("ft4");
|
||||
window.updateFt8Bar?.();
|
||||
scheduleFt4HistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerFt4Message(msg) {
|
||||
const raw = (msg.message || "").toString();
|
||||
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
||||
const grids = locatorDetails.length > 0
|
||||
? locatorDetails.map((d) => d.grid)
|
||||
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
||||
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
||||
const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||
return {
|
||||
raw, grids, station, rfHz, locatorDetails,
|
||||
history: {
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||
message: msg.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerFt4Batch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (ft4Status) ft4Status.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerFt4Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
normalized.reverse();
|
||||
ft4MessageHistory = normalized.concat(ft4MessageHistory);
|
||||
pruneFt4MessageHistory();
|
||||
window.setFt8FamilyBarDecoder?.("ft4");
|
||||
window.updateFt8Bar?.();
|
||||
scheduleFt4HistoryRender();
|
||||
};
|
||||
|
||||
window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); };
|
||||
window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); };
|
||||
|
||||
window.resetFt4HistoryView = function() {
|
||||
if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
|
||||
ft4MessageHistory = [];
|
||||
window.updateFt8Bar?.();
|
||||
renderFt4History();
|
||||
};
|
||||
|
||||
function buildFt4BarFrames() {
|
||||
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
||||
const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
||||
if (messages.length === 0) {
|
||||
return { count: 0, newestTsMs: 0, html: "" };
|
||||
}
|
||||
let html = "";
|
||||
for (const msg of messages) {
|
||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||
const text = ft8RenderMessage((msg.message || "").toString());
|
||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
return { count: messages.length, newestTsMs, html };
|
||||
}
|
||||
window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames);
|
||||
|
||||
if (ft4FilterInput) {
|
||||
ft4FilterInput.addEventListener("input", () => {
|
||||
ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
|
||||
renderFt4History();
|
||||
});
|
||||
}
|
||||
|
||||
const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
|
||||
ft4DecodeToggleBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn);
|
||||
await postPath("/toggle_ft4_decode");
|
||||
} catch (e) {
|
||||
console.error("FT4 toggle failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_ft4_decode");
|
||||
window.resetFt4HistoryView();
|
||||
} catch (e) { console.error("FT4 history clear failed", e); }
|
||||
});
|
||||
|
||||
window.onServerFt4 = function(msg) {
|
||||
if (ft4Status) ft4Status.textContent = "Receiving";
|
||||
const next = normalizeServerFt4Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||
}
|
||||
addFt4Message(next.history);
|
||||
};
|
||||
@@ -0,0 +1,486 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- FT8 Decoder Plugin (server-side decode) ---
|
||||
const ft8Status = document.getElementById("ft8-status");
|
||||
const ft8PeriodEl = document.getElementById("ft8-period");
|
||||
const ft8MessagesEl = document.getElementById("ft8-messages");
|
||||
const ft8FilterInput = document.getElementById("ft8-filter");
|
||||
const ft8BarOverlay = document.getElementById("ft8-bar-overlay");
|
||||
const FT8_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
const FT8_PERIOD_SECONDS = 15;
|
||||
const FT8_MAX_DOM_ROWS = 200;
|
||||
const FT8_BAR_DECODER_LABELS = {
|
||||
ft8: "FT8",
|
||||
ft4: "FT4",
|
||||
ft2: "FT2",
|
||||
};
|
||||
let ft8FilterText = "";
|
||||
let ft8MessageHistory = [];
|
||||
let ft8BarActiveDecoder = "ft8";
|
||||
const ft8BarBuilders = {};
|
||||
const ft8BarDismissedAtMsByDecoder = {
|
||||
ft8: 0,
|
||||
ft4: 0,
|
||||
ft2: 0,
|
||||
};
|
||||
|
||||
function currentFt8HistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneFt8MessageHistory() {
|
||||
const cutoffMs = Date.now() - currentFt8HistoryRetentionMs();
|
||||
ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleFt8Ui(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleFt8HistoryRender() {
|
||||
scheduleFt8Ui("ft8-history", () => renderFt8History());
|
||||
}
|
||||
|
||||
function scheduleFt8BarUpdate() {
|
||||
scheduleFt8Ui("ft8-bar", () => updateFt8Bar());
|
||||
}
|
||||
|
||||
window.registerFt8FamilyBarRenderer = function(decoder, builder) {
|
||||
if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return;
|
||||
ft8BarBuilders[decoder] = builder;
|
||||
};
|
||||
|
||||
window.setFt8FamilyBarDecoder = function(decoder) {
|
||||
if (!FT8_BAR_DECODER_LABELS[decoder]) return;
|
||||
ft8BarActiveDecoder = decoder;
|
||||
scheduleFt8BarUpdate();
|
||||
};
|
||||
|
||||
function normalizeFt8DisplayFreqHz(freqHz) {
|
||||
const rawHz = Number(freqHz);
|
||||
if (!Number.isFinite(rawHz)) return null;
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
||||
return baseHz + rawHz;
|
||||
}
|
||||
return rawHz;
|
||||
}
|
||||
|
||||
function fmtTime(tsMs) {
|
||||
if (!tsMs) return "--:--:--";
|
||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function updateFt8PeriodTimer() {
|
||||
if (!ft8PeriodEl) return;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS);
|
||||
ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
updateFt8PeriodTimer();
|
||||
setInterval(updateFt8PeriodTimer, 500);
|
||||
|
||||
function renderFt8Row(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ft8-row";
|
||||
const rawMessage = (msg.message || "").toString();
|
||||
row.dataset.message = rawMessage.toUpperCase();
|
||||
row.dataset.decoder = "ft8";
|
||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||
const renderedMessage = renderFt8Message(rawMessage);
|
||||
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
applyFt8FilterToRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFt8History() {
|
||||
pruneFt8MessageHistory();
|
||||
if (!ft8MessagesEl) return;
|
||||
const fragment = document.createDocumentFragment();
|
||||
const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS);
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
fragment.appendChild(renderFt8Row(ft8MessageHistory[i]));
|
||||
}
|
||||
ft8MessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addFt8Message(msg) {
|
||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
ft8MessageHistory.unshift(msg);
|
||||
pruneFt8MessageHistory();
|
||||
ft8BarActiveDecoder = "ft8";
|
||||
scheduleFt8BarUpdate();
|
||||
scheduleFt8HistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerFt8Message(msg) {
|
||||
const raw = (msg.message || "").toString();
|
||||
const locatorDetails = ft8ExtractLocatorDetails(raw);
|
||||
const grids = locatorDetails.length > 0
|
||||
? locatorDetails.map((detail) => detail.grid)
|
||||
: ft8ExtractAllGrids(raw);
|
||||
const station = ft8ExtractLikelyCallsign(raw);
|
||||
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||
return {
|
||||
raw,
|
||||
grids,
|
||||
station,
|
||||
rfHz,
|
||||
locatorDetails,
|
||||
history: {
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
dt_s: msg.dt_s,
|
||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||
message: msg.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerFt8Batch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
ft8Status.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerFt8Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
||||
...msg,
|
||||
freq_hz: next.rfHz,
|
||||
locator_details: next.locatorDetails,
|
||||
});
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
normalized.reverse();
|
||||
ft8MessageHistory = normalized.concat(ft8MessageHistory);
|
||||
pruneFt8MessageHistory();
|
||||
ft8BarActiveDecoder = "ft8";
|
||||
scheduleFt8BarUpdate();
|
||||
scheduleFt8HistoryRender();
|
||||
};
|
||||
|
||||
window.restoreFt8History = function(messages) {
|
||||
window.onServerFt8Batch(messages);
|
||||
};
|
||||
|
||||
window.pruneFt8HistoryView = function() {
|
||||
pruneFt8MessageHistory();
|
||||
updateFt8Bar();
|
||||
renderFt8History();
|
||||
};
|
||||
|
||||
function ft8BarRfText(msg) {
|
||||
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||
if (!Number.isFinite(displayFreqHz)) return null;
|
||||
return `${displayFreqHz.toFixed(0)} Hz`;
|
||||
}
|
||||
|
||||
function buildFt8BarFrames() {
|
||||
const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS;
|
||||
const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0);
|
||||
if (messages.length === 0) {
|
||||
return { count: 0, newestTsMs: 0, html: "" };
|
||||
}
|
||||
let html = "";
|
||||
for (const msg of messages) {
|
||||
const ts = msg.ts_ms ? `<span class="aprs-bar-time">${fmtTime(msg.ts_ms)}</span>` : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||
const rf = ft8BarRfText(msg);
|
||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||
const text = ft8EscapeHtml((msg.message || "").toString());
|
||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
return { count: messages.length, newestTsMs, html };
|
||||
}
|
||||
|
||||
function updateFt8Bar() {
|
||||
if (!ft8BarOverlay) return;
|
||||
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB";
|
||||
const decoder = ft8BarActiveDecoder;
|
||||
const builder = ft8BarBuilders[decoder];
|
||||
const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8";
|
||||
const result = typeof builder === "function" ? builder() : null;
|
||||
const newestTsMs = Number(result?.newestTsMs) || 0;
|
||||
if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) {
|
||||
ft8BarOverlay.style.display = "none";
|
||||
ft8BarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
ft8BarOverlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${result.html}`;
|
||||
ft8BarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateFt8Bar = updateFt8Bar;
|
||||
window.clearFt8Bar = function() {
|
||||
const decoder = ft8BarActiveDecoder;
|
||||
if (decoder === "ft4") {
|
||||
window.resetFt4HistoryView?.();
|
||||
return;
|
||||
}
|
||||
if (decoder === "ft2") {
|
||||
window.resetFt2HistoryView?.();
|
||||
return;
|
||||
}
|
||||
window.resetFt8HistoryView?.();
|
||||
};
|
||||
window.closeFt8Bar = function() {
|
||||
ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now();
|
||||
if (ft8BarOverlay) {
|
||||
ft8BarOverlay.style.display = "none";
|
||||
ft8BarOverlay.innerHTML = "";
|
||||
}
|
||||
};
|
||||
window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames);
|
||||
|
||||
function renderFt8Message(message) {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < message.length) {
|
||||
const ch = message[i];
|
||||
if (ft8IsAlphaNum(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||
const token = message.slice(i, j);
|
||||
const grid = token.toUpperCase();
|
||||
if (ft8IsMaidenheadGridToken(grid)) {
|
||||
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||
} else {
|
||||
out += ft8EscapeHtml(token);
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
out += ft8EscapeHtml(ch);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ft8TokenizeMessage(message) {
|
||||
return String(message || "")
|
||||
.toUpperCase()
|
||||
.split(/[^A-Z0-9/]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ft8ExtractAllGrids(message) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
let i = 0;
|
||||
while (i < message.length) {
|
||||
if (ft8IsAlphaNum(message[i])) {
|
||||
let j = i + 1;
|
||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||
const token = message.slice(i, j);
|
||||
const grid = token.toUpperCase();
|
||||
if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
|
||||
seen.add(grid);
|
||||
out.push(grid);
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ft8ExtractLocatorDetails(message) {
|
||||
const tokens = ft8TokenizeMessage(message);
|
||||
const grids = ft8ExtractAllGrids(String(message || ""));
|
||||
if (tokens.length === 0 || grids.length === 0) return [];
|
||||
const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
|
||||
const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
|
||||
const callsigns = [];
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
|
||||
}
|
||||
|
||||
let source = null;
|
||||
let target = null;
|
||||
const head = tokens[0];
|
||||
if (callsigns.length > 0) {
|
||||
if (head === "CQ" || head === "DE" || head === "QRZ") {
|
||||
source = callsigns[0];
|
||||
} else if (callsigns.length >= 2) {
|
||||
target = callsigns[0];
|
||||
source = callsigns[1];
|
||||
} else {
|
||||
source = callsigns[0];
|
||||
}
|
||||
}
|
||||
|
||||
return grids.map((grid) => ({
|
||||
grid,
|
||||
station: source || null,
|
||||
source: source || null,
|
||||
target: target || null,
|
||||
}));
|
||||
}
|
||||
|
||||
function ft8ExtractLikelyCallsign(message) {
|
||||
const locatorDetails = ft8ExtractLocatorDetails(message);
|
||||
if (locatorDetails.length > 0 && locatorDetails[0].station) {
|
||||
return locatorDetails[0].station;
|
||||
}
|
||||
const tokens = ft8TokenizeMessage(message);
|
||||
for (const token of tokens) {
|
||||
if (ft8IsLikelyCallsignToken(token)) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ft8IsLikelyCallsignToken(token) {
|
||||
if (!token) return false;
|
||||
if (token.length < 3 || token.length > 12) return false;
|
||||
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
|
||||
if (ft8IsMaidenheadGridToken(token)) return false;
|
||||
return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
||||
}
|
||||
|
||||
function ft8IsFarewellToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||
}
|
||||
|
||||
function ft8IsMaidenheadGridToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
|
||||
}
|
||||
|
||||
function ft8EscapeHtml(input) {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """);
|
||||
}
|
||||
|
||||
function ft8IsAlphaNum(ch) {
|
||||
return /[A-Za-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
function activateFt8HistoryLocator(targetEl) {
|
||||
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
||||
if (!locatorEl) return false;
|
||||
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||
if (!grid) return false;
|
||||
if (typeof window.navigateToMapLocator === "function") {
|
||||
window.navigateToMapLocator(grid, "ft8");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyFt8FilterToRow(row) {
|
||||
if (!ft8FilterText) {
|
||||
row.style.display = "";
|
||||
return;
|
||||
}
|
||||
const message = row.dataset.message || "";
|
||||
row.style.display = message.includes(ft8FilterText) ? "" : "none";
|
||||
}
|
||||
|
||||
function applyFt8FilterToAll() {
|
||||
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
||||
rows.forEach((row) => applyFt8FilterToRow(row));
|
||||
}
|
||||
|
||||
function updateFt8RowRf(row) {
|
||||
const freqEl = row.querySelector(".ft8-freq");
|
||||
if (!freqEl) return;
|
||||
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
|
||||
const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
|
||||
if (Number.isFinite(displayFreqHz)) {
|
||||
freqEl.textContent = displayFreqHz.toFixed(0);
|
||||
} else {
|
||||
freqEl.textContent = "--";
|
||||
}
|
||||
}
|
||||
|
||||
window.updateFt8RfDisplay = function() {
|
||||
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
||||
rows.forEach((row) => updateFt8RowRf(row));
|
||||
updateFt8Bar();
|
||||
};
|
||||
|
||||
window.resetFt8HistoryView = function() {
|
||||
ft8MessagesEl.innerHTML = "";
|
||||
ft8MessageHistory = [];
|
||||
updateFt8Bar();
|
||||
renderFt8History();
|
||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
|
||||
};
|
||||
|
||||
if (ft8FilterInput) {
|
||||
ft8FilterInput.addEventListener("input", () => {
|
||||
ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
|
||||
renderFt8History();
|
||||
});
|
||||
}
|
||||
|
||||
if (ft8MessagesEl) {
|
||||
ft8MessagesEl.addEventListener("click", (event) => {
|
||||
if (!activateFt8HistoryLocator(event.target)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
ft8MessagesEl.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (!activateFt8HistoryLocator(event.target)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
|
||||
ft8DecodeToggleBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
|
||||
await postPath("/toggle_ft8_decode");
|
||||
} catch (e) {
|
||||
console.error("FT8 toggle failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_ft8_decode");
|
||||
window.resetFt8HistoryView();
|
||||
} catch (e) {
|
||||
console.error("FT8 history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Server-side FT8 decode handler ---
|
||||
window.onServerFt8 = function(msg) {
|
||||
ft8Status.textContent = "Receiving";
|
||||
const next = normalizeServerFt8Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
||||
...msg,
|
||||
freq_hz: next.rfHz,
|
||||
locator_details: next.locatorDetails,
|
||||
});
|
||||
}
|
||||
addFt8Message(next.history);
|
||||
};
|
||||
@@ -0,0 +1,444 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
||||
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
||||
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
||||
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
||||
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
||||
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
||||
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
||||
let hfAprsFilterText = "";
|
||||
let hfAprsPacketHistory = [];
|
||||
let hfAprsOnlyPos = false;
|
||||
let hfAprsHideCrc = false;
|
||||
let hfAprsCollapseDup = false;
|
||||
let hfAprsTypeFilter = "all";
|
||||
|
||||
function currentHfAprsHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneHfAprsPacketHistory() {
|
||||
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
||||
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleHfAprsHistoryRender() {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
|
||||
return;
|
||||
}
|
||||
renderHfAprsHistory();
|
||||
}
|
||||
|
||||
function hfAprsPacketCategory(pkt) {
|
||||
const type = String(pkt.type || "").toLowerCase();
|
||||
const info = String(pkt.info || "").toLowerCase();
|
||||
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function hfAprsCategoryLabel(category) {
|
||||
switch (category) {
|
||||
case "position": return "Position";
|
||||
case "message": return "Message";
|
||||
case "weather": return "Weather";
|
||||
case "telemetry": return "Telemetry";
|
||||
default: return "Other";
|
||||
}
|
||||
}
|
||||
|
||||
function hfAprsAgeText(tsMs) {
|
||||
if (!Number.isFinite(tsMs)) return "just now";
|
||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||
const seconds = Math.round(deltaMs / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function hfAprsDistanceText(pkt) {
|
||||
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
}
|
||||
|
||||
function hfAprsPacketSignature(pkt) {
|
||||
return [
|
||||
pkt.srcCall || "",
|
||||
pkt.destCall || "",
|
||||
pkt.path || "",
|
||||
pkt.info || "",
|
||||
pkt.type || "",
|
||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function hfAprsHexBytes(bytes) {
|
||||
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||
}
|
||||
|
||||
function hfAprsFilterMatch(pkt) {
|
||||
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
||||
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
|
||||
if (!hfAprsFilterText) return true;
|
||||
const haystack = [
|
||||
pkt.srcCall,
|
||||
pkt.destCall,
|
||||
pkt.path,
|
||||
pkt.info,
|
||||
pkt.type,
|
||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||
hfAprsPacketCategory(pkt),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
return haystack.includes(hfAprsFilterText);
|
||||
}
|
||||
|
||||
function hfAprsVisiblePackets() {
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||
return packets.filter(hfAprsFilterMatch);
|
||||
}
|
||||
|
||||
function collapseHfAprsDuplicates(packets) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pkt of packets) {
|
||||
const key = hfAprsPacketSignature(pkt);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(pkt);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function updateHfAprsSummary() {
|
||||
const visible = hfAprsVisiblePackets();
|
||||
if (hfAprsTotalCountEl) {
|
||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||
}
|
||||
if (hfAprsVisibleCountEl) {
|
||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (hfAprsLatestSeenEl) {
|
||||
const latest = hfAprsPacketHistory[0];
|
||||
if (!latest) {
|
||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateHfAprsChipState() {
|
||||
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
|
||||
});
|
||||
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
|
||||
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
|
||||
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||
}
|
||||
|
||||
function renderHfAprsInfo(pkt) {
|
||||
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||
if (bytes && bytes.length > 0) {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i];
|
||||
if (b >= 0x20 && b <= 0x7e) {
|
||||
const ch = String.fromCharCode(b);
|
||||
if (ch === "<") out += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
else out += ch;
|
||||
} else {
|
||||
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const str = pkt.info || "";
|
||||
let out = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code >= 0x20 && code <= 0x7e) {
|
||||
const ch = str[i];
|
||||
if (ch === "<") out += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
else out += ch;
|
||||
} else {
|
||||
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderHfAprsRow(pkt, isFresh) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "aprs-packet";
|
||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||
if (isFresh) row.classList.add("aprs-packet-new");
|
||||
|
||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const age = hfAprsAgeText(pkt._tsMs);
|
||||
const category = hfAprsPacketCategory(pkt);
|
||||
const categoryLabel = hfAprsCategoryLabel(category);
|
||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
||||
let symbolHtml = "";
|
||||
if (pkt.symbolTable && pkt.symbolCode) {
|
||||
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||
const col = code % 16;
|
||||
const row2 = Math.floor(code / 16);
|
||||
const bgX = -(col * 24);
|
||||
const bgY = -(row2 * 24);
|
||||
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||
}
|
||||
const posLink = pkt.lat != null && pkt.lon != null
|
||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
||||
: "";
|
||||
const distance = hfAprsDistanceText(pkt);
|
||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||
|
||||
row.innerHTML =
|
||||
`<div class="aprs-row-head">` +
|
||||
`<span class="aprs-time">${ts}</span>` +
|
||||
hfBadge +
|
||||
symbolHtml +
|
||||
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
||||
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
||||
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
||||
pathBadge +
|
||||
crcBadge +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-meta">` +
|
||||
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
||||
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
||||
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-detail">` +
|
||||
`<span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(pkt)}</span>` +
|
||||
(posLink ? `<span>${posLink}</span>` : "") +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-actions">` +
|
||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||
`</div>` +
|
||||
`<details class="aprs-details">` +
|
||||
`<summary>Details</summary>` +
|
||||
`<div class="aprs-details-grid">` +
|
||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span>` +
|
||||
`</div>` +
|
||||
`</details>`;
|
||||
|
||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||
el.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
const raw = String(el.dataset.aprsMap || "");
|
||||
const [lat, lon] = raw.split(",").map(Number);
|
||||
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
window.navigateToAprsMap(lat, lon);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
showHint("Coordinates copied", 1200);
|
||||
}
|
||||
} catch (_e) {
|
||||
showHint("Copy failed", 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderHfAprsHistory() {
|
||||
pruneHfAprsPacketHistory();
|
||||
if (!hfAprsPacketsEl) {
|
||||
updateHfAprsSummary();
|
||||
updateHfAprsChipState();
|
||||
return;
|
||||
}
|
||||
const visible = hfAprsVisiblePackets();
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
|
||||
}
|
||||
hfAprsPacketsEl.replaceChildren(fragment);
|
||||
updateHfAprsSummary();
|
||||
updateHfAprsChipState();
|
||||
}
|
||||
|
||||
window.resetHfAprsHistoryView = function() {
|
||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||
hfAprsPacketHistory = [];
|
||||
renderHfAprsHistory();
|
||||
};
|
||||
|
||||
window.pruneHfAprsHistoryView = function() {
|
||||
pruneHfAprsPacketHistory();
|
||||
renderHfAprsHistory();
|
||||
};
|
||||
|
||||
function addHfAprsPacket(pkt) {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
|
||||
hfAprsPacketHistory.unshift(pkt);
|
||||
pruneHfAprsPacketHistory();
|
||||
|
||||
scheduleHfAprsHistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerHfAprsPacket(pkt) {
|
||||
return {
|
||||
rig_id: pkt.rig_id || null,
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
srcCall: pkt.src_call,
|
||||
destCall: pkt.dest_call,
|
||||
path: pkt.path,
|
||||
info: pkt.info,
|
||||
info_bytes: pkt.info_bytes,
|
||||
type: pkt.packet_type,
|
||||
crcOk: pkt.crc_ok,
|
||||
ts_ms: pkt.ts_ms,
|
||||
lat: pkt.lat,
|
||||
lon: pkt.lon,
|
||||
symbolTable: pkt.symbol_table,
|
||||
symbolCode: pkt.symbol_code,
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerHfAprsBatch = function(packets) {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerHfAprsPacket(pkt);
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
|
||||
pruneHfAprsPacketHistory();
|
||||
scheduleHfAprsHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreHfAprsHistory = function(packets) {
|
||||
window.onServerHfAprsBatch(packets);
|
||||
};
|
||||
|
||||
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
||||
hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
||||
await postPath("/toggle_hf_aprs_decode");
|
||||
} catch (e) {
|
||||
console.error("HF APRS toggle failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_hf_aprs_decode");
|
||||
window.resetHfAprsHistoryView();
|
||||
} catch (e) {
|
||||
console.error("HF APRS history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (hfAprsOnlyPosBtn) {
|
||||
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
||||
hfAprsOnlyPos = !hfAprsOnlyPos;
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (hfAprsHideCrcBtn) {
|
||||
hfAprsHideCrcBtn.addEventListener("click", () => {
|
||||
hfAprsHideCrc = !hfAprsHideCrc;
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (hfAprsCollapseDupBtn) {
|
||||
hfAprsCollapseDupBtn.addEventListener("click", () => {
|
||||
hfAprsCollapseDup = !hfAprsCollapseDup;
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||
const btn = document.getElementById(`hf-aprs-type-${type}`);
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
hfAprsTypeFilter = type;
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
});
|
||||
|
||||
if (hfAprsFilterInput) {
|
||||
hfAprsFilterInput.addEventListener("input", () => {
|
||||
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Server-side HF APRS decode handler ---
|
||||
window.onServerHfAprs = function(pkt) {
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||
};
|
||||
|
||||
renderHfAprsHistory();
|
||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
|
||||
@@ -0,0 +1,321 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// Satellite Pass Scheduling UI
|
||||
// Manages the satellite overlay section within the background decoding scheduler.
|
||||
// Communicates with scheduler.js via a thin window API for shared state access.
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ── DOM references (cached once) ──────────────────────────────────
|
||||
const dom = {
|
||||
enabled: document.getElementById("scheduler-sat-enabled"),
|
||||
pretune: document.getElementById("scheduler-sat-pretune"),
|
||||
body: document.getElementById("scheduler-sat-body"),
|
||||
tbody: document.getElementById("scheduler-sat-tbody"),
|
||||
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
||||
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
||||
formWrap: document.getElementById("sch-sat-form-wrap"),
|
||||
formTitle: document.getElementById("sch-sat-form-title"),
|
||||
form: document.getElementById("sch-sat-form"),
|
||||
formCancel: document.getElementById("sch-sat-form-cancel"),
|
||||
preset: document.getElementById("scheduler-sat-preset"),
|
||||
name: document.getElementById("scheduler-sat-name"),
|
||||
norad: document.getElementById("scheduler-sat-norad"),
|
||||
bookmark: document.getElementById("scheduler-sat-bookmark"),
|
||||
minEl: document.getElementById("scheduler-sat-min-el"),
|
||||
priority: document.getElementById("scheduler-sat-priority"),
|
||||
centerHz: document.getElementById("scheduler-sat-center-hz"),
|
||||
};
|
||||
|
||||
// ── Local state ───────────────────────────────────────────────────
|
||||
let editIdx = null; // null = adding, number = editing
|
||||
|
||||
// ── Scheduler bridge ──────────────────────────────────────────────
|
||||
// These accessors call into scheduler.js via window.schedulerBridge,
|
||||
// which is set up by scheduler.js after it initializes.
|
||||
function getBridge() {
|
||||
return window.schedulerBridge || {};
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const b = getBridge();
|
||||
return typeof b.getConfig === "function" ? b.getConfig() : null;
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
const b = getBridge();
|
||||
return typeof b.getStatus === "function" ? b.getStatus() : null;
|
||||
}
|
||||
|
||||
function getBookmarks() {
|
||||
const b = getBridge();
|
||||
return typeof b.getBookmarks === "function" ? b.getBookmarks() : [];
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
var b = getBridge();
|
||||
if (typeof b.markDirty === "function") b.markDirty();
|
||||
}
|
||||
|
||||
function bmName(id) {
|
||||
const bm = getBookmarks().find(function (b) { return b.id === id; });
|
||||
return bm ? bm.name : String(id || "");
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatFreq(hz) {
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
||||
return hz + " Hz";
|
||||
}
|
||||
|
||||
// ── Satellite config helpers ──────────────────────────────────────
|
||||
function getSatelliteEntries() {
|
||||
var config = getConfig();
|
||||
return (config && config.satellites && Array.isArray(config.satellites.entries))
|
||||
? config.satellites.entries
|
||||
: [];
|
||||
}
|
||||
|
||||
function ensureSatelliteConfig() {
|
||||
var config = getConfig();
|
||||
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
||||
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
||||
if (!config.satellites.entries) config.satellites.entries = [];
|
||||
return config.satellites;
|
||||
}
|
||||
|
||||
function collectSatelliteConfig() {
|
||||
var enabled = dom.enabled ? dom.enabled.checked : false;
|
||||
var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
||||
return {
|
||||
enabled: enabled,
|
||||
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
||||
entries: getSatelliteEntries(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Render: section ───────────────────────────────────────────────
|
||||
function renderSection() {
|
||||
var config = getConfig();
|
||||
var satCfg = (config && config.satellites) || {};
|
||||
var enabled = !!satCfg.enabled;
|
||||
|
||||
if (dom.enabled) dom.enabled.checked = enabled;
|
||||
if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60;
|
||||
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
||||
|
||||
renderEntries();
|
||||
renderPassStatus();
|
||||
}
|
||||
|
||||
// ── Render: entries table ─────────────────────────────────────────
|
||||
function renderEntries() {
|
||||
if (!dom.tbody) return;
|
||||
var entries = getSatelliteEntries();
|
||||
var frag = document.createDocumentFragment();
|
||||
|
||||
entries.forEach(function (entry, idx) {
|
||||
var tr = document.createElement("tr");
|
||||
|
||||
var tdSat = document.createElement("td");
|
||||
tdSat.textContent = entry.satellite || "";
|
||||
tr.appendChild(tdSat);
|
||||
|
||||
var tdNorad = document.createElement("td");
|
||||
tdNorad.textContent = entry.norad_id || "";
|
||||
tr.appendChild(tdNorad);
|
||||
|
||||
var tdBm = document.createElement("td");
|
||||
tdBm.textContent = bmName(entry.bookmark_id);
|
||||
tr.appendChild(tdBm);
|
||||
|
||||
var tdEl = document.createElement("td");
|
||||
tdEl.textContent = (entry.min_elevation_deg != null ? entry.min_elevation_deg + "\u00B0" : "5\u00B0");
|
||||
tr.appendChild(tdEl);
|
||||
|
||||
var tdPrio = document.createElement("td");
|
||||
tdPrio.textContent = entry.priority || 0;
|
||||
tr.appendChild(tdPrio);
|
||||
|
||||
var tdActions = document.createElement("td");
|
||||
|
||||
var editBtn = document.createElement("button");
|
||||
editBtn.className = "sch-write";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.addEventListener("click", function () {
|
||||
openForm(entry, idx);
|
||||
});
|
||||
tdActions.appendChild(editBtn);
|
||||
|
||||
var removeBtn = document.createElement("button");
|
||||
removeBtn.className = "sch-write";
|
||||
removeBtn.type = "button";
|
||||
removeBtn.textContent = "Remove";
|
||||
removeBtn.addEventListener("click", function () {
|
||||
removeEntry(idx);
|
||||
});
|
||||
tdActions.appendChild(removeBtn);
|
||||
|
||||
tr.appendChild(tdActions);
|
||||
frag.appendChild(tr);
|
||||
});
|
||||
|
||||
dom.tbody.replaceChildren(frag);
|
||||
}
|
||||
|
||||
// ── Render: pass status ───────────────────────────────────────────
|
||||
function renderPassStatus() {
|
||||
if (!dom.passStatus) return;
|
||||
var entries = getSatelliteEntries();
|
||||
if (entries.length === 0) {
|
||||
dom.passStatus.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
var status = getStatus();
|
||||
if (status && status.active_satellite) {
|
||||
dom.passStatus.innerHTML =
|
||||
'<span class="sch-sat-active-badge">PASS ACTIVE: ' +
|
||||
escHtml(status.active_satellite) +
|
||||
'</span>';
|
||||
} else {
|
||||
dom.passStatus.innerHTML =
|
||||
'<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render: bookmark dropdown ─────────────────────────────────────
|
||||
function renderBookmarkSelect(selectedId) {
|
||||
if (!dom.bookmark) return;
|
||||
dom.bookmark.innerHTML = '<option value="">— none —</option>';
|
||||
getBookmarks().forEach(function (bm) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = bm.id;
|
||||
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||
if (bm.id === selectedId) opt.selected = true;
|
||||
dom.bookmark.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Entry management ──────────────────────────────────────────────
|
||||
function removeEntry(idx) {
|
||||
var sat = ensureSatelliteConfig();
|
||||
sat.entries.splice(idx, 1);
|
||||
renderEntries();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
// ── Form: open ────────────────────────────────────────────────────
|
||||
function openForm(entry, idx) {
|
||||
editIdx = (idx != null) ? idx : null;
|
||||
|
||||
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
|
||||
if (dom.preset) dom.preset.value = "";
|
||||
if (dom.name) dom.name.value = entry ? (entry.satellite || "") : "";
|
||||
if (dom.norad) dom.norad.value = entry ? (entry.norad_id || "") : "";
|
||||
if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5;
|
||||
if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0;
|
||||
if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : "";
|
||||
|
||||
renderBookmarkSelect(entry ? entry.bookmark_id : null);
|
||||
|
||||
if (dom.formWrap) {
|
||||
dom.formWrap.style.display = "flex";
|
||||
if (dom.name) dom.name.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form: close ───────────────────────────────────────────────────
|
||||
function closeForm() {
|
||||
if (dom.formWrap) dom.formWrap.style.display = "none";
|
||||
editIdx = null;
|
||||
}
|
||||
|
||||
// ── Form: submit ──────────────────────────────────────────────────
|
||||
function onFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var satellite = dom.name ? dom.name.value.trim() : "";
|
||||
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
||||
var bmId = dom.bookmark ? dom.bookmark.value : "";
|
||||
|
||||
if (!satellite) { alert("Please enter a satellite name."); return; }
|
||||
if (isNaN(noradId) || noradId <= 0) { alert("Please enter a valid NORAD catalog number."); return; }
|
||||
if (!bmId) { alert("Please select a bookmark."); return; }
|
||||
|
||||
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
||||
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
||||
var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
||||
|
||||
var sat = ensureSatelliteConfig();
|
||||
|
||||
var entryData = {
|
||||
satellite: satellite,
|
||||
norad_id: noradId,
|
||||
bookmark_id: bmId,
|
||||
min_elevation_deg: isNaN(minEl) ? 5 : minEl,
|
||||
priority: isNaN(prio) ? 0 : prio,
|
||||
center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
|
||||
bookmark_ids: [],
|
||||
};
|
||||
|
||||
if (editIdx !== null) {
|
||||
var existing = sat.entries[editIdx];
|
||||
entryData.id = existing ? existing.id : ("sat_" + Date.now().toString(36));
|
||||
sat.entries[editIdx] = entryData;
|
||||
} else {
|
||||
entryData.id = "sat_" + Date.now().toString(36);
|
||||
sat.entries.push(entryData);
|
||||
}
|
||||
|
||||
closeForm();
|
||||
renderEntries();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
// ── Preset change handler ─────────────────────────────────────────
|
||||
function onPresetChange() {
|
||||
if (!dom.preset || !dom.preset.value) return;
|
||||
var parts = dom.preset.value.split("|");
|
||||
if (dom.name) dom.name.value = parts[0] || "";
|
||||
if (dom.norad) dom.norad.value = parts[1] || "";
|
||||
}
|
||||
|
||||
// ── Wire all events ───────────────────────────────────────────────
|
||||
function wireEvents() {
|
||||
if (dom.enabled) {
|
||||
dom.enabled.addEventListener("change", function () {
|
||||
if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none";
|
||||
markDirty();
|
||||
});
|
||||
}
|
||||
if (dom.pretune) {
|
||||
dom.pretune.addEventListener("input", function () {
|
||||
markDirty();
|
||||
});
|
||||
}
|
||||
if (dom.addBtn) dom.addBtn.addEventListener("click", function () { openForm(null, null); });
|
||||
if (dom.form) dom.form.addEventListener("submit", onFormSubmit);
|
||||
if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm);
|
||||
if (dom.preset) dom.preset.addEventListener("change", onPresetChange);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────
|
||||
window.satScheduler = {
|
||||
wireEvents: wireEvents,
|
||||
renderSection: renderSection,
|
||||
renderPassStatus: renderPassStatus,
|
||||
collectSatelliteConfig: collectSatelliteConfig,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,546 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- SAT Plugin ---
|
||||
// Live view: decoder state, latest image card
|
||||
// History view: filterable table of all decoded images
|
||||
// Predictions view: next 24 h passes for ham satellites
|
||||
|
||||
// ── DOM references (cached once) ───────────────────────────────────
|
||||
const satDom = {
|
||||
status: document.getElementById("sat-status"),
|
||||
liveView: document.getElementById("sat-live-view"),
|
||||
historyView: document.getElementById("sat-history-view"),
|
||||
predictionsView: document.getElementById("sat-predictions-view"),
|
||||
liveLatest: document.getElementById("sat-live-latest"),
|
||||
historyList: document.getElementById("sat-history-list"),
|
||||
historyCount: document.getElementById("sat-history-count"),
|
||||
filterInput: document.getElementById("sat-filter"),
|
||||
sortSelect: document.getElementById("sat-sort"),
|
||||
typeFilter: document.getElementById("sat-type-filter"),
|
||||
lrptState: document.getElementById("sat-lrpt-state"),
|
||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||
predFilter: document.getElementById("sat-pred-filter"),
|
||||
predMinEl: document.getElementById("sat-pred-min-el"),
|
||||
predCategory: document.getElementById("sat-pred-category"),
|
||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||
predStatus: document.getElementById("sat-pred-status"),
|
||||
};
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────
|
||||
let satImageHistory = [];
|
||||
const SAT_MAX_IMAGES = 100;
|
||||
const SAT_PRED_PAGE_SIZE = 50;
|
||||
let satPredShowAll = false;
|
||||
let satFilterText = "";
|
||||
let satActiveView = "live"; // "live" | "history" | "predictions"
|
||||
let satPredData = [];
|
||||
let satPredFilterText = "";
|
||||
let satPredMinEl = 0;
|
||||
let satPredCategory = "all";
|
||||
let satPredSatCount = 0;
|
||||
let satPredCountdownTimer = null;
|
||||
|
||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||
function scheduleSatUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
// ── View switching ──────────────────────────────────────────────────
|
||||
function switchSatView(view) {
|
||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||
satActiveView = view;
|
||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||
if (leavingPredictions) clearPredictionDom();
|
||||
if (view === "history") {
|
||||
renderSatHistoryTable();
|
||||
} else if (view === "predictions") {
|
||||
satPredShowAll = false;
|
||||
loadSatPredictions();
|
||||
}
|
||||
}
|
||||
|
||||
function clearPredictionDom() {
|
||||
stopCountdownTimer();
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
}
|
||||
window.clearSatPredictionDom = clearPredictionDom;
|
||||
|
||||
satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
|
||||
satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
|
||||
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
|
||||
|
||||
// ── Live view: decoder state ────────────────────────────────────────
|
||||
let _lastSatLrptOn = null;
|
||||
window.updateSatLiveState = function (update) {
|
||||
if (!satDom.lrptState) return;
|
||||
const lrptOn = !!update.lrpt_decode_enabled;
|
||||
if (lrptOn !== _lastSatLrptOn) {
|
||||
_lastSatLrptOn = lrptOn;
|
||||
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
||||
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
|
||||
if (satDom.status) {
|
||||
if (lrptOn) {
|
||||
satDom.status.textContent = "Decoder active \u2014 waiting for signal";
|
||||
} else {
|
||||
satDom.status.textContent = "Decoder idle";
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function renderSatLatestCard() {
|
||||
if (!satDom.liveLatest) return;
|
||||
if (satImageHistory.length === 0) {
|
||||
satDom.liveLatest.innerHTML =
|
||||
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const img = satImageHistory[0];
|
||||
const decoder = img._decoder || "unknown";
|
||||
const typeName = "Meteor LRPT";
|
||||
const satellite = img.satellite || "";
|
||||
const channels = img.channels || img.channel_a || "";
|
||||
const lines = img.mcu_count || img.line_count || 0;
|
||||
const unit = "MCU rows";
|
||||
const ts = img._ts || "--";
|
||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||
|
||||
let meta = [typeName];
|
||||
if (satellite) meta.push(satellite);
|
||||
if (channels) meta.push(channels);
|
||||
meta.push(`${lines} ${unit}`);
|
||||
meta.push(`${date} ${ts}`);
|
||||
|
||||
let html = `<div class="sat-latest-card">`;
|
||||
html += `<div class="sat-latest-title">Latest decoded image</div>`;
|
||||
html += `<div class="sat-latest-meta">${meta.join(" · ")}</div>`;
|
||||
if (img.path) {
|
||||
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
|
||||
}
|
||||
if (img.geo_bounds) {
|
||||
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
satDom.liveLatest.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── History view: table ─────────────────────────────────────────────
|
||||
function getSatFilteredHistory() {
|
||||
let items = satImageHistory;
|
||||
|
||||
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
||||
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
|
||||
|
||||
if (satFilterText) {
|
||||
items = items.filter((i) => {
|
||||
const haystack = [
|
||||
"meteor lrpt",
|
||||
i.satellite || "",
|
||||
i.channels || "",
|
||||
i.channel_a || "",
|
||||
i.channel_b || "",
|
||||
].join(" ").toUpperCase();
|
||||
return haystack.includes(satFilterText);
|
||||
});
|
||||
}
|
||||
|
||||
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
|
||||
if (sortVal === "oldest") items = items.slice().reverse();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderSatHistoryRow(img) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-history-row";
|
||||
|
||||
const decoder = img._decoder || "unknown";
|
||||
const typeName = "Meteor LRPT";
|
||||
const typeClass = "sat-type-lrpt";
|
||||
const ts = img._ts || "--";
|
||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||
const satellite = img.satellite || "--";
|
||||
const channels = img.channels || "--";
|
||||
const lines = img.mcu_count || img.line_count || 0;
|
||||
const unit = "MCU";
|
||||
let link = img.path
|
||||
? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>`
|
||||
: "--";
|
||||
if (img.geo_bounds) {
|
||||
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
|
||||
}
|
||||
|
||||
row.innerHTML = [
|
||||
`<span>${date} ${ts}</span>`,
|
||||
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
|
||||
`<span>${satellite}</span>`,
|
||||
`<span>${channels}</span>`,
|
||||
`<span>${lines} ${unit}</span>`,
|
||||
`<span>${link}</span>`,
|
||||
].join("");
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderSatHistoryTable() {
|
||||
if (!satDom.historyList) return;
|
||||
const items = getSatFilteredHistory();
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
fragment.appendChild(renderSatHistoryRow(items[i]));
|
||||
}
|
||||
satDom.historyList.replaceChildren(fragment);
|
||||
|
||||
if (satDom.historyCount) {
|
||||
const total = satImageHistory.length;
|
||||
const shown = items.length;
|
||||
satDom.historyCount.textContent =
|
||||
total === 0
|
||||
? "No images yet"
|
||||
: shown === total
|
||||
? `${total} image${total === 1 ? "" : "s"}`
|
||||
: `${shown} of ${total} images`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add image to history ────────────────────────────────────────────
|
||||
function addSatImage(img, decoder) {
|
||||
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
|
||||
img._tsMs = tsMs;
|
||||
img._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
img._decoder = decoder;
|
||||
|
||||
satImageHistory.unshift(img);
|
||||
if (satImageHistory.length > SAT_MAX_IMAGES) {
|
||||
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
|
||||
}
|
||||
|
||||
scheduleSatUi("sat-latest", () => renderSatLatestCard());
|
||||
if (satActiveView === "history") {
|
||||
scheduleSatUi("sat-history", () => renderSatHistoryTable());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server callbacks ────────────────────────────────────────────────
|
||||
window.onServerLrptProgress = function (msg) {
|
||||
if (satDom.status && msg.mcu_count > 0) {
|
||||
satDom.status.textContent = "Receiving \u2014 " + msg.mcu_count + " MCU rows decoded";
|
||||
}
|
||||
};
|
||||
|
||||
window.onServerLrptImage = function (msg) {
|
||||
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
||||
addSatImage(msg, "lrpt");
|
||||
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
|
||||
window.addSatMapOverlay(msg);
|
||||
}
|
||||
};
|
||||
|
||||
window.resetSatHistoryView = function () {
|
||||
satImageHistory = [];
|
||||
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
||||
renderSatLatestCard();
|
||||
renderSatHistoryTable();
|
||||
if (window.clearSatMapOverlays) window.clearSatMapOverlays();
|
||||
};
|
||||
|
||||
window.pruneSatHistoryView = function () {
|
||||
renderSatHistoryTable();
|
||||
renderSatLatestCard();
|
||||
};
|
||||
|
||||
// ── Toggle buttons ──────────────────────────────────────────────────
|
||||
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||
lrptDecodeToggleBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||
await postPath("/toggle_lrpt_decode");
|
||||
} catch (e) {
|
||||
console.error("LRPT toggle failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Filter / sort event listeners ───────────────────────────────────
|
||||
satDom.filterInput?.addEventListener("input", () => {
|
||||
satFilterText = satDom.filterInput.value.trim().toUpperCase();
|
||||
renderSatHistoryTable();
|
||||
});
|
||||
|
||||
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
|
||||
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
|
||||
|
||||
// ── Settings: clear history ─────────────────────────────────────────
|
||||
document
|
||||
.getElementById("settings-clear-sat-history")
|
||||
?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all satellite decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_lrpt_decode");
|
||||
window.resetSatHistoryView();
|
||||
} catch (e) {
|
||||
console.error("Weather satellite history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Predictions: helpers ────────────────────────────────────────────
|
||||
function azToCardinal(deg) {
|
||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
return dirs[Math.round(deg / 45) % 8];
|
||||
}
|
||||
|
||||
function formatPredTime(ms) {
|
||||
const d = new Date(ms);
|
||||
const now = new Date();
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const day = d.getUTCDay() !== now.getUTCDay() ? dayNames[d.getUTCDay()] + " " : "";
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${day}${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function formatPredDuration(s) {
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function formatCountdown(ms) {
|
||||
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function elevationClass(deg) {
|
||||
if (deg >= 45) return "sat-pred-el-high";
|
||||
if (deg >= 10) return "sat-pred-el-mid";
|
||||
return "sat-pred-el-low";
|
||||
}
|
||||
|
||||
// ── Predictions: countdown timer management ─────────────────────────
|
||||
function stopCountdownTimer() {
|
||||
if (satPredCountdownTimer) {
|
||||
clearInterval(satPredCountdownTimer);
|
||||
satPredCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdownTimer(container) {
|
||||
const countdownEls = container ? container.querySelectorAll(".sat-pred-col-countdown") : [];
|
||||
if (countdownEls.length === 0) return;
|
||||
|
||||
satPredCountdownTimer = setInterval(() => {
|
||||
if (satActiveView !== "predictions") {
|
||||
stopCountdownTimer();
|
||||
return;
|
||||
}
|
||||
const n = Date.now();
|
||||
let anyActive = false;
|
||||
for (const el of countdownEls) {
|
||||
const los = parseInt(el.dataset.los, 10);
|
||||
const rem = los - n;
|
||||
if (rem > 0) {
|
||||
el.textContent = formatCountdown(rem);
|
||||
anyActive = true;
|
||||
} else {
|
||||
el.textContent = "0:00";
|
||||
}
|
||||
}
|
||||
if (!anyActive) {
|
||||
stopCountdownTimer();
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ── Predictions: row builders ───────────────────────────────────────
|
||||
function buildCurrentPassRow(pass, now) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row-current";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
const remaining = Math.max(0, pass.los_ms - now);
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildUpcomingPassRow(pass) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Predictions: filter state ───────────────────────────────────────
|
||||
function getFilteredPredictions() {
|
||||
let items = satPredData;
|
||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
||||
return items;
|
||||
}
|
||||
|
||||
function applyPredFilters() {
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
|
||||
satDom.predFilter?.addEventListener("input", () => {
|
||||
satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
|
||||
applyPredFilters();
|
||||
});
|
||||
|
||||
satDom.predMinEl?.addEventListener("change", () => {
|
||||
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
|
||||
applyPredFilters();
|
||||
});
|
||||
|
||||
satDom.predCategory?.addEventListener("change", () => {
|
||||
satPredCategory = satDom.predCategory.value;
|
||||
applyPredFilters();
|
||||
});
|
||||
|
||||
// ── Predictions: main render ────────────────────────────────────────
|
||||
function renderSatPredictions(passes, error) {
|
||||
stopCountdownTimer();
|
||||
|
||||
if (error) {
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(passes) || passes.length === 0) {
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||
|
||||
// ── Current passes ──
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||
if (satDom.predCurrentList) {
|
||||
if (current.length === 0) {
|
||||
satDom.predCurrentList.innerHTML = "";
|
||||
} else {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||
satDom.predCurrentList.replaceChildren(frag);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upcoming passes ──
|
||||
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||
if (satDom.predUpcomingList) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||
if (hiddenCount > 0) {
|
||||
const moreRow = document.createElement("div");
|
||||
moreRow.className = "sat-pred-row";
|
||||
moreRow.style.cursor = "pointer";
|
||||
moreRow.style.textAlign = "center";
|
||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
||||
moreRow.addEventListener("click", () => {
|
||||
satPredShowAll = true;
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
});
|
||||
frag.appendChild(moreRow);
|
||||
}
|
||||
satDom.predUpcomingList.replaceChildren(frag);
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
if (satDom.predStatus) {
|
||||
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
||||
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
|
||||
satDom.predStatus.textContent = text;
|
||||
}
|
||||
|
||||
// ── Countdown timer ──
|
||||
if (current.length > 0 && satActiveView === "predictions") {
|
||||
startCountdownTimer(satDom.predCurrentList);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Predictions: data loading ───────────────────────────────────────
|
||||
async function loadSatPredictions() {
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
try {
|
||||
const resp = await fetch("/sat_passes");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
satPredSatCount = data.satellite_count || 0;
|
||||
if (data.error) {
|
||||
satPredData = [];
|
||||
renderSatPredictions([], data.error);
|
||||
} else {
|
||||
satPredData = data.passes || [];
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
} catch (e) {
|
||||
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||
window.satShowOnMap = function (south, west, north, east) {
|
||||
if (typeof window.enableMapSourceFilter === "function") {
|
||||
window.enableMapSourceFilter("sat");
|
||||
}
|
||||
const lat = (south + north) / 2;
|
||||
const lon = (west + east) / 2;
|
||||
if (window.navigateToAprsMap) {
|
||||
window.navigateToAprsMap(lat, lon);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Initial render ──────────────────────────────────────────────────
|
||||
renderSatLatestCard();
|
||||
renderSatHistoryTable();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,565 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- Virtual Channels Plugin ---
|
||||
//
|
||||
// Handles the `session` and `channels` SSE events emitted by /events and
|
||||
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
|
||||
|
||||
let vchanSessionId = null;
|
||||
let vchanRigId = null;
|
||||
let vchanChannels = [];
|
||||
let vchanActiveId = null;
|
||||
let schedulerReleaseState = null;
|
||||
let schedulerReleasePollTimer = null;
|
||||
|
||||
function vchanFmtFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||
return hz + "\u202fHz";
|
||||
}
|
||||
|
||||
function schedulerReleaseSummaryText(state) {
|
||||
if (!state) return "Scheduler is controlling the rig.";
|
||||
const connected = Number(state.connected_sessions) || 0;
|
||||
const released = Number(state.released_sessions) || 0;
|
||||
if (connected === 0) return "Scheduler can control the rig.";
|
||||
if (state.all_released) {
|
||||
return connected === 1
|
||||
? "Scheduler is controlling the rig."
|
||||
: `Scheduler is controlling the rig for all ${connected} users.`;
|
||||
}
|
||||
if (!state.current_session_released) {
|
||||
const othersReleased = Math.max(released, 0);
|
||||
return othersReleased > 0
|
||||
? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.`
|
||||
: "You are holding control. Release it to return control to the scheduler.";
|
||||
}
|
||||
const blocking = Math.max(connected - released, 0);
|
||||
return blocking > 0
|
||||
? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.`
|
||||
: "Scheduler can control the rig.";
|
||||
}
|
||||
|
||||
function vchanRenderSchedulerRelease() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
const status = document.getElementById("scheduler-release-status");
|
||||
if (!btn || !status) return;
|
||||
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
||||
btn.disabled = !vchanSessionId || currentReleased;
|
||||
btn.classList.toggle("active", !currentReleased);
|
||||
btn.textContent = "Release to Scheduler";
|
||||
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
||||
}
|
||||
|
||||
async function vchanPollSchedulerRelease() {
|
||||
if (!vchanSessionId) {
|
||||
schedulerReleaseState = null;
|
||||
vchanRenderSchedulerRelease();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
vchanRenderSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.error("scheduler release status failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function vchanStartSchedulerReleasePolling() {
|
||||
if (schedulerReleasePollTimer) {
|
||||
clearInterval(schedulerReleasePollTimer);
|
||||
}
|
||||
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 10000);
|
||||
}
|
||||
|
||||
async function vchanToggleSchedulerRelease() {
|
||||
if (!vchanSessionId) return;
|
||||
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
|
||||
try {
|
||||
const resp = await fetch("/scheduler-control", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
vchanRenderSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.error("scheduler release toggle failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanTakeSchedulerControl() {
|
||||
if (!vchanSessionId) return;
|
||||
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
|
||||
try {
|
||||
const resp = await fetch("/scheduler-control", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, released: false }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
vchanRenderSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.error("scheduler control takeover failed", e);
|
||||
}
|
||||
}
|
||||
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
|
||||
|
||||
// Called by app.js when the SSE `session` event arrives.
|
||||
function vchanHandleSession(data) {
|
||||
try {
|
||||
const d = JSON.parse(data);
|
||||
vchanSessionId = d.session_id || null;
|
||||
vchanPollSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.warn("vchan: bad session event", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by app.js when the SSE `channels` event arrives.
|
||||
function vchanHandleChannels(data) {
|
||||
try {
|
||||
const d = JSON.parse(data);
|
||||
vchanRigId = d.remote || null;
|
||||
vchanChannels = d.channels || [];
|
||||
const ids = new Set(vchanChannels.map(c => c.id));
|
||||
if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) {
|
||||
// First channels event for this session — auto-subscribe to channel 0
|
||||
// so we join the same tuned channel as other users on this rig.
|
||||
// Use a direct subscribe (no scheduler control takeover) to avoid
|
||||
// side-effects on initial connect.
|
||||
vchanAutoJoinPrimary(vchanChannels[0].id);
|
||||
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
||||
// Active channel was evicted — fall back to channel 0 and reconnect audio.
|
||||
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
|
||||
vchanReconnectAudio();
|
||||
}
|
||||
vchanRender();
|
||||
vchanRenderSchedulerRelease();
|
||||
if (typeof renderRdsOverlays === "function") renderRdsOverlays();
|
||||
} catch (e) {
|
||||
console.warn("vchan: bad channels event", e);
|
||||
}
|
||||
}
|
||||
|
||||
function vchanRender() {
|
||||
const picker = document.getElementById("vchan-picker");
|
||||
if (!picker) return;
|
||||
picker.innerHTML = "";
|
||||
|
||||
vchanChannels.forEach(ch => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
|
||||
if (ch.id === vchanActiveId) btn.classList.add("active");
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "vchan-label";
|
||||
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
|
||||
btn.appendChild(label);
|
||||
|
||||
if (!ch.permanent) {
|
||||
const del = document.createElement("span");
|
||||
del.className = "vchan-del";
|
||||
del.textContent = "\u00d7";
|
||||
del.title = "Delete channel";
|
||||
del.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
vchanDelete(ch.id);
|
||||
});
|
||||
btn.appendChild(del);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
|
||||
});
|
||||
|
||||
picker.appendChild(btn);
|
||||
});
|
||||
|
||||
// "+" button — allocate a new channel at the current VFO frequency.
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "vchan-add";
|
||||
addBtn.textContent = "+";
|
||||
addBtn.title = "Allocate new virtual channel at current frequency";
|
||||
addBtn.addEventListener("click", vchanAllocate);
|
||||
picker.appendChild(addBtn);
|
||||
|
||||
vchanSyncAccentUI();
|
||||
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||
updateDocumentTitle(activeChannelRds());
|
||||
}
|
||||
vchanRenderSchedulerRelease();
|
||||
}
|
||||
|
||||
async function vchanAllocate() {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
|
||||
// Use the last known rig frequency and mode as the starting point.
|
||||
const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0)
|
||||
? lastFreqHz
|
||||
: 0;
|
||||
const modeEl = document.getElementById("mode");
|
||||
const mode = modeEl ? (modeEl.value || "USB") : "USB";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const msg = await resp.text().catch(() => String(resp.status));
|
||||
console.warn("vchan: allocate failed —", msg);
|
||||
return;
|
||||
}
|
||||
const ch = await resp.json();
|
||||
vchanActiveId = ch.id;
|
||||
// The SSE `channels` event will trigger vchanRender(); optimistically
|
||||
// mark active so the picker feels responsive even before the event arrives.
|
||||
vchanRender();
|
||||
vchanReconnectAudio();
|
||||
} catch (e) {
|
||||
console.error("vchan: allocate error", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanDelete(channelId) {
|
||||
if (!vchanRigId) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn("vchan: delete failed", resp.status);
|
||||
}
|
||||
// Channel list updates via SSE `channels` event.
|
||||
} catch (e) {
|
||||
console.error("vchan: delete error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight auto-join for initial connect: registers the session on
|
||||
// channel 0 without taking scheduler control or reconnecting audio
|
||||
// (audio isn't started yet at this point).
|
||||
async function vchanAutoJoinPrimary(channelId) {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn("vchan: auto-join primary failed", resp.status);
|
||||
return;
|
||||
}
|
||||
vchanActiveId = channelId;
|
||||
vchanRender();
|
||||
} catch (e) {
|
||||
console.error("vchan: auto-join error", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanSubscribe(channelId) {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
try {
|
||||
await vchanTakeSchedulerControl();
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn("vchan: subscribe failed", resp.status);
|
||||
return;
|
||||
}
|
||||
vchanActiveId = channelId;
|
||||
vchanRender();
|
||||
vchanSyncModeDisplay();
|
||||
vchanReconnectAudio();
|
||||
} catch (e) {
|
||||
console.error("vchan: subscribe error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect the audio WebSocket to the appropriate endpoint:
|
||||
// - virtual channel: /audio?channel_id=<uuid>
|
||||
// - primary channel: /audio (no param)
|
||||
// Always updates _audioChannelOverride so that starting audio later
|
||||
// connects to the correct channel. Only reconnects if RX audio is active.
|
||||
function vchanReconnectAudio() {
|
||||
// Always update the override so startRxAudio picks up the right URL,
|
||||
// even when audio isn't currently running.
|
||||
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||
if (typeof _audioChannelOverride !== "undefined") {
|
||||
_audioChannelOverride = ch ? ch.id : null;
|
||||
}
|
||||
if (typeof rxActive === "undefined" || !rxActive) return;
|
||||
if (typeof stopRxAudio === "function") stopRxAudio();
|
||||
// Delay so the server has time to set up the per-channel encoder.
|
||||
// The server-side audio_ws handler also polls for up to 2 s, so this
|
||||
// just needs to be long enough for the WS upgrade to reach the server.
|
||||
setTimeout(() => {
|
||||
if (typeof startRxAudio === "function") startRxAudio();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Called by app.js from applyCapabilities().
|
||||
// Shows the channel picker only for SDR rigs.
|
||||
function vchanApplyCapabilities(caps) {
|
||||
const picker = document.getElementById("vchan-picker");
|
||||
if (!picker) return;
|
||||
picker.style.display = (caps && caps.filter_controls) ? "" : "none";
|
||||
vchanRenderSchedulerRelease();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Freq / mode interception + UI accent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Returns true when the active channel is a non-primary (virtual) channel.
|
||||
function vchanIsOnVirtual() {
|
||||
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
||||
return vchanActiveId !== vchanChannels[0].id;
|
||||
}
|
||||
|
||||
function vchanActiveChannel() {
|
||||
return vchanChannels.find(c => c.id === vchanActiveId) || null;
|
||||
}
|
||||
|
||||
// Update the main freq input to show the virtual channel's frequency.
|
||||
function vchanUpdateFreqDisplay() {
|
||||
const ch = vchanActiveChannel();
|
||||
if (!ch) return;
|
||||
const el = document.getElementById("freq");
|
||||
if (!el) return;
|
||||
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") {
|
||||
el.value = formatFreqForStep(ch.freq_hz, jogUnit);
|
||||
} else {
|
||||
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the mode picker to the active virtual channel's mode.
|
||||
// Called whenever the active channel changes or the channel list is refreshed.
|
||||
function vchanSyncModeDisplay() {
|
||||
const modeEl = document.getElementById("mode");
|
||||
if (!modeEl) return;
|
||||
if (vchanIsOnVirtual()) {
|
||||
const ch = vchanActiveChannel();
|
||||
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
||||
}
|
||||
// When on primary channel, app.js rig-state updates handle the picker.
|
||||
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||
if (typeof lastModeName !== "undefined") {
|
||||
if (modeUpper === "WFM" && lastModeName !== "WFM") {
|
||||
if (typeof setJogDivisor === "function") setJogDivisor(10);
|
||||
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||
} else if (modeUpper !== "WFM" && lastModeName === "WFM") {
|
||||
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||
}
|
||||
lastModeName = modeUpper;
|
||||
}
|
||||
if (typeof updateWfmControls === "function") updateWfmControls();
|
||||
if (typeof updateSdrSquelchControlVisibility === "function") {
|
||||
updateSdrSquelchControlVisibility();
|
||||
}
|
||||
if (typeof refreshRdsUi === "function") {
|
||||
refreshRdsUi();
|
||||
} else if (typeof positionRdsPsOverlay === "function") {
|
||||
positionRdsPsOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the BW input to the active virtual channel's bandwidth.
|
||||
function vchanSyncBwDisplay() {
|
||||
if (!vchanIsOnVirtual()) return;
|
||||
const ch = vchanActiveChannel();
|
||||
if (!ch) return;
|
||||
const bwEl = document.getElementById("spectrum-bw-input");
|
||||
if (!bwEl) return;
|
||||
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
|
||||
let bwHz = ch.bandwidth_hz || 0;
|
||||
if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
|
||||
bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
|
||||
}
|
||||
if (bwHz > 0) {
|
||||
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
|
||||
if (typeof currentBandwidthHz !== "undefined") {
|
||||
currentBandwidthHz = bwHz;
|
||||
window.currentBandwidthHz = bwHz;
|
||||
} else {
|
||||
window.currentBandwidthHz = bwHz;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add / remove the vchan accent class from the freq and BW inputs.
|
||||
function vchanSyncAccentUI() {
|
||||
const onVirtual = vchanIsOnVirtual();
|
||||
const freqEl = document.getElementById("freq");
|
||||
const bwEl = document.getElementById("spectrum-bw-input");
|
||||
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||
if (onVirtual) {
|
||||
vchanUpdateFreqDisplay();
|
||||
vchanSyncModeDisplay();
|
||||
vchanSyncBwDisplay();
|
||||
} else if (typeof _origRefreshFreqDisplay === "function") {
|
||||
_origRefreshFreqDisplay();
|
||||
}
|
||||
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||
updateDocumentTitle(activeChannelRds());
|
||||
}
|
||||
}
|
||||
|
||||
// Saved reference to the original refreshFreqDisplay from app.js.
|
||||
let _origRefreshFreqDisplay = null;
|
||||
|
||||
function vchanSetChannelFreq(freqHz) {
|
||||
if (!vchanRigId || !vchanActiveId) return;
|
||||
// Validate against current SDR capture window.
|
||||
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData &&
|
||||
lastSpectrumData.sample_rate > 0) {
|
||||
const halfSpan = Number(lastSpectrumData.sample_rate) / 2;
|
||||
const center = Number(lastSpectrumData.center_hz);
|
||||
if (Math.abs(freqHz - center) > halfSpan) {
|
||||
if (typeof showHint === "function") {
|
||||
showHint(
|
||||
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||
3000
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fire-and-forget: scheduler control + channel freq PUT run in background.
|
||||
vchanTakeSchedulerControl();
|
||||
fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ freq_hz: Math.round(freqHz) }),
|
||||
}
|
||||
).catch(e => console.error("vchan: set freq error", e));
|
||||
}
|
||||
|
||||
async function vchanSetChannelBandwidth(bwHz) {
|
||||
if (!vchanRigId || !vchanActiveId) return;
|
||||
try {
|
||||
await vchanTakeSchedulerControl();
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
|
||||
} catch (e) {
|
||||
console.error("vchan: set bw error", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanSetChannelMode(mode) {
|
||||
if (!vchanRigId || !vchanActiveId) return;
|
||||
try {
|
||||
await vchanTakeSchedulerControl();
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
|
||||
} catch (e) {
|
||||
console.error("vchan: set mode error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
|
||||
// sending /set_mode to the server. Returns true if the change was handled
|
||||
// by the virtual channel (caller should skip the server request).
|
||||
window.vchanInterceptMode = async function(mode) {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
await vchanSetChannelMode(mode);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Called by app.js bandwidth setters before sending /set_bandwidth to the
|
||||
// server. Returns true if the change was handled by the virtual channel.
|
||||
window.vchanInterceptBandwidth = async function(bwHz) {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
await vchanSetChannelBandwidth(bwHz);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
|
||||
// frequency changes are redirected to the active virtual channel instead of
|
||||
// the server when on a non-primary channel.
|
||||
(function() {
|
||||
const _orig = window.setRigFrequency;
|
||||
window.setRigFrequency = function(freqHz) {
|
||||
if (vchanIsOnVirtual()) {
|
||||
// Optimistic local update first, then fire-and-forget channel API.
|
||||
if (typeof applyLocalTunedFrequency === "function") {
|
||||
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||
++_freqOptimisticSeq;
|
||||
_freqOptimisticHz = Math.round(freqHz);
|
||||
}
|
||||
applyLocalTunedFrequency(Math.round(freqHz));
|
||||
}
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return;
|
||||
}
|
||||
// Scheduler control is fire-and-forget — don't block the freq change.
|
||||
vchanTakeSchedulerControl();
|
||||
if (typeof _orig === "function") _orig(freqHz);
|
||||
};
|
||||
})();
|
||||
|
||||
(function initSchedulerReleaseControl() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
if (btn) {
|
||||
btn.addEventListener("click", () => {
|
||||
vchanToggleSchedulerRelease();
|
||||
});
|
||||
}
|
||||
vchanStartSchedulerReleasePolling();
|
||||
vchanRenderSchedulerRelease();
|
||||
})();
|
||||
|
||||
// Wrap refreshFreqDisplay so the main freq field stays in sync with the
|
||||
// active virtual channel's frequency (SSE rig-state updates would otherwise
|
||||
// constantly overwrite it with channel 0's freq).
|
||||
(function() {
|
||||
_origRefreshFreqDisplay = window.refreshFreqDisplay;
|
||||
window.refreshFreqDisplay = function() {
|
||||
if (vchanIsOnVirtual()) {
|
||||
vchanUpdateFreqDisplay();
|
||||
return;
|
||||
}
|
||||
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay();
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,352 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- VDES Decoder Plugin (server-side decode) ---
|
||||
const vdesStatus = document.getElementById("vdes-status");
|
||||
const vdesMessagesEl = document.getElementById("vdes-messages");
|
||||
const vdesFilterInput = document.getElementById("vdes-filter");
|
||||
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
let vdesFilterText = "";
|
||||
let vdesMessageHistory = [];
|
||||
|
||||
function currentVdesHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneVdesMessageHistory() {
|
||||
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
||||
vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleVdesUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleVdesHistoryRender() {
|
||||
scheduleVdesUi("vdes-history", () => renderVdesHistory());
|
||||
}
|
||||
|
||||
function scheduleVdesBarUpdate() {
|
||||
scheduleVdesUi("vdes-bar", () => updateVdesBar());
|
||||
}
|
||||
|
||||
function currentVdesCenterText() {
|
||||
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||
const hz = raw ? Number(raw) : 0;
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
|
||||
return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`;
|
||||
}
|
||||
|
||||
function vdesAgeText(tsMs) {
|
||||
if (!Number.isFinite(tsMs)) return "just now";
|
||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||
const seconds = Math.round(deltaMs / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function vdesHexPreview(rawBytes) {
|
||||
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||
return rawBytes
|
||||
.slice(0, 20)
|
||||
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function updateVdesSummary() {
|
||||
pruneVdesMessageHistory();
|
||||
if (vdesChannelSummaryEl) {
|
||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||
}
|
||||
if (vdesFrameCountEl) {
|
||||
const count = vdesMessageHistory.length;
|
||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (vdesLatestSeenEl) {
|
||||
const latest = vdesMessageHistory[0];
|
||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||
}
|
||||
}
|
||||
|
||||
function applyVdesFilterToRow(row) {
|
||||
if (!vdesFilterText) {
|
||||
row.style.display = "";
|
||||
return;
|
||||
}
|
||||
const text = row.dataset.filterText || "";
|
||||
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
||||
}
|
||||
|
||||
function applyVdesFilterToAll() {
|
||||
if (!vdesMessagesEl) return;
|
||||
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
|
||||
}
|
||||
|
||||
function renderVdesRow(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "vdes-message";
|
||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
const title = msg.vessel_name || "VDES Burst";
|
||||
const label = msg.callsign || "VDES";
|
||||
const info = msg.destination || "";
|
||||
const labelText = msg.message_label || "";
|
||||
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
|
||||
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
|
||||
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
|
||||
const fecText = msg.fec_state || "";
|
||||
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
|
||||
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
|
||||
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
|
||||
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
|
||||
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
|
||||
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
|
||||
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
|
||||
const previewText = msg.payload_preview || "";
|
||||
const rawHex = vdesHexPreview(msg.raw_bytes);
|
||||
row.dataset.filterText = [
|
||||
title,
|
||||
label,
|
||||
labelText,
|
||||
info,
|
||||
srcText,
|
||||
dstText,
|
||||
sessionText,
|
||||
asmText,
|
||||
countText,
|
||||
ackText,
|
||||
cqiText,
|
||||
previewText,
|
||||
linkText,
|
||||
syncText,
|
||||
phaseText,
|
||||
fecText,
|
||||
rawHex,
|
||||
msg.message_type,
|
||||
msg.bit_len,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
row.innerHTML =
|
||||
`<div class="vdes-row-head">` +
|
||||
`<span class="vdes-time">${ts}</span>` +
|
||||
`<span class="vdes-call">${escapeMapHtml(title)}</span>` +
|
||||
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` +
|
||||
(labelText ? `<span class="vdes-badge">${escapeMapHtml(labelText)}</span>` : "") +
|
||||
(linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") +
|
||||
(srcText ? `<span class="vdes-badge">${escapeMapHtml(srcText)}</span>` : "") +
|
||||
(dstText ? `<span class="vdes-badge">${escapeMapHtml(dstText)}</span>` : "") +
|
||||
(syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") +
|
||||
(phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") +
|
||||
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` +
|
||||
`</div>` +
|
||||
`<div class="vdes-row-meta">` +
|
||||
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` +
|
||||
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` +
|
||||
(sessionText ? `<span>${escapeMapHtml(sessionText)}</span>` : "") +
|
||||
(asmText ? `<span>${escapeMapHtml(asmText)}</span>` : "") +
|
||||
(countText ? `<span>${escapeMapHtml(countText)}</span>` : "") +
|
||||
(ackText ? `<span>${escapeMapHtml(ackText)}</span>` : "") +
|
||||
(cqiText ? `<span>${escapeMapHtml(cqiText)}</span>` : "") +
|
||||
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
|
||||
(fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") +
|
||||
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
|
||||
`</div>` +
|
||||
`<div class="vdes-row-detail">` +
|
||||
(previewText ? `<span>${escapeMapHtml(previewText)}</span>` : "") +
|
||||
(previewText ? `<span>·</span>` : "") +
|
||||
`<span class="vdes-raw">${escapeMapHtml(rawHex)}</span>` +
|
||||
`</div>`;
|
||||
applyVdesFilterToRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function updateVdesBar() {
|
||||
if (!vdesBarOverlay) return;
|
||||
updateVdesSummary();
|
||||
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||
const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6);
|
||||
if (!isVdes || messages.length === 0) {
|
||||
vdesBarOverlay.style.display = "none";
|
||||
vdesBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
||||
for (const msg of messages) {
|
||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||
const label = escapeMapHtml(msg.callsign || "VDES");
|
||||
const title = escapeMapHtml(msg.vessel_name || "Burst");
|
||||
const detail = [
|
||||
`${msg.bit_len || 0} bits`,
|
||||
msg.message_label ? escapeMapHtml(msg.message_label) : null,
|
||||
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
|
||||
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
|
||||
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
|
||||
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
|
||||
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
|
||||
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
|
||||
msg.destination ? escapeMapHtml(msg.destination) : null,
|
||||
escapeMapHtml(vdesAgeText(msg._tsMs)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
|
||||
}
|
||||
vdesBarOverlay.innerHTML = html;
|
||||
vdesBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateVdesBar = updateVdesBar;
|
||||
window.clearVdesBar = function() {
|
||||
window.resetVdesHistoryView();
|
||||
};
|
||||
|
||||
window.resetVdesHistoryView = function() {
|
||||
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||
vdesMessageHistory = [];
|
||||
updateVdesBar();
|
||||
renderVdesHistory();
|
||||
};
|
||||
|
||||
function renderVdesHistory() {
|
||||
pruneVdesMessageHistory();
|
||||
if (!vdesMessagesEl) {
|
||||
updateVdesSummary();
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < vdesMessageHistory.length; i += 1) {
|
||||
fragment.appendChild(renderVdesRow(vdesMessageHistory[i]));
|
||||
}
|
||||
vdesMessagesEl.replaceChildren(fragment);
|
||||
updateVdesSummary();
|
||||
}
|
||||
|
||||
function addVdesMessage(msg) {
|
||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
msg._tsMs = tsMs;
|
||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
vdesMessageHistory.unshift(msg);
|
||||
pruneVdesMessageHistory();
|
||||
scheduleVdesBarUpdate();
|
||||
scheduleVdesHistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerVdesMessage(msg) {
|
||||
return {
|
||||
rig_id: msg.rig_id || null,
|
||||
message_type: msg.message_type,
|
||||
bit_len: msg.bit_len,
|
||||
raw_bytes: msg.raw_bytes,
|
||||
lat: msg.lat,
|
||||
lon: msg.lon,
|
||||
vessel_name: msg.vessel_name,
|
||||
callsign: msg.callsign,
|
||||
destination: msg.destination,
|
||||
message_label: msg.message_label,
|
||||
session_id: msg.session_id,
|
||||
source_id: msg.source_id,
|
||||
destination_id: msg.destination_id,
|
||||
data_count: msg.data_count,
|
||||
asm_identifier: msg.asm_identifier,
|
||||
ack_nack_mask: msg.ack_nack_mask,
|
||||
channel_quality: msg.channel_quality,
|
||||
payload_preview: msg.payload_preview,
|
||||
link_id: msg.link_id,
|
||||
sync_score: msg.sync_score,
|
||||
sync_errors: msg.sync_errors,
|
||||
phase_rotation: msg.phase_rotation,
|
||||
fec_state: msg.fec_state,
|
||||
ts_ms: msg.ts_ms,
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerVdesBatch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||
window.vdesMapAddPoint(next);
|
||||
}
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
vdesMessageHistory = normalized.concat(vdesMessageHistory);
|
||||
pruneVdesMessageHistory();
|
||||
scheduleVdesBarUpdate();
|
||||
scheduleVdesHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreVdesHistory = function(messages) {
|
||||
window.onServerVdesBatch(messages);
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all VDES decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_vdes_decode");
|
||||
window.resetVdesHistoryView();
|
||||
} catch (e) {
|
||||
console.error("VDES history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (vdesFilterInput) {
|
||||
vdesFilterInput.addEventListener("input", () => {
|
||||
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
||||
renderVdesHistory();
|
||||
});
|
||||
}
|
||||
|
||||
window.onServerVdes = function(msg) {
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
addVdesMessage(next);
|
||||
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||
window.vdesMapAddPoint(next);
|
||||
}
|
||||
};
|
||||
|
||||
window.pruneVdesHistoryView = function() {
|
||||
pruneVdesMessageHistory();
|
||||
updateVdesBar();
|
||||
renderVdesHistory();
|
||||
};
|
||||
|
||||
updateVdesSummary();
|
||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes");
|
||||
@@ -0,0 +1,386 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wefax.js — WEFAX decoder plugin for trx-frontend-http
|
||||
// Live view: decoder state, live canvas, latest image card
|
||||
// History view: filterable table of all decoded images
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ── DOM references (cached once) ───────────────────────────────────
|
||||
var wefaxDom = {
|
||||
status: document.getElementById('wefax-status'),
|
||||
liveView: document.getElementById('wefax-live-view'),
|
||||
historyView: document.getElementById('wefax-history-view'),
|
||||
liveContainer: document.getElementById('wefax-live-container'),
|
||||
liveInfo: document.getElementById('wefax-live-info'),
|
||||
liveCanvas: document.getElementById('wefax-live-canvas'),
|
||||
liveLatest: document.getElementById('wefax-live-latest'),
|
||||
historyList: document.getElementById('wefax-history-list'),
|
||||
historyCount: document.getElementById('wefax-history-count'),
|
||||
filterInput: document.getElementById('wefax-filter'),
|
||||
sortSelect: document.getElementById('wefax-sort'),
|
||||
toggleBtn: document.getElementById('wefax-decode-toggle-btn'),
|
||||
clearBtn: document.getElementById('wefax-clear-btn'),
|
||||
viewLiveBtn: document.getElementById('wefax-view-live'),
|
||||
viewHistoryBtn: document.getElementById('wefax-view-history'),
|
||||
};
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────
|
||||
var wefaxImageHistory = [];
|
||||
var WEFAX_MAX_IMAGES = 100;
|
||||
var wefaxLiveCtx = null;
|
||||
var wefaxLiveLineCount = 0;
|
||||
var wefaxLivePixelsPerLine = 1809;
|
||||
var wefaxActiveView = 'live';
|
||||
var wefaxFilterText = '';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
function currentWefaxHistoryRetentionMs() {
|
||||
return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneWefaxHistory() {
|
||||
var cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
||||
wefaxImageHistory = wefaxImageHistory.filter(function (m) { return (m._tsMs || 0) > cutoff; });
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function scheduleWefaxUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === 'function') {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
// ── View switching ──────────────────────────────────────────────────
|
||||
function switchWefaxView(view) {
|
||||
wefaxActiveView = view;
|
||||
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === 'live' ? '' : 'none';
|
||||
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === 'history' ? '' : 'none';
|
||||
|
||||
[wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function (btn) {
|
||||
if (btn) btn.classList.remove('sat-view-active');
|
||||
});
|
||||
if (view === 'live' && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add('sat-view-active');
|
||||
if (view === 'history' && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add('sat-view-active');
|
||||
|
||||
if (view === 'history') renderWefaxHistoryTable();
|
||||
}
|
||||
|
||||
if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener('click', function () { switchWefaxView('live'); });
|
||||
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener('click', function () { switchWefaxView('history'); });
|
||||
|
||||
// ── Live canvas rendering ───────────────────────────────────────────
|
||||
function resetLiveCanvas(pixelsPerLine) {
|
||||
wefaxLivePixelsPerLine = pixelsPerLine;
|
||||
wefaxLiveLineCount = 0;
|
||||
wefaxDom.liveCanvas.width = pixelsPerLine;
|
||||
wefaxDom.liveCanvas.height = 800;
|
||||
wefaxLiveCtx = wefaxDom.liveCanvas.getContext('2d');
|
||||
wefaxLiveCtx.fillStyle = '#000';
|
||||
wefaxLiveCtx.fillRect(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = '';
|
||||
}
|
||||
|
||||
function paintLine(lineBytes) {
|
||||
if (!wefaxLiveCtx) return;
|
||||
var y = wefaxLiveLineCount;
|
||||
|
||||
if (y >= wefaxDom.liveCanvas.height) {
|
||||
var old = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||
wefaxDom.liveCanvas.height *= 2;
|
||||
wefaxLiveCtx.putImageData(old, 0, 0);
|
||||
}
|
||||
|
||||
var w = wefaxLivePixelsPerLine;
|
||||
var imgData = wefaxLiveCtx.createImageData(w, 1);
|
||||
var d = imgData.data;
|
||||
for (var x = 0; x < w; x++) {
|
||||
var v = x < lineBytes.length ? lineBytes[x] : 0;
|
||||
var i = x * 4;
|
||||
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
|
||||
}
|
||||
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||
wefaxLiveLineCount++;
|
||||
}
|
||||
|
||||
// ── Live view: latest image card ────────────────────────────────────
|
||||
function renderWefaxLatestCard() {
|
||||
if (!wefaxDom.liveLatest) return;
|
||||
if (wefaxImageHistory.length === 0) {
|
||||
wefaxDom.liveLatest.innerHTML =
|
||||
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var img = wefaxImageHistory[0];
|
||||
var ts = img._ts || '--';
|
||||
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||
var meta = [
|
||||
img.ioc + ' IOC',
|
||||
img.lpm + ' LPM',
|
||||
img.line_count + ' lines',
|
||||
date + ' ' + ts,
|
||||
].join(' \u00b7 ');
|
||||
|
||||
var imgSrc = img._dataUrl
|
||||
? img._dataUrl
|
||||
: img.path
|
||||
? '/images/' + escapeHtml(img.path.split('/').pop())
|
||||
: null;
|
||||
|
||||
var html = '<div class="sat-latest-card">';
|
||||
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
||||
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + '</div>';
|
||||
if (imgSrc) {
|
||||
html += '<a href="' + imgSrc + '" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">View full image</a>';
|
||||
}
|
||||
html += '</div>';
|
||||
wefaxDom.liveLatest.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── History view: table ─────────────────────────────────────────────
|
||||
function getWefaxFilteredHistory() {
|
||||
var items = wefaxImageHistory;
|
||||
|
||||
if (wefaxFilterText) {
|
||||
items = items.filter(function (i) {
|
||||
var haystack = [
|
||||
String(i.ioc || ''),
|
||||
String(i.lpm || ''),
|
||||
String(i.line_count || ''),
|
||||
].join(' ').toUpperCase();
|
||||
return haystack.indexOf(wefaxFilterText) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
var sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : 'newest';
|
||||
if (sortVal === 'oldest') items = items.slice().reverse();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderWefaxHistoryRow(img) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'sat-history-row';
|
||||
|
||||
var ts = img._ts || '--';
|
||||
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
|
||||
var ioc = img.ioc || '--';
|
||||
var lpm = img.lpm || '--';
|
||||
var lines = img.line_count || 0;
|
||||
|
||||
var imgSrc = img._dataUrl
|
||||
? img._dataUrl
|
||||
: img.path
|
||||
? '/images/' + escapeHtml(img.path.split('/').pop())
|
||||
: null;
|
||||
var link = imgSrc
|
||||
? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>'
|
||||
: '--';
|
||||
|
||||
row.innerHTML = [
|
||||
'<span>' + escapeHtml(date + ' ' + ts) + '</span>',
|
||||
'<span>' + escapeHtml(String(ioc)) + '</span>',
|
||||
'<span>' + escapeHtml(String(lpm)) + '</span>',
|
||||
'<span>' + lines + '</span>',
|
||||
'<span>' + link + '</span>',
|
||||
].join('');
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderWefaxHistoryTable() {
|
||||
if (!wefaxDom.historyList) return;
|
||||
pruneWefaxHistory();
|
||||
var items = getWefaxFilteredHistory();
|
||||
var fragment = document.createDocumentFragment();
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
fragment.appendChild(renderWefaxHistoryRow(items[i]));
|
||||
}
|
||||
wefaxDom.historyList.replaceChildren(fragment);
|
||||
|
||||
if (wefaxDom.historyCount) {
|
||||
var total = wefaxImageHistory.length;
|
||||
var shown = items.length;
|
||||
wefaxDom.historyCount.textContent =
|
||||
total === 0
|
||||
? 'No images yet'
|
||||
: shown === total
|
||||
? total + ' image' + (total === 1 ? '' : 's')
|
||||
: shown + ' of ' + total + ' images';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add image to history ────────────────────────────────────────────
|
||||
function addWefaxImage(msg) {
|
||||
var tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
msg._tsMs = tsMs;
|
||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
// Capture the live canvas as a data URI for thumbnails.
|
||||
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
|
||||
var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxLiveLineCount);
|
||||
wefaxDom.liveCanvas.height = wefaxLiveLineCount;
|
||||
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
||||
try { msg._dataUrl = wefaxDom.liveCanvas.toDataURL('image/png'); } catch (e) {}
|
||||
}
|
||||
|
||||
wefaxImageHistory.unshift(msg);
|
||||
if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
|
||||
wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
|
||||
}
|
||||
|
||||
scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
|
||||
if (wefaxActiveView === 'history') {
|
||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE event handlers (public API) ─────────────────────────────────
|
||||
window.onServerWefaxProgress = function (msg) {
|
||||
// State-only update (no image data): show decoder state in status.
|
||||
if (msg.state && !msg.line_data) {
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = msg.state;
|
||||
// Highlight active states, dim idle/scanning.
|
||||
wefaxDom.status.style.color = msg.state.indexOf('Idle') === 0 ? '' : 'var(--text-accent)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.line_count <= 1 || !wefaxLiveCtx) {
|
||||
resetLiveCanvas(msg.pixels_per_line || 1809);
|
||||
}
|
||||
|
||||
if (msg.line_data) {
|
||||
var binary = atob(msg.line_data);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
paintLine(bytes);
|
||||
}
|
||||
|
||||
if (wefaxDom.liveInfo) {
|
||||
wefaxDom.liveInfo.textContent =
|
||||
'Line ' + msg.line_count + ' \u00b7 ' + msg.ioc + ' IOC \u00b7 ' + msg.lpm + ' LPM';
|
||||
}
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = 'Receiving \u2014 line ' + msg.line_count;
|
||||
wefaxDom.status.style.color = 'var(--text-accent)';
|
||||
}
|
||||
};
|
||||
|
||||
window.onServerWefax = function (msg) {
|
||||
addWefaxImage(msg);
|
||||
|
||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = 'Complete \u2014 ' + msg.line_count + ' lines';
|
||||
wefaxDom.status.style.color = '';
|
||||
}
|
||||
};
|
||||
|
||||
window.restoreWefaxHistory = function (messages) {
|
||||
if (!messages || !messages.length) return;
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
var tsMs = Number.isFinite(messages[i].ts_ms) ? Number(messages[i].ts_ms) : Date.now();
|
||||
messages[i]._tsMs = tsMs;
|
||||
messages[i]._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
wefaxImageHistory = messages.concat(wefaxImageHistory);
|
||||
pruneWefaxHistory();
|
||||
scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
|
||||
if (wefaxActiveView === 'history') {
|
||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||
}
|
||||
};
|
||||
|
||||
window.pruneWefaxHistoryView = function () {
|
||||
pruneWefaxHistory();
|
||||
renderWefaxHistoryTable();
|
||||
renderWefaxLatestCard();
|
||||
};
|
||||
|
||||
window.resetWefaxHistoryView = function () {
|
||||
wefaxImageHistory = [];
|
||||
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = '';
|
||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||
wefaxLiveCtx = null;
|
||||
wefaxLiveLineCount = 0;
|
||||
renderWefaxLatestCard();
|
||||
renderWefaxHistoryTable();
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = 'Idle';
|
||||
wefaxDom.status.style.color = '';
|
||||
}
|
||||
};
|
||||
|
||||
// ── Filter / sort handlers ──────────────────────────────────────────
|
||||
if (wefaxDom.filterInput) {
|
||||
wefaxDom.filterInput.addEventListener('input', function () {
|
||||
wefaxFilterText = wefaxDom.filterInput.value.trim().toUpperCase();
|
||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||
});
|
||||
}
|
||||
if (wefaxDom.sortSelect) {
|
||||
wefaxDom.sortSelect.addEventListener('change', function () {
|
||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Toggle button sync ──────────────────────────────────────────────
|
||||
// Sync the Enable/Disable button from the SSE state update. This is
|
||||
// belt-and-suspenders alongside app.js _decoderToggles — guarantees the
|
||||
// WEFAX button always reflects the server state.
|
||||
window.syncWefaxToggle = function (enabled) {
|
||||
if (!wefaxDom.toggleBtn) return;
|
||||
wefaxDom.toggleBtn.dataset.enabled = enabled ? 'true' : 'false';
|
||||
wefaxDom.toggleBtn.textContent = enabled ? 'Disable WEFAX' : 'Enable WEFAX';
|
||||
wefaxDom.toggleBtn.style.borderColor = enabled ? '#00d17f' : '';
|
||||
wefaxDom.toggleBtn.style.color = enabled ? '#00d17f' : '';
|
||||
};
|
||||
|
||||
// ── Button handlers ─────────────────────────────────────────────────
|
||||
if (wefaxDom.toggleBtn) {
|
||||
wefaxDom.toggleBtn.addEventListener('click', async function () {
|
||||
try {
|
||||
if (window.takeSchedulerControlForDecoderDisable) {
|
||||
await window.takeSchedulerControlForDecoderDisable(wefaxDom.toggleBtn);
|
||||
}
|
||||
await postPath('/toggle_wefax_decode');
|
||||
} catch (e) {
|
||||
console.error('WEFAX toggle failed', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (wefaxDom.clearBtn) {
|
||||
wefaxDom.clearBtn.addEventListener('click', async function () {
|
||||
try {
|
||||
await postPath('/clear_wefax_decode');
|
||||
window.resetWefaxHistoryView();
|
||||
} catch (e) {
|
||||
console.error('WEFAX clear failed', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial render ──────────────────────────────────────────────────
|
||||
renderWefaxLatestCard();
|
||||
@@ -0,0 +1,292 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- WSPR Decoder Plugin (server-side decode) ---
|
||||
const wsprStatus = document.getElementById("wspr-status");
|
||||
const wsprPeriodEl = document.getElementById("wspr-period");
|
||||
const wsprMessagesEl = document.getElementById("wspr-messages");
|
||||
const wsprFilterInput = document.getElementById("wspr-filter");
|
||||
const WSPR_PERIOD_SECONDS = 120;
|
||||
let wsprFilterText = "";
|
||||
let wsprMessageHistory = [];
|
||||
|
||||
function currentWsprHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneWsprMessageHistory() {
|
||||
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
||||
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleWsprHistoryRender() {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
|
||||
return;
|
||||
}
|
||||
renderWsprHistory();
|
||||
}
|
||||
|
||||
function fmtWsprTime(tsMs) {
|
||||
if (!tsMs) return "--:--:--";
|
||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function updateWsprPeriodTimer() {
|
||||
if (!wsprPeriodEl) return;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS);
|
||||
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||
const ss = String(remaining % 60).padStart(2, "0");
|
||||
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
||||
}
|
||||
|
||||
updateWsprPeriodTimer();
|
||||
setInterval(updateWsprPeriodTimer, 500);
|
||||
|
||||
function renderWsprRow(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ft8-row";
|
||||
row.dataset.decoder = "wspr";
|
||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
|
||||
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
|
||||
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
|
||||
const message = (msg.message || "").toString();
|
||||
row.dataset.message = message.toUpperCase();
|
||||
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||
applyWsprFilterToRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderWsprHistory() {
|
||||
pruneWsprMessageHistory();
|
||||
if (!wsprMessagesEl) return;
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||
fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
|
||||
}
|
||||
wsprMessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addWsprMessage(msg) {
|
||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
wsprMessageHistory.unshift(msg);
|
||||
pruneWsprMessageHistory();
|
||||
scheduleWsprHistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerWsprMessage(msg) {
|
||||
const raw = (msg.message || "").toString();
|
||||
const grids = extractAllGrids(raw);
|
||||
const station = extractLikelyCallsign(raw);
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz)
|
||||
? (baseHz + Number(msg.freq_hz))
|
||||
: (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
|
||||
return {
|
||||
raw,
|
||||
grids,
|
||||
station,
|
||||
rfHz,
|
||||
history: {
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
dt_s: msg.dt_s,
|
||||
freq_hz: msg.freq_hz,
|
||||
message: raw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerWsprBatch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
wsprStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
freq_hz: next.rfHz,
|
||||
});
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
normalized.reverse();
|
||||
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
||||
pruneWsprMessageHistory();
|
||||
scheduleWsprHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreWsprHistory = function(messages) {
|
||||
window.onServerWsprBatch(messages);
|
||||
};
|
||||
|
||||
window.pruneWsprHistoryView = function() {
|
||||
pruneWsprMessageHistory();
|
||||
renderWsprHistory();
|
||||
};
|
||||
|
||||
function escapeWsprHtml(input) {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """);
|
||||
}
|
||||
|
||||
function renderWsprMessage(message) {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < message.length) {
|
||||
const ch = message[i];
|
||||
if (isAlphaNum(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < message.length && isAlphaNum(message[j])) j++;
|
||||
const token = message.slice(i, j);
|
||||
const grid = token.toUpperCase();
|
||||
if (isMaidenheadGridToken(grid)) {
|
||||
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||
} else {
|
||||
out += escapeWsprHtml(token);
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
out += escapeWsprHtml(ch);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractAllGrids(message) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
||||
for (const token of parts) {
|
||||
if (!token) continue;
|
||||
if (isMaidenheadGridToken(token) && !seen.has(token)) {
|
||||
seen.add(token);
|
||||
out.push(token);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractLikelyCallsign(message) {
|
||||
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
|
||||
for (const token of parts) {
|
||||
if (!token) continue;
|
||||
if (token.length < 3 || token.length > 12) continue;
|
||||
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
|
||||
if (isMaidenheadGridToken(token)) continue;
|
||||
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFtxFarewellToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||
}
|
||||
|
||||
function isMaidenheadGridToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
||||
}
|
||||
|
||||
function isAlphaNum(ch) {
|
||||
return /[A-Za-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
function activateWsprHistoryLocator(targetEl) {
|
||||
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
||||
if (!locatorEl) return false;
|
||||
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||
if (!grid) return false;
|
||||
if (typeof window.navigateToMapLocator === "function") {
|
||||
window.navigateToMapLocator(grid, "wspr");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyWsprFilterToRow(row) {
|
||||
if (!wsprFilterText) {
|
||||
row.style.display = "";
|
||||
return;
|
||||
}
|
||||
const message = row.dataset.message || "";
|
||||
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
||||
}
|
||||
|
||||
function applyWsprFilterToAll() {
|
||||
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
|
||||
rows.forEach((row) => applyWsprFilterToRow(row));
|
||||
}
|
||||
|
||||
window.resetWsprHistoryView = function() {
|
||||
wsprMessagesEl.innerHTML = "";
|
||||
wsprMessageHistory = [];
|
||||
renderWsprHistory();
|
||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
|
||||
};
|
||||
|
||||
if (wsprFilterInput) {
|
||||
wsprFilterInput.addEventListener("input", () => {
|
||||
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
||||
renderWsprHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (wsprMessagesEl) {
|
||||
wsprMessagesEl.addEventListener("click", (event) => {
|
||||
if (!activateWsprHistoryLocator(event.target)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
wsprMessagesEl.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (!activateWsprHistoryLocator(event.target)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
||||
wsprDecodeToggleBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
||||
await postPath("/toggle_wspr_decode");
|
||||
} catch (e) {
|
||||
console.error("WSPR toggle failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_wspr_decode");
|
||||
window.resetWsprHistoryView();
|
||||
} catch (e) {
|
||||
console.error("WSPR history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
window.onServerWspr = function(msg) {
|
||||
wsprStatus.textContent = "Receiving";
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
freq_hz: next.rfHz,
|
||||
});
|
||||
}
|
||||
addWsprMessage(next.history);
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// Spectrum screenshot module (loaded on demand when user triggers screenshot).
|
||||
// Communicates with app.js core via window.trx namespace.
|
||||
(function () {
|
||||
"use strict";
|
||||
const T = window.trx;
|
||||
|
||||
function isVisibleForSnapshot(el) {
|
||||
if (!el) return false;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||
const opacity = Number(style.opacity);
|
||||
if (Number.isFinite(opacity) && opacity <= 0) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
||||
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + w - radius, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
|
||||
ctx.lineTo(x + w, y + h - radius);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
|
||||
ctx.lineTo(x + radius, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
||||
if (!isVisibleForSnapshot(el)) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
const x = rect.left - rootRect.left;
|
||||
const y = rect.top - rootRect.top;
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const radius = parseFloat(style.borderTopLeftRadius) || 0;
|
||||
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
||||
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
||||
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
||||
|
||||
const bgAlpha = Math.min(bg[3], maxAlpha);
|
||||
if (bgAlpha > 0.01) {
|
||||
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
||||
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
|
||||
ctx.fill();
|
||||
}
|
||||
const borderAlpha = Math.min(border[3], maxAlpha);
|
||||
if (borderWidth > 0 && borderAlpha > 0.01) {
|
||||
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
|
||||
ctx.lineWidth = borderWidth;
|
||||
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
|
||||
ctx.stroke();
|
||||
}
|
||||
return { x, y, w, h, style };
|
||||
}
|
||||
|
||||
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||
const words = String(text || "").split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return;
|
||||
let line = "";
|
||||
let lineIdx = 0;
|
||||
for (let i = 0; i < words.length; i += 1) {
|
||||
const candidate = line ? `${line} ${words[i]}` : words[i];
|
||||
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
||||
line = candidate;
|
||||
continue;
|
||||
}
|
||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||
lineIdx += 1;
|
||||
if (lineIdx >= maxLines) return;
|
||||
line = words[i];
|
||||
}
|
||||
if (line && lineIdx < maxLines) {
|
||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
||||
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
||||
if (!chrome) return;
|
||||
const text = (fallbackText == null ? el.innerText : fallbackText) || "";
|
||||
const clean = text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim();
|
||||
if (!clean) return;
|
||||
const style = chrome.style;
|
||||
const fontSize = parseFloat(style.fontSize) || 12;
|
||||
const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25);
|
||||
const padX = 6;
|
||||
const padY = 4;
|
||||
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
||||
const maxLines = Math.max(1, Math.floor((chrome.h - padY * 2) / lineHeight));
|
||||
ctx.fillStyle = style.color || "#ffffff";
|
||||
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
|
||||
ctx.textBaseline = "top";
|
||||
const lines = clean.split(/\n+/);
|
||||
let lineCursor = 0;
|
||||
for (const line of lines) {
|
||||
if (lineCursor >= maxLines) break;
|
||||
drawWrappedText(
|
||||
ctx,
|
||||
line,
|
||||
chrome.x + padX,
|
||||
chrome.y + padY + lineCursor * lineHeight,
|
||||
maxWidth,
|
||||
lineHeight,
|
||||
maxLines - lineCursor,
|
||||
);
|
||||
lineCursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function drawAxisLabels(ctx, axisEl, rootRect) {
|
||||
if (!isVisibleForSnapshot(axisEl)) return;
|
||||
for (const node of axisEl.children) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
if (!(node.matches("span") || node.matches("button"))) continue;
|
||||
if (!isVisibleForSnapshot(node)) continue;
|
||||
const chrome = drawElementChrome(ctx, node, rootRect);
|
||||
const text = (node.textContent || "").trim();
|
||||
if (!chrome || !text) continue;
|
||||
const style = chrome.style;
|
||||
ctx.fillStyle = style.color || "#ffffff";
|
||||
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSpectrumSnapshotCanvas() {
|
||||
const rootEl = document.querySelector(".signal-visual-block");
|
||||
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||||
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
|
||||
return null;
|
||||
}
|
||||
for (const renderer of [T.overviewGl, T.spectrumGl, T.signalOverlayGl]) {
|
||||
const gl = renderer?.gl;
|
||||
if (!gl) continue;
|
||||
try {
|
||||
if (typeof gl.flush === "function") gl.flush();
|
||||
if (typeof gl.finish === "function") gl.finish();
|
||||
} catch (_) {
|
||||
// Ignore transient WebGL state errors and capture the last good frame.
|
||||
}
|
||||
}
|
||||
const rootRect = rootEl.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const out = document.createElement("canvas");
|
||||
out.width = Math.max(1, Math.round(rootRect.width * dpr));
|
||||
out.height = Math.max(1, Math.round(rootRect.height * dpr));
|
||||
const ctx = out.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
||||
|
||||
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
||||
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
||||
for (const canvas of canvases) {
|
||||
if (!canvas || !isVisibleForSnapshot(canvas)) continue;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.drawImage(
|
||||
canvas,
|
||||
rect.left - rootRect.left,
|
||||
rect.top - rootRect.top,
|
||||
rect.width,
|
||||
rect.height,
|
||||
);
|
||||
}
|
||||
|
||||
// Decoder overlays over the signal view.
|
||||
// Cap background alpha to avoid opaque blocks (backdrop-filter can't be
|
||||
// replicated on canvas, so frosted-glass overlays would otherwise obscure
|
||||
// the spectrum).
|
||||
const decoderOverlayIds = [
|
||||
"ais-bar-overlay",
|
||||
"vdes-bar-overlay",
|
||||
"ft8-bar-overlay",
|
||||
"aprs-bar-overlay",
|
||||
"rds-ps-overlay",
|
||||
];
|
||||
for (const id of decoderOverlayIds) {
|
||||
const overlayEl = document.getElementById(id);
|
||||
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
||||
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
||||
}
|
||||
|
||||
// Spectrum axis labels and bookmark chips (includes freq bar).
|
||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
||||
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
||||
drawAxisLabels(ctx, spectrumDbAxis, rootRect);
|
||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function clickCanvasDownload(href, fileName) {
|
||||
const a = document.createElement("a");
|
||||
a.href = href;
|
||||
a.download = fileName;
|
||||
a.rel = "noopener";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
requestAnimationFrame(() => a.remove());
|
||||
}
|
||||
|
||||
function saveCanvasAsPng(canvas, fileName) {
|
||||
if (!canvas) return Promise.resolve(false);
|
||||
if (typeof canvas.toBlob === "function") {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
clickCanvasDownload(url, fileName);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
resolve(true);
|
||||
}, "image/png");
|
||||
} catch (_) {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
||||
return Promise.resolve(true);
|
||||
} catch (_) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureSpectrumScreenshot() {
|
||||
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
||||
if (!snapshotCanvas) {
|
||||
T.showHint("Spectrum view not ready", 1300);
|
||||
return false;
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
||||
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// Register module API
|
||||
window.trx.screenshot = {
|
||||
captureSpectrumScreenshot,
|
||||
buildSpectrumSnapshotCanvas,
|
||||
saveCanvasAsPng,
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,478 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
/* ── Arctic style ─────────────────────────────────────────────────────── */
|
||||
[data-style="arctic"] {
|
||||
--bg: #242933;
|
||||
--card-bg: #2e3440;
|
||||
--input-bg: #242933;
|
||||
--border: #3b4252;
|
||||
--border-light: #4c566a;
|
||||
--text: #d8dee9;
|
||||
--text-muted: #8a9ab0;
|
||||
--text-heading: #eceff4;
|
||||
--btn-bg: #3b4252;
|
||||
--btn-border: #5e6f88;
|
||||
--accent-green: #88c0d0;
|
||||
--accent-yellow: #ebcb8b;
|
||||
--accent-red: #bf616a;
|
||||
--jog-hi: #434c5e;
|
||||
--jog-lo: #3b4252;
|
||||
--jog-shadow: rgba(0,0,0,0.40);
|
||||
--jog-inset: rgba(255,255,255,0.06);
|
||||
--audio-level-bg: #2e3440;
|
||||
--audio-level-border: #4c566a;
|
||||
--audio-level-fill-start: #88c0d0;
|
||||
--audio-level-fill-end: #ebcb8b;
|
||||
--filter-bg: #3b4252;
|
||||
--filter-fg: #d8dee9;
|
||||
--filter-border: #5e6f88;
|
||||
--wavelength-fg: #7a8ea8;
|
||||
--spectrum-bg: #1e2530;
|
||||
}
|
||||
[data-style="arctic"][data-theme="light"] {
|
||||
--bg: #e5e9f0;
|
||||
--card-bg: #eceff4;
|
||||
--input-bg: #d8dee9;
|
||||
--border: #c5ccd8;
|
||||
--border-light: #a8b2c0;
|
||||
--text: #2e3440;
|
||||
--text-muted: #4c566a;
|
||||
--text-heading: #2e3440;
|
||||
--btn-bg: #d8dee9;
|
||||
--btn-border: #8fa3b8;
|
||||
--accent-green: #5e81ac;
|
||||
--accent-yellow: #c07a22;
|
||||
--accent-red: #bf616a;
|
||||
--jog-hi: #d8dee9;
|
||||
--jog-lo: #c2cbd8;
|
||||
--jog-shadow: rgba(46,52,64,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.70);
|
||||
--audio-level-bg: #d0d6e0;
|
||||
--audio-level-border: #a8b2c0;
|
||||
--audio-level-fill-start: #5e81ac;
|
||||
--audio-level-fill-end: #c07a22;
|
||||
--filter-bg: #d8dee9;
|
||||
--filter-fg: #2e3440;
|
||||
--filter-border: #8fa3b8;
|
||||
--wavelength-fg: #5a6a80;
|
||||
--spectrum-bg: #dde1e9;
|
||||
}
|
||||
|
||||
/* ── Lime style ───────────────────────────────────────────────────────── */
|
||||
[data-style="lime"] {
|
||||
--bg: #1c1c17;
|
||||
--card-bg: #272822;
|
||||
--input-bg: #1c1c17;
|
||||
--border: #3e3d32;
|
||||
--border-light: #5c5c45;
|
||||
--text: #f8f8f2;
|
||||
--text-muted: #908980;
|
||||
--text-heading: #f8f8f2;
|
||||
--btn-bg: #3e3d32;
|
||||
--btn-border: #75715e;
|
||||
--accent-green: #a6e22e;
|
||||
--accent-yellow: #e6db74;
|
||||
--accent-red: #f92672;
|
||||
--jog-hi: #49483e;
|
||||
--jog-lo: #3e3d32;
|
||||
--jog-shadow: rgba(0,0,0,0.45);
|
||||
--jog-inset: rgba(255,255,255,0.05);
|
||||
--audio-level-bg: #272822;
|
||||
--audio-level-border: #5c5c45;
|
||||
--audio-level-fill-start: #a6e22e;
|
||||
--audio-level-fill-end: #e6db74;
|
||||
--filter-bg: #3e3d32;
|
||||
--filter-fg: #f8f8f2;
|
||||
--filter-border: #75715e;
|
||||
--wavelength-fg: #9c8f78;
|
||||
--spectrum-bg: #181815;
|
||||
}
|
||||
[data-style="lime"][data-theme="light"] {
|
||||
--bg: #f5f0e4;
|
||||
--card-bg: #fdf9f2;
|
||||
--input-bg: #ede8d8;
|
||||
--border: #d8d0bb;
|
||||
--border-light: #c0b89e;
|
||||
--text: #272822;
|
||||
--text-muted: #6e6a56;
|
||||
--text-heading: #272822;
|
||||
--btn-bg: #ede8d8;
|
||||
--btn-border: #b0a888;
|
||||
--accent-green: #5f8700;
|
||||
--accent-yellow: #9a7200;
|
||||
--accent-red: #c60052;
|
||||
--jog-hi: #ede8d8;
|
||||
--jog-lo: #ddd8c8;
|
||||
--jog-shadow: rgba(39,40,34,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.75);
|
||||
--audio-level-bg: #ede8d8;
|
||||
--audio-level-border: #c0b89e;
|
||||
--audio-level-fill-start: #5f8700;
|
||||
--audio-level-fill-end: #9a7200;
|
||||
--filter-bg: #ede8d8;
|
||||
--filter-fg: #272822;
|
||||
--filter-border: #b0a888;
|
||||
--wavelength-fg: #7a7260;
|
||||
--spectrum-bg: #ede8d8;
|
||||
}
|
||||
|
||||
/* ── Contrast style ───────────────────────────────────────────────────── */
|
||||
[data-style="contrast"] {
|
||||
--bg: #000000;
|
||||
--card-bg: #0a0a0a;
|
||||
--input-bg: #111111;
|
||||
--border: #333333;
|
||||
--border-light: #555555;
|
||||
--text: #ffffff;
|
||||
--text-muted: #bbbbbb;
|
||||
--text-heading: #ffffff;
|
||||
--btn-bg: #1a1a1a;
|
||||
--btn-border: #666666;
|
||||
--accent-green: #00ff88;
|
||||
--accent-yellow: #ffcc00;
|
||||
--accent-red: #ff3344;
|
||||
--jog-hi: #2a2a2a;
|
||||
--jog-lo: #1a1a1a;
|
||||
--jog-shadow: rgba(0,0,0,0.60);
|
||||
--jog-inset: rgba(255,255,255,0.08);
|
||||
--audio-level-bg: #111111;
|
||||
--audio-level-border: #555555;
|
||||
--audio-level-fill-start: #00ff88;
|
||||
--audio-level-fill-end: #ffcc00;
|
||||
--filter-bg: #1a1a1a;
|
||||
--filter-fg: #ffffff;
|
||||
--filter-border: #666666;
|
||||
--wavelength-fg: #aaaaaa;
|
||||
--spectrum-bg: #000000;
|
||||
}
|
||||
[data-style="contrast"][data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--card-bg: #f4f4f4;
|
||||
--input-bg: #e8e8e8;
|
||||
--border: #cccccc;
|
||||
--border-light: #999999;
|
||||
--text: #000000;
|
||||
--text-muted: #333333;
|
||||
--text-heading: #000000;
|
||||
--btn-bg: #e0e0e0;
|
||||
--btn-border: #777777;
|
||||
--accent-green: #005cc5;
|
||||
--accent-yellow: #cc5500;
|
||||
--accent-red: #cc0000;
|
||||
--jog-hi: #e0e0e0;
|
||||
--jog-lo: #cccccc;
|
||||
--jog-shadow: rgba(0,0,0,0.25);
|
||||
--jog-inset: rgba(255,255,255,0.80);
|
||||
--audio-level-bg: #e8e8e8;
|
||||
--audio-level-border: #999999;
|
||||
--audio-level-fill-start: #005cc5;
|
||||
--audio-level-fill-end: #cc5500;
|
||||
--filter-bg: #e8e8e8;
|
||||
--filter-fg: #000000;
|
||||
--filter-border: #999999;
|
||||
--wavelength-fg: #444444;
|
||||
--spectrum-bg: #f4f4f4;
|
||||
}
|
||||
|
||||
/* ── Neon Disco style ─────────────────────────────────────────────────── */
|
||||
[data-style="neon-disco"] {
|
||||
--bg: #0d0015;
|
||||
--card-bg: #180026;
|
||||
--input-bg: #100018;
|
||||
--border: #3d0060;
|
||||
--border-light: #7700bb;
|
||||
--text: #f5e0ff;
|
||||
--text-muted: #b070d8;
|
||||
--text-heading: #fce8ff;
|
||||
--btn-bg: #2a0042;
|
||||
--btn-border: #9900dd;
|
||||
--accent-green: #ff10e0;
|
||||
--accent-yellow: #39ff14;
|
||||
--accent-red: #ff1460;
|
||||
--jog-hi: #360058;
|
||||
--jog-lo: #280042;
|
||||
--jog-shadow: rgba(0,0,0,0.65);
|
||||
--jog-inset: rgba(255,16,224,0.08);
|
||||
--audio-level-bg: #180026;
|
||||
--audio-level-border: #7700bb;
|
||||
--audio-level-fill-start: #ff10e0;
|
||||
--audio-level-fill-end: #39ff14;
|
||||
--filter-bg: #2a0042;
|
||||
--filter-fg: #f5e0ff;
|
||||
--filter-border: #9900dd;
|
||||
--wavelength-fg: #9055b8;
|
||||
--spectrum-bg: #090010;
|
||||
}
|
||||
[data-style="neon-disco"][data-theme="light"] {
|
||||
--bg: #faeeff;
|
||||
--card-bg: #fff4ff;
|
||||
--input-bg: #f2e0ff;
|
||||
--border: #dda8f5;
|
||||
--border-light: #cc80e8;
|
||||
--text: #1a0030;
|
||||
--text-muted: #7a30a0;
|
||||
--text-heading: #1a0030;
|
||||
--btn-bg: #f0d8ff;
|
||||
--btn-border: #bb80dd;
|
||||
--accent-green: #cc00a8;
|
||||
--accent-yellow: #1f8800;
|
||||
--accent-red: #cc0044;
|
||||
--jog-hi: #f0d8ff;
|
||||
--jog-lo: #e2c8f5;
|
||||
--jog-shadow: rgba(60,0,100,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.72);
|
||||
--audio-level-bg: #f0d8ff;
|
||||
--audio-level-border: #cc80e8;
|
||||
--audio-level-fill-start: #cc00a8;
|
||||
--audio-level-fill-end: #1f8800;
|
||||
--filter-bg: #f0d8ff;
|
||||
--filter-fg: #1a0030;
|
||||
--filter-border: #bb80dd;
|
||||
--wavelength-fg: #7030a0;
|
||||
--spectrum-bg: #f0d8ff;
|
||||
}
|
||||
/* ── Donald style ─────────────────────────────────────────────────────── */
|
||||
[data-style="golden-rain"] {
|
||||
--bg: #100c06;
|
||||
--card-bg: #1a1209;
|
||||
--input-bg: #140f08;
|
||||
--border: #3f2d18;
|
||||
--border-light: #6d4e23;
|
||||
--text: #f3e4bf;
|
||||
--text-muted: #aa9062;
|
||||
--text-heading: #fff0ca;
|
||||
--btn-bg: #2a1c0d;
|
||||
--btn-border: #7c5928;
|
||||
--accent-green: #dfac48;
|
||||
--accent-yellow: #f4cd74;
|
||||
--accent-red: #cf7d32;
|
||||
--jog-hi: #392610;
|
||||
--jog-lo: #24170b;
|
||||
--jog-shadow: rgba(0,0,0,0.64);
|
||||
--jog-inset: rgba(255,219,138,0.06);
|
||||
--audio-level-bg: #1c130a;
|
||||
--audio-level-border: #6d4e23;
|
||||
--audio-level-fill-start: #dfac48;
|
||||
--audio-level-fill-end: #f4cd74;
|
||||
--filter-bg: #2b1d0f;
|
||||
--filter-fg: #f3e4bf;
|
||||
--filter-border: #7c5928;
|
||||
--wavelength-fg: #ab8b52;
|
||||
--spectrum-bg: #120d07;
|
||||
}
|
||||
[data-style="golden-rain"][data-theme="light"] {
|
||||
--bg: #f7efdd;
|
||||
--card-bg: #fff9ec;
|
||||
--input-bg: #f0e3c6;
|
||||
--border: #d4bc8a;
|
||||
--border-light: #b99243;
|
||||
--text: #3f2c10;
|
||||
--text-muted: #7f6640;
|
||||
--text-heading: #3a2609;
|
||||
--btn-bg: #f0e3c6;
|
||||
--btn-border: #b99243;
|
||||
--accent-green: #a96d00;
|
||||
--accent-yellow: #c88a16;
|
||||
--accent-red: #b65316;
|
||||
--jog-hi: #f2e5c8;
|
||||
--jog-lo: #e3d1a8;
|
||||
--jog-shadow: rgba(82,55,14,0.16);
|
||||
--jog-inset: rgba(255,255,255,0.76);
|
||||
--audio-level-bg: #f0e3c6;
|
||||
--audio-level-border: #c5a15d;
|
||||
--audio-level-fill-start: #a96d00;
|
||||
--audio-level-fill-end: #d4a13a;
|
||||
--filter-bg: #f0e3c6;
|
||||
--filter-fg: #3f2c10;
|
||||
--filter-border: #b99243;
|
||||
--wavelength-fg: #87663a;
|
||||
--spectrum-bg: #f5ecd9;
|
||||
}
|
||||
|
||||
/* ── Amber style ──────────────────────────────────────────────────────── */
|
||||
[data-style="amber"] {
|
||||
--bg: #120706;
|
||||
--card-bg: #1b0c0a;
|
||||
--input-bg: #180907;
|
||||
--border: #4c1a12;
|
||||
--border-light: #7a2e1a;
|
||||
--text: #ffe7d2;
|
||||
--text-muted: #c78361;
|
||||
--text-heading: #fff3e7;
|
||||
--btn-bg: #2c110d;
|
||||
--btn-border: #8f3a20;
|
||||
--accent-green: #ff6f1f;
|
||||
--accent-yellow: #ffb347;
|
||||
--accent-red: #ff4a24;
|
||||
--jog-hi: #381510;
|
||||
--jog-lo: #24100c;
|
||||
--jog-shadow: rgba(0,0,0,0.62);
|
||||
--jog-inset: rgba(255,164,76,0.07);
|
||||
--audio-level-bg: #1f0d0a;
|
||||
--audio-level-border: #7a2e1a;
|
||||
--audio-level-fill-start: #ff4a24;
|
||||
--audio-level-fill-end: #ffb347;
|
||||
--filter-bg: #2b120d;
|
||||
--filter-fg: #ffe7d2;
|
||||
--filter-border: #8f3a20;
|
||||
--wavelength-fg: #d38d6a;
|
||||
--spectrum-bg: #140907;
|
||||
}
|
||||
[data-style="amber"][data-theme="light"] {
|
||||
--bg: #fff3ea;
|
||||
--card-bg: #fff7f0;
|
||||
--input-bg: #ffe9da;
|
||||
--border: #efc7b1;
|
||||
--border-light: #d9a487;
|
||||
--text: #42180d;
|
||||
--text-muted: #8a4b31;
|
||||
--text-heading: #2f120a;
|
||||
--btn-bg: #ffe2cf;
|
||||
--btn-border: #cc8563;
|
||||
--accent-green: #d24c12;
|
||||
--accent-yellow: #d88400;
|
||||
--accent-red: #c53114;
|
||||
--jog-hi: #ffe2cf;
|
||||
--jog-lo: #ffd5bc;
|
||||
--jog-shadow: rgba(108,44,15,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.72);
|
||||
--audio-level-bg: #ffe7d7;
|
||||
--audio-level-border: #d9a487;
|
||||
--audio-level-fill-start: #c53114;
|
||||
--audio-level-fill-end: #d88400;
|
||||
--filter-bg: #ffe2cf;
|
||||
--filter-fg: #42180d;
|
||||
--filter-border: #cc8563;
|
||||
--wavelength-fg: #9a5a3a;
|
||||
--spectrum-bg: #fff0e4;
|
||||
}
|
||||
|
||||
/* ── Fire style ───────────────────────────────────────────────────────── */
|
||||
[data-style="fire"] {
|
||||
--bg: #140406;
|
||||
--card-bg: #1d0708;
|
||||
--input-bg: #1a0607;
|
||||
--border: #551015;
|
||||
--border-light: #8f1f26;
|
||||
--text: #ffe6df;
|
||||
--text-muted: #cf8d82;
|
||||
--text-heading: #fff4ef;
|
||||
--btn-bg: #2d0c0d;
|
||||
--btn-border: #9d262b;
|
||||
--accent-green: #d13a32;
|
||||
--accent-yellow: #ff6a3d;
|
||||
--accent-red: #c10f1f;
|
||||
--jog-hi: #390f11;
|
||||
--jog-lo: #25090b;
|
||||
--jog-shadow: rgba(0,0,0,0.64);
|
||||
--jog-inset: rgba(255,120,100,0.06);
|
||||
--audio-level-bg: #22090b;
|
||||
--audio-level-border: #8f1f26;
|
||||
--audio-level-fill-start: #c10f1f;
|
||||
--audio-level-fill-end: #ff6a3d;
|
||||
--filter-bg: #2d0c0d;
|
||||
--filter-fg: #ffe6df;
|
||||
--filter-border: #9d262b;
|
||||
--wavelength-fg: #d78d78;
|
||||
--spectrum-bg: #150508;
|
||||
}
|
||||
[data-style="fire"][data-theme="light"] {
|
||||
--bg: #fdf0ea;
|
||||
--card-bg: #fff6f1;
|
||||
--input-bg: #ffe5db;
|
||||
--border: #e9b8aa;
|
||||
--border-light: #d27c66;
|
||||
--text: #4a110d;
|
||||
--text-muted: #8a493f;
|
||||
--text-heading: #340b08;
|
||||
--btn-bg: #ffd9cc;
|
||||
--btn-border: #c85b47;
|
||||
--accent-green: #ba2d24;
|
||||
--accent-yellow: #d95518;
|
||||
--accent-red: #a80f1c;
|
||||
--jog-hi: #ffd9cc;
|
||||
--jog-lo: #ffcab9;
|
||||
--jog-shadow: rgba(110,35,20,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.74);
|
||||
--audio-level-bg: #ffe2d7;
|
||||
--audio-level-border: #d27c66;
|
||||
--audio-level-fill-start: #a80f1c;
|
||||
--audio-level-fill-end: #d95518;
|
||||
--filter-bg: #ffd9cc;
|
||||
--filter-fg: #4a110d;
|
||||
--filter-border: #c85b47;
|
||||
--wavelength-fg: #9d5547;
|
||||
--spectrum-bg: #ffede5;
|
||||
}
|
||||
|
||||
/* ── Phosphor style ───────────────────────────────────────────────────── */
|
||||
/* Classic green-phosphor CRT terminal aesthetic */
|
||||
[data-style="phosphor"] {
|
||||
--bg: #030a03;
|
||||
--card-bg: #060e06;
|
||||
--input-bg: #030a03;
|
||||
--border: #0f2e0f;
|
||||
--border-light: #1a4a1a;
|
||||
--text: #a8e6a8;
|
||||
--text-muted: #5a9a5a;
|
||||
--text-heading: #c8f0c8;
|
||||
--btn-bg: #0a1e0a;
|
||||
--btn-border: #1e4a1e;
|
||||
--accent-green: #39ff14;
|
||||
--accent-yellow: #b8f060;
|
||||
--accent-red: #ff4444;
|
||||
--jog-hi: #0e2a0e;
|
||||
--jog-lo: #081808;
|
||||
--jog-shadow: rgba(0,0,0,0.65);
|
||||
--jog-inset: rgba(57,255,20,0.07);
|
||||
--audio-level-bg: #060e06;
|
||||
--audio-level-border: #1a4a1a;
|
||||
--audio-level-fill-start: #39ff14;
|
||||
--audio-level-fill-end: #b8f060;
|
||||
--filter-bg: #0a1e0a;
|
||||
--filter-fg: #a8e6a8;
|
||||
--filter-border: #1e4a1e;
|
||||
--wavelength-fg: #4a8a4a;
|
||||
--spectrum-bg: #010501;
|
||||
}
|
||||
[data-style="phosphor"] #freq {
|
||||
color: #39ff14;
|
||||
text-shadow: 0 0 8px rgba(57,255,20,0.55), 0 0 20px rgba(57,255,20,0.2);
|
||||
}
|
||||
[data-style="phosphor"] .signal-bar-fill,
|
||||
[data-style="phosphor"] .meter-fill {
|
||||
background: linear-gradient(90deg, #39ff14, #b8f060);
|
||||
box-shadow: 0 0 6px rgba(57,255,20,0.45);
|
||||
}
|
||||
[data-style="phosphor"][data-theme="light"] {
|
||||
--bg: #e8f5e8;
|
||||
--card-bg: #f0faf0;
|
||||
--input-bg: #dff0df;
|
||||
--border: #b0d8b0;
|
||||
--border-light: #80c080;
|
||||
--text: #0a2a0a;
|
||||
--text-muted: #2a6a2a;
|
||||
--text-heading: #062006;
|
||||
--btn-bg: #d0ebd0;
|
||||
--btn-border: #4a8a4a;
|
||||
--accent-green: #1a7a1a;
|
||||
--accent-yellow: #4a8a00;
|
||||
--accent-red: #cc2222;
|
||||
--jog-hi: #d0ebd0;
|
||||
--jog-lo: #bcdabc;
|
||||
--jog-shadow: rgba(10,42,10,0.15);
|
||||
--jog-inset: rgba(255,255,255,0.72);
|
||||
--audio-level-bg: #d8edd8;
|
||||
--audio-level-border: #80c080;
|
||||
--audio-level-fill-start: #1a7a1a;
|
||||
--audio-level-fill-end: #4a8a00;
|
||||
--filter-bg: #d0ebd0;
|
||||
--filter-fg: #0a2a0a;
|
||||
--filter-border: #4a8a4a;
|
||||
--wavelength-fg: #3a7a3a;
|
||||
--spectrum-bg: #e0f0e0;
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,535 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
(function initTrxWebGl(global) {
|
||||
"use strict";
|
||||
|
||||
const cssColorCache = new Map();
|
||||
let cssColorProbe = null;
|
||||
|
||||
function clearCssColorCache() {
|
||||
cssColorCache.clear();
|
||||
}
|
||||
|
||||
function ensureCssColorProbe() {
|
||||
if (cssColorProbe) return cssColorProbe;
|
||||
const el = document.createElement("span");
|
||||
el.style.position = "absolute";
|
||||
el.style.left = "-9999px";
|
||||
el.style.top = "-9999px";
|
||||
el.style.pointerEvents = "none";
|
||||
el.style.opacity = "0";
|
||||
document.body.appendChild(el);
|
||||
cssColorProbe = el;
|
||||
return cssColorProbe;
|
||||
}
|
||||
|
||||
function parseRgbString(value) {
|
||||
const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
|
||||
if (!m) return null;
|
||||
const parts = m[1].split(",").map((p) => p.trim());
|
||||
if (parts.length < 3) return null;
|
||||
const r = Number(parts[0]);
|
||||
const g = Number(parts[1]);
|
||||
const b = Number(parts[2]);
|
||||
const a = parts.length > 3 ? Number(parts[3]) : 1;
|
||||
if (![r, g, b, a].every(Number.isFinite)) return null;
|
||||
return [
|
||||
Math.max(0, Math.min(1, r / 255)),
|
||||
Math.max(0, Math.min(1, g / 255)),
|
||||
Math.max(0, Math.min(1, b / 255)),
|
||||
Math.max(0, Math.min(1, a)),
|
||||
];
|
||||
}
|
||||
|
||||
function parseHexColor(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
|
||||
let hex = raw.slice(1);
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
hex = hex.split("").map((ch) => ch + ch).join("");
|
||||
}
|
||||
if (!(hex.length === 6 || hex.length === 8)) return null;
|
||||
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return [r, g, b, a];
|
||||
}
|
||||
|
||||
function parseCssColor(value) {
|
||||
const key = String(value ?? "");
|
||||
if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
|
||||
|
||||
let parsed = parseHexColor(key) || parseRgbString(key);
|
||||
if (!parsed) {
|
||||
const probe = ensureCssColorProbe();
|
||||
probe.style.color = "";
|
||||
probe.style.color = key;
|
||||
const computed = getComputedStyle(probe).color;
|
||||
parsed = parseRgbString(computed) || [0, 0, 0, 1];
|
||||
}
|
||||
cssColorCache.set(key, parsed.slice());
|
||||
return parsed.slice();
|
||||
}
|
||||
|
||||
function hslToRgba(h, s, l, a = 1) {
|
||||
const hue = ((((Number(h) || 0) % 360) + 360) % 360) / 360;
|
||||
const sat = Math.max(0, Math.min(1, (Number(s) || 0) / 100));
|
||||
const lig = Math.max(0, Math.min(1, (Number(l) || 0) / 100));
|
||||
|
||||
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
|
||||
const p = 2 * lig - q;
|
||||
const hueToRgb = (t) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
|
||||
const g = sat === 0 ? lig : hueToRgb(hue);
|
||||
const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
|
||||
return [r, g, b, Math.max(0, Math.min(1, Number(a)))];
|
||||
}
|
||||
|
||||
function normalizeColor(input, alphaMul = 1) {
|
||||
let rgba;
|
||||
if (Array.isArray(input)) {
|
||||
const arr = input.map((v) => Number(v));
|
||||
if (arr.length >= 4) {
|
||||
rgba = [arr[0], arr[1], arr[2], arr[3]];
|
||||
} else {
|
||||
rgba = [0, 0, 0, 1];
|
||||
}
|
||||
} else if (typeof input === "string") {
|
||||
rgba = parseCssColor(input);
|
||||
} else if (input && typeof input === "object") {
|
||||
rgba = [
|
||||
Number(input.r) || 0,
|
||||
Number(input.g) || 0,
|
||||
Number(input.b) || 0,
|
||||
Number(input.a ?? 1),
|
||||
];
|
||||
} else {
|
||||
rgba = [0, 0, 0, 1];
|
||||
}
|
||||
const out = [
|
||||
Math.max(0, Math.min(1, rgba[0])),
|
||||
Math.max(0, Math.min(1, rgba[1])),
|
||||
Math.max(0, Math.min(1, rgba[2])),
|
||||
Math.max(0, Math.min(1, rgba[3] * alphaMul)),
|
||||
];
|
||||
return out;
|
||||
}
|
||||
|
||||
function compileShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const log = gl.getShaderInfoLog(shader) || "shader compile error";
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(log);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl, vertexSrc, fragmentSrc) {
|
||||
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
|
||||
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
const log = gl.getProgramInfoLog(program) || "program link error";
|
||||
gl.deleteProgram(program);
|
||||
throw new Error(log);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
function pushColoredVertex(target, x, y, rgba) {
|
||||
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
}
|
||||
|
||||
function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (!(len > 0.0001)) return;
|
||||
const nx = (-dy / len) * halfW;
|
||||
const ny = (dx / len) * halfW;
|
||||
|
||||
const ax = x0 - nx, ay = y0 - ny;
|
||||
const bx = x0 + nx, by = y0 + ny;
|
||||
const cx = x1 + nx, cy = y1 + ny;
|
||||
const dx2 = x1 - nx, dy2 = y1 - ny;
|
||||
|
||||
pushColoredVertex(out, ax, ay, rgba);
|
||||
pushColoredVertex(out, bx, by, rgba);
|
||||
pushColoredVertex(out, cx, cy, rgba);
|
||||
|
||||
pushColoredVertex(out, ax, ay, rgba);
|
||||
pushColoredVertex(out, cx, cy, rgba);
|
||||
pushColoredVertex(out, dx2, dy2, rgba);
|
||||
}
|
||||
|
||||
class TrxWebGlRenderer {
|
||||
constructor(canvas, options = {}) {
|
||||
this.canvas = canvas;
|
||||
this.options = { alpha: true, premultipliedAlpha: false, ...options };
|
||||
this.gl =
|
||||
canvas?.getContext("webgl", this.options) ||
|
||||
canvas?.getContext("experimental-webgl", this.options) ||
|
||||
null;
|
||||
this.ready = !!this.gl;
|
||||
this.textures = new Map();
|
||||
// Reusable scratch buffers — avoids per-draw-call Float32Array allocation
|
||||
// and lets us use bufferSubData instead of bufferData (no GPU realloc).
|
||||
this._colorScratch = new Float32Array(4096 * 6); // grows as needed
|
||||
this._colorGpuSize = 0; // current GPU buffer size (floats)
|
||||
this._texScratch = new Float32Array(6 * 4); // fixed: 6 verts × (xy+uv)
|
||||
if (!this.ready) return;
|
||||
|
||||
const gl = this.gl;
|
||||
gl.disable(gl.DEPTH_TEST);
|
||||
gl.disable(gl.CULL_FACE);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
const colorVertexSrc =
|
||||
"attribute vec2 a_pos;\n" +
|
||||
"attribute vec4 a_color;\n" +
|
||||
"uniform vec2 u_resolution;\n" +
|
||||
"varying vec4 v_color;\n" +
|
||||
"void main() {\n" +
|
||||
" vec2 zeroToOne = a_pos / u_resolution;\n" +
|
||||
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
|
||||
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
|
||||
" v_color = a_color;\n" +
|
||||
"}\n";
|
||||
const colorFragmentSrc =
|
||||
"precision mediump float;\n" +
|
||||
"varying vec4 v_color;\n" +
|
||||
"void main() {\n" +
|
||||
" gl_FragColor = v_color;\n" +
|
||||
"}\n";
|
||||
|
||||
const textureVertexSrc =
|
||||
"attribute vec2 a_pos;\n" +
|
||||
"attribute vec2 a_uv;\n" +
|
||||
"uniform vec2 u_resolution;\n" +
|
||||
"varying vec2 v_uv;\n" +
|
||||
"void main() {\n" +
|
||||
" vec2 zeroToOne = a_pos / u_resolution;\n" +
|
||||
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
|
||||
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
|
||||
" v_uv = a_uv;\n" +
|
||||
"}\n";
|
||||
const textureFragmentSrc =
|
||||
"precision mediump float;\n" +
|
||||
"varying vec2 v_uv;\n" +
|
||||
"uniform sampler2D u_tex;\n" +
|
||||
"uniform float u_alpha;\n" +
|
||||
"void main() {\n" +
|
||||
" vec4 c = texture2D(u_tex, v_uv);\n" +
|
||||
" gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n" +
|
||||
"}\n";
|
||||
|
||||
this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc);
|
||||
this.colorBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||||
this._colorGpuSize = this._colorScratch.length;
|
||||
this.colorLoc = {
|
||||
pos: gl.getAttribLocation(this.colorProgram, "a_pos"),
|
||||
color: gl.getAttribLocation(this.colorProgram, "a_color"),
|
||||
resolution: gl.getUniformLocation(this.colorProgram, "u_resolution"),
|
||||
};
|
||||
|
||||
this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc);
|
||||
this.textureBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW);
|
||||
this.textureLoc = {
|
||||
pos: gl.getAttribLocation(this.textureProgram, "a_pos"),
|
||||
uv: gl.getAttribLocation(this.textureProgram, "a_uv"),
|
||||
resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"),
|
||||
alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"),
|
||||
tex: gl.getUniformLocation(this.textureProgram, "u_tex"),
|
||||
};
|
||||
}
|
||||
|
||||
ensureSize(cssWidth, cssHeight, dpr = (window.devicePixelRatio || 1)) {
|
||||
if (!this.ready) return false;
|
||||
const nextW = Math.max(1, Math.round(cssWidth * dpr));
|
||||
const nextH = Math.max(1, Math.round(cssHeight * dpr));
|
||||
const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
|
||||
if (changed) {
|
||||
this.canvas.width = nextW;
|
||||
this.canvas.height = nextH;
|
||||
}
|
||||
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||
return changed;
|
||||
}
|
||||
|
||||
clear(color) {
|
||||
if (!this.ready) return;
|
||||
const gl = this.gl;
|
||||
const rgba = normalizeColor(color);
|
||||
gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
}
|
||||
|
||||
drawTriangles(vertices) {
|
||||
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawTriangleStrip(vertices) {
|
||||
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
|
||||
}
|
||||
|
||||
_drawColorGeometry(vertices, mode) {
|
||||
if (!this.ready || !vertices || vertices.length === 0) return;
|
||||
const gl = this.gl;
|
||||
const count = vertices.length;
|
||||
|
||||
// Grow scratch buffer if needed (doubles each time to amortise copies).
|
||||
if (count > this._colorScratch.length) {
|
||||
let newLen = this._colorScratch.length;
|
||||
while (newLen < count) newLen *= 2;
|
||||
this._colorScratch = new Float32Array(newLen);
|
||||
}
|
||||
|
||||
// Copy into scratch (set() is a fast typed memcpy; avoids new allocation).
|
||||
this._colorScratch.set(vertices);
|
||||
const view = this._colorScratch.subarray(0, count);
|
||||
|
||||
gl.useProgram(this.colorProgram);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||||
|
||||
// Only reallocate the GPU buffer when it is too small; otherwise use
|
||||
// bufferSubData which avoids a GPU reallocation (Safari is sensitive to this).
|
||||
if (count > this._colorGpuSize) {
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||||
this._colorGpuSize = this._colorScratch.length;
|
||||
} else {
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, view);
|
||||
}
|
||||
|
||||
gl.enableVertexAttribArray(this.colorLoc.pos);
|
||||
gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0);
|
||||
gl.enableVertexAttribArray(this.colorLoc.color);
|
||||
gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8);
|
||||
gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height);
|
||||
gl.drawArrays(mode, 0, count / 6);
|
||||
}
|
||||
|
||||
fillRect(x, y, w, h, color) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const rgba = normalizeColor(color);
|
||||
const v = [];
|
||||
pushColoredVertex(v, x, y, rgba);
|
||||
pushColoredVertex(v, x + w, y, rgba);
|
||||
pushColoredVertex(v, x + w, y + h, rgba);
|
||||
pushColoredVertex(v, x, y, rgba);
|
||||
pushColoredVertex(v, x + w, y + h, rgba);
|
||||
pushColoredVertex(v, x, y + h, rgba);
|
||||
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const tl = normalizeColor(colorTL);
|
||||
const tr = normalizeColor(colorTR);
|
||||
const br = normalizeColor(colorBR);
|
||||
const bl = normalizeColor(colorBL);
|
||||
const v = [];
|
||||
pushColoredVertex(v, x, y, tl);
|
||||
pushColoredVertex(v, x + w, y, tr);
|
||||
pushColoredVertex(v, x + w, y + h, br);
|
||||
pushColoredVertex(v, x, y, tl);
|
||||
pushColoredVertex(v, x + w, y + h, br);
|
||||
pushColoredVertex(v, x, y + h, bl);
|
||||
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawPolyline(points, color, width = 1) {
|
||||
if (!Array.isArray(points) || points.length < 4) return;
|
||||
const rgba = normalizeColor(color);
|
||||
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||
const verts = [];
|
||||
for (let i = 0; i < points.length - 2; i += 2) {
|
||||
segmentToQuadVertices(
|
||||
verts,
|
||||
points[i], points[i + 1],
|
||||
points[i + 2], points[i + 3],
|
||||
halfW,
|
||||
rgba,
|
||||
);
|
||||
}
|
||||
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawSegments(segments, color, width = 1) {
|
||||
if (!Array.isArray(segments) || segments.length < 4) return;
|
||||
const rgba = normalizeColor(color);
|
||||
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||
const verts = [];
|
||||
for (let i = 0; i < segments.length - 3; i += 4) {
|
||||
segmentToQuadVertices(
|
||||
verts,
|
||||
segments[i], segments[i + 1],
|
||||
segments[i + 2], segments[i + 3],
|
||||
halfW,
|
||||
rgba,
|
||||
);
|
||||
}
|
||||
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawFilledArea(points, baselineY, color) {
|
||||
if (!Array.isArray(points) || points.length < 4) return;
|
||||
const rgba = normalizeColor(color);
|
||||
const verts = [];
|
||||
for (let i = 0; i < points.length; i += 2) {
|
||||
pushColoredVertex(verts, points[i], baselineY, rgba);
|
||||
pushColoredVertex(verts, points[i], points[i + 1], rgba);
|
||||
}
|
||||
this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
|
||||
}
|
||||
|
||||
drawPoints(points, size, color) {
|
||||
if (!Array.isArray(points) || points.length < 2) return;
|
||||
const radius = Math.max(1, Number(size) || 1);
|
||||
const rgba = normalizeColor(color);
|
||||
const verts = [];
|
||||
for (let i = 0; i < points.length; i += 2) {
|
||||
const x = points[i] - radius;
|
||||
const y = points[i + 1] - radius;
|
||||
const w = radius * 2;
|
||||
const h = radius * 2;
|
||||
pushColoredVertex(verts, x, y, rgba);
|
||||
pushColoredVertex(verts, x + w, y, rgba);
|
||||
pushColoredVertex(verts, x + w, y + h, rgba);
|
||||
pushColoredVertex(verts, x, y, rgba);
|
||||
pushColoredVertex(verts, x + w, y + h, rgba);
|
||||
pushColoredVertex(verts, x, y + h, rgba);
|
||||
}
|
||||
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) {
|
||||
const dash = Math.max(1, Number(dashLen) || 1);
|
||||
const gap = Math.max(1, Number(gapLen) || 1);
|
||||
const top = Math.min(y0, y1);
|
||||
const bottom = Math.max(y0, y1);
|
||||
const segments = [];
|
||||
for (let y = top; y < bottom; y += dash + gap) {
|
||||
const segEnd = Math.min(bottom, y + dash);
|
||||
segments.push(x, y, x, segEnd);
|
||||
}
|
||||
this.drawSegments(segments, color, width);
|
||||
}
|
||||
|
||||
uploadRgbaTexture(name, width, height, data, filter = "linear") {
|
||||
if (!this.ready || !name || !data) return null;
|
||||
const gl = this.gl;
|
||||
let entry = this.textures.get(name);
|
||||
if (!entry) {
|
||||
const texture = gl.createTexture();
|
||||
entry = { texture, width: 0, height: 0 };
|
||||
this.textures.set(name, entry);
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
if (entry.width !== width || entry.height !== height) {
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
data,
|
||||
);
|
||||
entry.width = width;
|
||||
entry.height = height;
|
||||
} else {
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
data,
|
||||
);
|
||||
}
|
||||
return entry.texture;
|
||||
}
|
||||
|
||||
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
|
||||
if (!this.ready || !name || w <= 0 || h <= 0) return;
|
||||
const entry = this.textures.get(name);
|
||||
if (!entry) return;
|
||||
const gl = this.gl;
|
||||
const s = this._texScratch;
|
||||
const x2 = x + w, y2 = y + h;
|
||||
if (flipY) {
|
||||
s[0]=x; s[1]=y; s[2]=0; s[3]=1;
|
||||
s[4]=x2; s[5]=y; s[6]=1; s[7]=1;
|
||||
s[8]=x2; s[9]=y2; s[10]=1;s[11]=0;
|
||||
s[12]=x; s[13]=y; s[14]=0;s[15]=1;
|
||||
s[16]=x2;s[17]=y2;s[18]=1;s[19]=0;
|
||||
s[20]=x; s[21]=y2;s[22]=0;s[23]=0;
|
||||
} else {
|
||||
s[0]=x; s[1]=y; s[2]=0; s[3]=0;
|
||||
s[4]=x2; s[5]=y; s[6]=1; s[7]=0;
|
||||
s[8]=x2; s[9]=y2; s[10]=1;s[11]=1;
|
||||
s[12]=x; s[13]=y; s[14]=0;s[15]=0;
|
||||
s[16]=x2;s[17]=y2;s[18]=1;s[19]=1;
|
||||
s[20]=x; s[21]=y2;s[22]=0;s[23]=1;
|
||||
}
|
||||
gl.useProgram(this.textureProgram);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, s);
|
||||
gl.enableVertexAttribArray(this.textureLoc.pos);
|
||||
gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0);
|
||||
gl.enableVertexAttribArray(this.textureLoc.uv);
|
||||
gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
|
||||
gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height);
|
||||
gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, Number(alpha) || 0)));
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||
gl.uniform1i(this.textureLoc.tex, 0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
}
|
||||
|
||||
function createRenderer(canvas, options) {
|
||||
return new TrxWebGlRenderer(canvas, options);
|
||||
}
|
||||
|
||||
global.trxParseCssColor = parseCssColor;
|
||||
global.trxHslToRgba = hslToRgba;
|
||||
global.createTrxWebGlRenderer = createRenderer;
|
||||
global.trxClearCssColorCache = clearCssColorCache;
|
||||
})(window);
|
||||
Reference in New Issue
Block a user