Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Band Activity Help Content - Help registration for DX distance thresholds.
*
* Shows how DX scoring varies by band based on propagation characteristics.
* For example, 3,000 km is exceptional on 160m but routine on 20m.
*
* KEEP IN SYNC with FrequencyBand.java DxThresholds
* @see src/main/java/io/nextskip/common/model/FrequencyBand.java
*/

import React from 'react';
import { Radio } from 'lucide-react';
import { registerHelp } from '../../help/HelpRegistry';
import type { HelpDefinition } from '../../help/types';
import { bandDxThresholds } from '../../../utils/bandDxThresholds';

/**
* Band DX Thresholds Help Content
*/
function BandDxThresholdsHelpContent() {
return (
<div className="help-content">
<p>
Different bands have vastly different propagation characteristics. What counts as &ldquo;excellent DX&rdquo;
varies by band:
</p>

<ul>
<li>
<strong>160m</strong> &mdash; 3,000 km is exceptional (requires night skip)
</li>
<li>
<strong>20m</strong> &mdash; 15,000 km is needed for top score (workhorse DX band)
</li>
<li>
<strong>6m</strong> &mdash; 5,000 km is legendary F2 propagation
</li>
</ul>

<p>
<strong>Scoring:</strong> DX distance contributes 20% to the overall band activity score. Scores scale from 0
(no DX) to 100 (excellent threshold reached).
</p>

<details>
<summary>
<strong>Distance Thresholds by Band</strong>
</summary>
<table className="help-section__table" role="table" aria-label="Band DX distance thresholds">
<thead>
<tr>
<th scope="col">Band</th>
<th scope="col">Excellent</th>
<th scope="col">Good</th>
<th scope="col">Propagation</th>
</tr>
</thead>
<tbody>
{bandDxThresholds.map((threshold) => (
<tr key={threshold.band}>
<td>{threshold.band}</td>
<td>{threshold.excellentKm.toLocaleString()}+ km</td>
<td>{threshold.goodKm.toLocaleString()}+ km</td>
<td>{threshold.description}</td>
</tr>
))}
</tbody>
</table>
</details>

<p className="data-source">
Data source:{' '}
<a href="https://pskreporter.info/" target="_blank" rel="noopener noreferrer">
PSKReporter
</a>
</p>
</div>
);
}

// Register help definition
const bandDxThresholdsHelp: HelpDefinition = {
id: 'band-dx-thresholds',
title: 'DX Distance Scoring',
icon: <Radio size={16} />,
order: 25, // After band conditions (20), before activations (30)
Content: BandDxThresholdsHelpContent,
};

registerHelp(bandDxThresholdsHelp);
1 change: 1 addition & 0 deletions src/main/frontend/components/help/HelpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import '../cards/propagation/PropagationHelp';
import '../cards/activations/ActivationsHelp';
import '../cards/contests/ContestsHelp';
import '../cards/meteor-showers/MeteorShowersHelp';
import '../cards/band-activity/BandActivityHelp';

export function HelpModal({ isOpen, onClose }: HelpModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
Expand Down
57 changes: 57 additions & 0 deletions src/main/frontend/components/help/HelpSection.css
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,44 @@
padding: calc(var(--spacing-unit) * 2);
}

/* Table styles for data displays */
.help-section__table-wrapper {
overflow-x: auto;
margin: calc(var(--spacing-unit) * 2) 0;
}

.help-section__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}

.help-section__table th,
.help-section__table td {
padding: calc(var(--spacing-unit) * 1) calc(var(--spacing-unit) * 1.5);
text-align: left;
border-bottom: 1px solid var(--border-color);
}

.help-section__table th {
font-weight: 600;
color: var(--text-primary);
background: rgba(0, 0, 0, 0.02);
white-space: nowrap;
}

.help-section__table td {
color: var(--text-secondary);
}

.help-section__table tbody tr:hover {
background: rgba(0, 0, 0, 0.02);
}

.help-section__table tbody tr:last-child td {
border-bottom: none;
}

/* Data source attribution - subtle but accessible */
.help-section__content .data-source {
font-size: 0.75rem;
Expand Down Expand Up @@ -130,3 +168,22 @@
:root[data-theme='dark'] .help-content__definitions {
background: rgba(255, 255, 255, 0.03);
}

/* Dark mode table styles */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) .help-section__table th {
background: rgba(255, 255, 255, 0.03);
}

