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
6 changes: 6 additions & 0 deletions public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,12 @@
"settings.enable_audio_notifications_description": "Play a sound when new messages arrive. Disable this if you experience audio issues in your browser.",
"settings.hide_incomplete_nodes": "Hide Incomplete Nodes",
"settings.hide_incomplete_description": "Hide nodes missing name or hardware info. On secure channels (custom PSK), incomplete nodes haven't been verified as being on your channel.",
"settings.node_dimming_enabled": "Dim Inactive Nodes on Map",
"settings.node_dimming_description": "Gradually fade out map markers for nodes that haven't been heard from recently.",
"settings.node_dimming_start_hours": "Start Dimming After (hours)",
"settings.node_dimming_start_hours_description": "How long after a node was last heard before it starts to fade.",
"settings.node_dimming_min_opacity": "Minimum Opacity",
"settings.node_dimming_min_opacity_description": "The dimmest a node can become (0.1 = almost invisible, 0.9 = barely dimmed).",
"settings.solar_monitoring": "Solar Monitoring",
"settings.solar_monitoring_description": "Configure solar panel monitoring for production estimates. Thanks to {{link}} for their Solar Estimates API!",
"settings.solar_latitude": "Latitude",
Expand Down
42 changes: 42 additions & 0 deletions src/components/NodesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,34 @@ const isToday = (date: Date): boolean => {
date.getFullYear() === today.getFullYear();
};

// Helper function to calculate node opacity based on last heard time
const calculateNodeOpacity = (
lastHeard: number | undefined,
enabled: boolean,
startHours: number,
minOpacity: number,
maxNodeAgeHours: number
): number => {
if (!enabled || !lastHeard) return 1;

const now = Date.now();
const lastHeardMs = lastHeard * 1000;
const ageHours = (now - lastHeardMs) / (1000 * 60 * 60);

// No dimming if node was heard within the start threshold
if (ageHours <= startHours) return 1;

// Calculate opacity linearly from 1 at startHours to minOpacity at maxNodeAgeHours
const dimmingRange = maxNodeAgeHours - startHours;
if (dimmingRange <= 0) return 1;

const ageInDimmingRange = ageHours - startHours;
const dimmingProgress = Math.min(1, ageInDimmingRange / dimmingRange);

// Linear interpolation from 1 to minOpacity
return 1 - (dimmingProgress * (1 - minOpacity));
};