:root:not([data-theme='light']) .help-section__table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
}

:root[data-theme='dark'] .help-section__table th {
background: rgba(255, 255, 255, 0.03);
}

:root[data-theme='dark'] .help-section__table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
1 change: 1 addition & 0 deletions src/main/frontend/components/help/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type HelpSectionId =
| 'solar-indices'
| 'band-conditions'
| 'band-condition'
| 'band-dx-thresholds'
| 'pota-activations'
| 'sota-activations'
| 'contests'
Expand Down
70 changes: 70 additions & 0 deletions src/main/frontend/utils/bandDxThresholds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Band-specific DX distance thresholds for scoring and help text.
*
* Different bands have vastly different propagation characteristics:
* - 160m: 3,000 km is exceptional (requires night skip, low noise)
* - 20m: 15,000 km is needed for excellent score (workhorse DX band)
* - 6m: 5,000 km is legendary F2 propagation; 2,000 km sporadic-E is exciting
*
* KEEP IN SYNC with FrequencyBand.java DxThresholds
* @see src/main/java/io/nextskip/common/model/FrequencyBand.java
*/
export const bandDxThresholds = [
{ band: '160m', excellentKm: 3_000, goodKm: 1_500, moderateKm: 500, description: 'Difficult; night skip required' },
{ band: '80m', excellentKm: 5_000, goodKm: 2_500, moderateKm: 1_000, description: 'Regional; some DX at night' },
{
band: '60m',
excellentKm: 6_000,
goodKm: 3_000,
moderateKm: 1_500,
description: 'Secondary allocation; variable propagation',
},
{ band: '40m', excellentKm: 7_000, goodKm: 4_000, moderateKm: 2_000, description: 'Day/night transitions' },
{ band: '30m', excellentKm: 8_000, goodKm: 5_000, moderateKm: 2_500, description: 'WARC; reliable propagation' },
{ band: '20m', excellentKm: 15_000, goodKm: 10_000, moderateKm: 5_000, description: 'Workhorse DX band' },
{ band: '17m', excellentKm: 12_000, goodKm: 8_000, moderateKm: 4_000, description: 'WARC; solar-dependent' },
{
band: '15m',
excellentKm: 14_000,
goodKm: 9_000,
moderateKm: 4_500,
description: 'Solar-dependent; excellent when open',
},
{ band: '12m', excellentKm: 13_000, goodKm: 8_500, moderateKm: 4_000, description: 'WARC; similar to 10m/15m' },
{ band: '10m', excellentKm: 12_000, goodKm: 7_000, moderateKm: 3_000, description: 'Magic when open' },
{ band: '6m', excellentKm: 5_000, goodKm: 2_000, moderateKm: 500, description: 'Sporadic-E; rare F2' },
{ band: '2m', excellentKm: 2_000, goodKm: 500, moderateKm: 100, description: 'Tropo/EME' },
] as const;

export type BandDxThreshold = (typeof bandDxThresholds)[number];

/**
* Base threshold shape without band-specific literal types.
*/
export interface DxThresholdBase {
excellentKm: number;
goodKm: number;
moderateKm: number;
description: string;
}

/**
* Get DX thresholds for a specific band.
*
* @param band - Band name (e.g., '20m', '40m')
* @returns Threshold data for the band, or undefined if not found
*/
export function getThresholdsForBand(band: string): BandDxThreshold | undefined {
return bandDxThresholds.find((t) => t.band === band);
}

/**
* Default thresholds for unknown bands.
* Uses conservative values similar to 40m.
*/
export const defaultDxThresholds: DxThresholdBase = {
excellentKm: 7_000,
goodKm: 4_000,
moderateKm: 2_000,
description: 'Moderate propagation',
};
77 changes: 62 additions & 15 deletions src/main/java/io/nextskip/common/model/FrequencyBand.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,69 @@
/**
* Amateur radio frequency bands.
*
* Represents the common HF and VHF amateur radio bands with their
* frequency ranges in kHz.
* <p>Represents the common HF and VHF amateur radio bands with their
* frequency ranges in kHz and band-specific DX distance thresholds.
*
* <p>DX thresholds vary by band because propagation characteristics differ:
* <ul>
* <li>160m: Difficult band - 3,000 km is exceptional (night skip required)</li>
* <li>20m: "Workhorse" DX band - 15,000 km is needed for excellent score</li>
* <li>6m: Sporadic-E at 2,000 km is exciting; F2 at 5,000+ km is legendary</li>
* </ul>
*
* @see DxThresholds
*/
public enum FrequencyBand {
BAND_160M("160m", 1800, 2000),
BAND_80M("80m", 3500, 4000),
BAND_60M("60m", 5330, 5405),
BAND_40M("40m", 7000, 7300),
BAND_30M("30m", 10100, 10150),
BAND_20M("20m", 14000, 14350),
BAND_17M("17m", 18068, 18168),
BAND_15M("15m", 21000, 21450),
BAND_12M("12m", 24890, 24990),
BAND_10M("10m", 28000, 29700),
BAND_6M("6m", 50000, 54000),
BAND_2M("2m", 144000, 148000);
// Low bands - difficult DX, lower thresholds
BAND_160M("160m", 1800, 2000, new DxThresholds(3_000, 1_500, 500, "Difficult; night skip required")),
BAND_80M("80m", 3500, 4000, new DxThresholds(5_000, 2_500, 1_000, "Regional; some DX at night")),
BAND_60M("60m", 5330, 5405, new DxThresholds(6_000, 3_000, 1_500, "Secondary allocation; variable propagation")),

// Transition bands
BAND_40M("40m", 7000, 7300, new DxThresholds(7_000, 4_000, 2_000, "Day/night transitions")),
BAND_30M("30m", 10100, 10150, new DxThresholds(8_000, 5_000, 2_500, "WARC; reliable propagation")),

// High bands - workhorse DX bands, higher thresholds
BAND_20M("20m", 14000, 14350, new DxThresholds(15_000, 10_000, 5_000, "Workhorse DX band")),
BAND_17M("17m", 18068, 18168, new DxThresholds(12_000, 8_000, 4_000, "WARC; solar-dependent")),
BAND_15M("15m", 21000, 21450, new DxThresholds(14_000, 9_000, 4_500, "Solar-dependent; excellent when open")),
BAND_12M("12m", 24890, 24990, new DxThresholds(13_000, 8_500, 4_000, "WARC; similar to 10m/15m")),
BAND_10M("10m", 28000, 29700, new DxThresholds(12_000, 7_000, 3_000, "Magic when open")),

// VHF bands - rare propagation, low thresholds
BAND_6M("6m", 50000, 54000, new DxThresholds(5_000, 2_000, 500, "Sporadic-E; rare F2")),
BAND_2M("2m", 144000, 148000, new DxThresholds(2_000, 500, 100, "Tropo/EME"));

private final String name;
private final int startKhz;
private final int endKhz;
private final DxThresholds dxThresholds;

FrequencyBand(String name, int startKhz, int endKhz) {
FrequencyBand(String name, int startKhz, int endKhz, DxThresholds dxThresholds) {
this.name = name;
this.startKhz = startKhz;
this.endKhz = endKhz;
this.dxThresholds = dxThresholds;
}

/**
* DX distance thresholds for scoring band activity.
*
* <p>Different bands have vastly different propagation characteristics.
* These thresholds define what constitutes excellent, good, and moderate
* DX distances for each band.
*
* <p>KEEP IN SYNC with src/main/frontend/utils/bandDxThresholds.ts
*
* @param excellentKm distance for 100 points (exceptional DX for this band)
* @param goodKm distance for 70-100 point range
* @param moderateKm distance for 40-70 point range
* @param description human-readable description of band propagation
*/
public record DxThresholds(int excellentKm, int goodKm, int moderateKm, String description) {

/** Default thresholds for unknown bands (conservative, similar to 40m). */
public static final DxThresholds DEFAULT = new DxThresholds(7_000, 4_000, 2_000, "Moderate propagation");
}

public String getName() {
Expand All @@ -44,6 +82,15 @@ public int getEndKhz() {
return endKhz;
}

/**
* Get the DX distance thresholds for this band.
*
* @return band-specific DX thresholds for scoring
*/
public DxThresholds getDxThresholds() {
return dxThresholds;
}

/**
* Get the center frequency of the band in kHz.
*/
Expand Down
Loading