// Memoized distance display component to avoid recalculating on every render
const DistanceDisplay = React.memo<{
homeNode: DeviceInfo | undefined;
Expand Down Expand Up @@ -177,6 +205,10 @@ const NodesTabComponent: React.FC<NodesTabProps> = ({
mapPinStyle,
customTilesets,
distanceUnit,
nodeDimmingEnabled,
nodeDimmingStartHours,
nodeDimmingMinOpacity,
maxNodeAgeHours,
} = useSettings();

const { hasPermission } = useAuth();
Expand Down Expand Up @@ -1363,11 +1395,21 @@ const NodesTabComponent: React.FC<NodesTabProps> = ({
// Use memoized position to prevent React-Leaflet from resetting marker position
const position = nodePositions.get(node.nodeNum)!;

// Calculate opacity based on last heard time
const markerOpacity = calculateNodeOpacity(
node.lastHeard,
nodeDimmingEnabled,
nodeDimmingStartHours,
nodeDimmingMinOpacity,
maxNodeAgeHours
);

return (
<Marker
key={node.nodeNum}
position={position}
icon={markerIcon}
opacity={markerOpacity}
zIndexOffset={shouldAnimate ? 10000 : 0}
ref={(ref) => handleMarkerRef(ref, node.user?.id)}
>
Expand Down
66 changes: 65 additions & 1 deletion src/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,18 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
}) => {
const { t } = useTranslation();
const csrfFetch = useCsrfFetch();
const { customThemes, customTilesets, enableAudioNotifications, setEnableAudioNotifications } = useSettings();
const {
customThemes,
customTilesets,
enableAudioNotifications,
setEnableAudioNotifications,
nodeDimmingEnabled,
setNodeDimmingEnabled,
nodeDimmingStartHours,
setNodeDimmingStartHours,
nodeDimmingMinOpacity,
setNodeDimmingMinOpacity
} = useSettings();
const { showIncompleteNodes, setShowIncompleteNodes } = useUI();

// Local state for editing
Expand Down Expand Up @@ -1023,6 +1034,59 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
</p>
</div>

<div className="settings-section">
<h3 style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', margin: 0, cursor: 'pointer' }}>
<input
type="checkbox"
checked={nodeDimmingEnabled}
onChange={(e) => setNodeDimmingEnabled(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
<span>{t('settings.node_dimming_enabled')}</span>
</label>
</h3>
<p className="setting-description">
{t('settings.node_dimming_description')}
</p>
{nodeDimmingEnabled && (
<>
<div className="setting-item">
<label htmlFor="nodeDimmingStartHours">
{t('settings.node_dimming_start_hours')}
<span className="setting-description">{t('settings.node_dimming_start_hours_description')}</span>
</label>
<input
id="nodeDimmingStartHours"
type="number"
min="0.5"
max="24"
step="0.5"
value={nodeDimmingStartHours}
onChange={(e) => setNodeDimmingStartHours(Math.min(24, Math.max(0.5, parseFloat(e.target.value) || 1)))}
className="setting-input"
/>
</div>
<div className="setting-item">
<label htmlFor="nodeDimmingMinOpacity">
{t('settings.node_dimming_min_opacity')}
<span className="setting-description">{t('settings.node_dimming_min_opacity_description')}</span>
</label>
<input
id="nodeDimmingMinOpacity"
type="number"
min="0.1"
max="0.9"
step="0.1"
value={nodeDimmingMinOpacity}
onChange={(e) => setNodeDimmingMinOpacity(Math.min(0.9, Math.max(0.1, parseFloat(e.target.value) || 0.3)))}
className="setting-input"
/>
</div>
</>
)}
</div>

<div id="settings-solar" className="settings-section">
<h3 style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', margin: 0, cursor: 'pointer' }}>
Expand Down
43 changes: 43 additions & 0 deletions src/contexts/SettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ interface SettingsContextType {
solarMonitoringAzimuth: number;
solarMonitoringDeclination: number;
enableAudioNotifications: boolean;
nodeDimmingEnabled: boolean;
nodeDimmingStartHours: number;
nodeDimmingMinOpacity: number;
temporaryTileset: TilesetId | null;
setTemporaryTileset: (tilesetId: TilesetId | null) => void;
isLoading: boolean;
Expand Down Expand Up @@ -92,6 +95,9 @@ interface SettingsContextType {
setSolarMonitoringAzimuth: (azimuth: number) => void;
setSolarMonitoringDeclination: (declination: number) => void;
setEnableAudioNotifications: (enabled: boolean) => void;
setNodeDimmingEnabled: (enabled: boolean) => void;
setNodeDimmingStartHours: (hours: number) => void;
setNodeDimmingMinOpacity: (opacity: number) => void;
}

const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
Expand Down Expand Up @@ -220,6 +226,22 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children, ba
return saved === null ? true : saved === 'true';
});

// Node dimming settings - localStorage only
const [nodeDimmingEnabled, setNodeDimmingEnabledState] = useState<boolean>(() => {
const saved = localStorage.getItem('nodeDimmingEnabled');
return saved === 'true';
});

const [nodeDimmingStartHours, setNodeDimmingStartHoursState] = useState<number>(() => {
const saved = localStorage.getItem('nodeDimmingStartHours');
return saved ? parseFloat(saved) : 1;
});

const [nodeDimmingMinOpacity, setNodeDimmingMinOpacityState] = useState<number>(() => {
const saved = localStorage.getItem('nodeDimmingMinOpacity');
return saved ? parseFloat(saved) : 0.3;
});

const [temporaryTileset, setTemporaryTileset] = useState<TilesetId | null>(null);

// Custom themes state
Expand Down Expand Up @@ -508,6 +530,21 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children, ba
localStorage.setItem('enableAudioNotifications', enabled.toString());
};

const setNodeDimmingEnabled = (enabled: boolean) => {
setNodeDimmingEnabledState(enabled);
localStorage.setItem('nodeDimmingEnabled', enabled.toString());
};

const setNodeDimmingStartHours = (hours: number) => {
setNodeDimmingStartHoursState(hours);
localStorage.setItem('nodeDimmingStartHours', hours.toString());
};

const setNodeDimmingMinOpacity = (opacity: number) => {
setNodeDimmingMinOpacityState(opacity);
localStorage.setItem('nodeDimmingMinOpacity', opacity.toString());
};

/**
* Add a new custom tileset
*/
Expand Down Expand Up @@ -871,6 +908,9 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children, ba
solarMonitoringAzimuth,
solarMonitoringDeclination,
enableAudioNotifications,
nodeDimmingEnabled,
nodeDimmingStartHours,
nodeDimmingMinOpacity,
temporaryTileset,
setTemporaryTileset,
isLoading,
Expand Down Expand Up @@ -901,6 +941,9 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children, ba
setSolarMonitoringAzimuth,
setSolarMonitoringDeclination,
setEnableAudioNotifications,
setNodeDimmingEnabled,
setNodeDimmingStartHours,
setNodeDimmingMinOpacity,
};

return (
Expand Down
Loading