3674 lines
		
	
	
		
			117 KiB
		
	
	
	
		
			Plaintext
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			3674 lines
		
	
	
		
			117 KiB
		
	
	
	
		
			Plaintext
		
	
	
		
			Executable File
		
	
	
	
	
#!/usr/bin/env seiscomp-python
 | 
						|
 | 
						|
from getopt import getopt, GetoptError
 | 
						|
from time import time, gmtime
 | 
						|
from datetime import datetime
 | 
						|
import os
 | 
						|
import sys
 | 
						|
import signal
 | 
						|
import glob
 | 
						|
import re
 | 
						|
import json
 | 
						|
 | 
						|
from seiscomp.myconfig import MyConfig
 | 
						|
import seiscomp.slclient
 | 
						|
import seiscomp.kernel, seiscomp.config
 | 
						|
from urllib.request import urlopen
 | 
						|
 | 
						|
# A dictionary to store station coordinates
 | 
						|
station_coordinates = {}
 | 
						|
 | 
						|
def load_station_coordinates(config):
 | 
						|
    """Load station coordinates from FDSN web service"""
 | 
						|
    global station_coordinates
 | 
						|
 | 
						|
    # Get base URL from config or use default
 | 
						|
    base_url = config['setup'].get('fdsnws_url', 'http://localhost:8080/fdsnws/')
 | 
						|
 | 
						|
    # Create a dictionary in the format needed by data_fetcher
 | 
						|
    stations_config = {}
 | 
						|
    for key in config.station:
 | 
						|
        network = config.station[key]['net']
 | 
						|
        station = config.station[key]['sta']
 | 
						|
        station_id = f"{network}.{station}"
 | 
						|
        stations_config[station_id] = {
 | 
						|
            'network': network,
 | 
						|
            'station': station,
 | 
						|
            'location': '',  # Default location
 | 
						|
            'stream': 'HHZ'  # Default stream
 | 
						|
        }
 | 
						|
 | 
						|
    # Fetch coordinates for each station
 | 
						|
    for station_id, station_info in stations_config.items():
 | 
						|
        network = station_info['network']
 | 
						|
        station = station_info['station']
 | 
						|
 | 
						|
        try:
 | 
						|
            with urlopen(base_url + f"station/1/query?net={network}&sta={station}&format=text") as fp:
 | 
						|
                fp.readline()
 | 
						|
                location_info = dict(zip(('lat', 'lon', 'elevation'), map(float, fp.readline().split(b'|')[2:5])))
 | 
						|
 | 
						|
            if location_info:
 | 
						|
                station_coordinates[f"{network}_{station}"] = location_info
 | 
						|
                print(f"Loaded coordinates for {network}_{station}: {location_info}")
 | 
						|
            else:
 | 
						|
                print(f"Could not fetch coordinates for {network}_{station}")
 | 
						|
        except Exception as e:
 | 
						|
            print(f"Error fetching coordinates for {network}_{station}: {str(e)}")
 | 
						|
 | 
						|
    # Print summary
 | 
						|
    print(f"Loaded coordinates for {len(station_coordinates)} stations")
 | 
						|
 | 
						|
 | 
						|
 | 
						|
usage_info = """
 | 
						|
Usage:
 | 
						|
  slmon [options]
 | 
						|
 | 
						|
Enhanced SeedLink monitor creating modern, interactive web dashboards
 | 
						|
 | 
						|
Options:
 | 
						|
  -h, --help       display this help message
 | 
						|
  -c               ini_setup = arg
 | 
						|
  -s               ini_stations = arg
 | 
						|
  -t               refresh = float(arg) # XXX not yet used
 | 
						|
  -v               verbose = 1
 | 
						|
  -g, --generate   generate only template files and exit
 | 
						|
 | 
						|
Examples:
 | 
						|
Start slmon from the command line
 | 
						|
  slmon -c $SEISCOMP_ROOT/var/lib/slmon/config.ini
 | 
						|
 | 
						|
Restart slmon in order to update the web pages. Use crontab entries for
 | 
						|
automatic restart, e.g.:
 | 
						|
  */3 * * * * /home/sysop/seiscomp/bin/seiscomp check slmon >/dev/null 2>&1
 | 
						|
"""
 | 
						|
 | 
						|
def usage(exitcode=0):
 | 
						|
    sys.stderr.write(usage_info)
 | 
						|
    exit(exitcode)
 | 
						|
 | 
						|
try:
 | 
						|
    seiscompRoot = os.environ["SEISCOMP_ROOT"]
 | 
						|
except:
 | 
						|
    print("\nSEISCOMP_ROOT must be defined - EXIT\n", file=sys.stderr)
 | 
						|
    usage(exitcode=2)
 | 
						|
 | 
						|
ini_stations = os.path.join(seiscompRoot, 'var/lib/slmon2/stations.ini')
 | 
						|
ini_setup = os.path.join(seiscompRoot, 'var/lib/slmon2/config.ini')
 | 
						|
 | 
						|
regexStreams = re.compile("[SLBVEH][HNLGD][ZNE123ADHF]")
 | 
						|
verbose = 0
 | 
						|
generate_only = False
 | 
						|
 | 
						|
 | 
						|
class Module(seiscomp.kernel.Module):
 | 
						|
    def __init__(self, env):
 | 
						|
        seiscomp.kernel.Module.__init__(self, env, env.moduleName(__file__))
 | 
						|
 | 
						|
    def printCrontab(self):
 | 
						|
        print("3 * * * * %s/bin/seiscomp check slmon >/dev/null 2>&1" % (self.env.SEISCOMP_ROOT))
 | 
						|
 | 
						|
 | 
						|
class Status:
 | 
						|
    def __repr__(self):
 | 
						|
        return "%2s %-5s %2s %3s %1s %s %s" % \
 | 
						|
               (self.net, self.sta, self.loc, self.cha, self.typ,
 | 
						|
                str(self.last_data), str(self.last_feed))
 | 
						|
 | 
						|
 | 
						|
class StatusDict(dict):
 | 
						|
    def __init__(self, source=None):
 | 
						|
        if source:
 | 
						|
            self.read(source)
 | 
						|
 | 
						|
    def fromSlinkTool(self, server="", stations=["AU_ARMA", "AU_BLDU", "AU_YAPP"]):
 | 
						|
        # later this shall use XML
 | 
						|
        cmd = "slinktool -nd 10 -nt 10 -Q %s" % server
 | 
						|
        print(cmd)
 | 
						|
        f = os.popen(cmd)
 | 
						|
        # regex = re.compile("[SLBVEH][HNLG][ZNE123]")
 | 
						|
        regex = regexStreams
 | 
						|
        for line in f:
 | 
						|
            net_sta = line[:2].strip() + "_" + line[3:8].strip()
 | 
						|
            if not net_sta in stations:
 | 
						|
                continue
 | 
						|
            typ = line[16]
 | 
						|
            if typ != "D":
 | 
						|
                continue
 | 
						|
            cha = line[12:15].strip()
 | 
						|
            if not regex.match(cha):
 | 
						|
                continue
 | 
						|
 | 
						|
            d = Status()
 | 
						|
            d.net = line[0: 2].strip()
 | 
						|
            d.sta = line[3: 8].strip()
 | 
						|
            d.loc = line[9:11].strip()
 | 
						|
            d.cha = line[12:15]
 | 
						|
            d.typ = line[16]
 | 
						|
            d.last_data = seiscomp.slclient.timeparse(line[47:70])
 | 
						|
            d.last_feed = d.last_data
 | 
						|
            sec = "%s_%s" % (d.net, d.sta)
 | 
						|
            sec = "%s.%s.%s.%s.%c" % (d.net, d.sta, d.loc, d.cha, d.typ)
 | 
						|
            self[sec] = d
 | 
						|
 | 
						|
    def read(self, source):
 | 
						|
        """
 | 
						|
        Read status data from various source types (file path, file object, or list of lines)
 | 
						|
        Python 3 compatible version
 | 
						|
        """
 | 
						|
        lines = []
 | 
						|
 | 
						|
        # Handle different source types
 | 
						|
        if isinstance(source, str):
 | 
						|
            # String - treat as file path
 | 
						|
            with open(source, 'r', encoding='utf-8') as f:
 | 
						|
                lines = f.readlines()
 | 
						|
        elif hasattr(source, 'readlines'):
 | 
						|
            # File-like object
 | 
						|
            lines = source.readlines()
 | 
						|
        elif isinstance(source, list):
 | 
						|
            # Already a list of lines
 | 
						|
            lines = source
 | 
						|
        else:
 | 
						|
            raise TypeError(f'Cannot read from {type(source).__name__}')
 | 
						|
 | 
						|
        # Process each line
 | 
						|
        for line in lines:
 | 
						|
            line = str(line).rstrip('\n\r')
 | 
						|
 | 
						|
            # Skip lines that are too short
 | 
						|
            if len(line) < 65:
 | 
						|
                continue
 | 
						|
 | 
						|
            # Create status object and parse fields
 | 
						|
            d = Status()
 | 
						|
            d.net = line[0:2].strip()
 | 
						|
            d.sta = line[3:8].strip()
 | 
						|
            d.loc = line[9:11].strip()
 | 
						|
            d.cha = line[12:15].strip()
 | 
						|
            d.typ = line[16]
 | 
						|
 | 
						|
            # Parse timestamps with error handling
 | 
						|
            try:
 | 
						|
                d.last_data = seiscomp.slclient.timeparse(line[18:41])
 | 
						|
            except:
 | 
						|
                d.last_data = None
 | 
						|
 | 
						|
            try:
 | 
						|
                d.last_feed = seiscomp.slclient.timeparse(line[42:65])
 | 
						|
            except:
 | 
						|
                d.last_feed = None
 | 
						|
 | 
						|
            # Ensure last_feed is not earlier than last_data
 | 
						|
            if d.last_feed and d.last_data and d.last_feed < d.last_data:
 | 
						|
                d.last_feed = d.last_data
 | 
						|
 | 
						|
            # Create dictionary key and store
 | 
						|
            sec = f"{d.net}_{d.sta}:{d.loc}.{d.cha}.{d.typ}"
 | 
						|
            self[sec] = d
 | 
						|
 | 
						|
    def write(self, f):
 | 
						|
        """
 | 
						|
        Write status data to file or file-like object
 | 
						|
        Python 3 compatible version
 | 
						|
        """
 | 
						|
        should_close = False
 | 
						|
 | 
						|
        if isinstance(f, str):
 | 
						|
            # String - treat as file path
 | 
						|
            f = open(f, "w", encoding='utf-8')
 | 
						|
            should_close = True
 | 
						|
 | 
						|
        try:
 | 
						|
            # Prepare and write sorted lines
 | 
						|
            lines = [str(self[key]) for key in sorted(self.keys())]
 | 
						|
            f.write('\n'.join(lines) + '\n')
 | 
						|
        finally:
 | 
						|
            if should_close:
 | 
						|
                f.close()
 | 
						|
 | 
						|
    def to_json(self):
 | 
						|
        """Convert status dictionary to JSON for JavaScript use"""
 | 
						|
        global station_coordinates
 | 
						|
        stations_data = {}
 | 
						|
 | 
						|
        # Group by network and station
 | 
						|
        for key, value in self.items():
 | 
						|
            net_sta = f"{value.net}_{value.sta}"
 | 
						|
            if net_sta not in stations_data:
 | 
						|
                stations_data[net_sta] = {
 | 
						|
                    "network": value.net,
 | 
						|
                    "station": value.sta,
 | 
						|
                    "channels": [],
 | 
						|
                    "channelGroups": {
 | 
						|
                        "HH": [],  # High-frequency, High-gain channels
 | 
						|
                        "BH": [],  # Broadband, High-gain channels
 | 
						|
                        "LH": [],  # Long-period, High-gain channels
 | 
						|
                        "SH": [],  # Short-period, High-gain channels
 | 
						|
                        "EH": [],  # Extremely Short-period, High-gain channels
 | 
						|
                        "other": []  # All other channel types
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                # Add coordinates if available
 | 
						|
                if net_sta in station_coordinates:
 | 
						|
                    stations_data[net_sta]["coordinates"] = station_coordinates[net_sta]
 | 
						|
 | 
						|
            # Get latency information
 | 
						|
            now = datetime.utcnow()
 | 
						|
            latency_data = now - value.last_data
 | 
						|
            latency_seconds = total_seconds(latency_data)
 | 
						|
 | 
						|
            # Extract channel type (first two characters, e.g., 'LH', 'BH', 'HH', 'EH')
 | 
						|
            channel_type = value.cha[:2] if len(value.cha) >= 2 else "other"
 | 
						|
 | 
						|
            # Get status with channel-aware thresholds
 | 
						|
            status = get_status_from_seconds(latency_seconds, channel_type)
 | 
						|
 | 
						|
            # Create channel data
 | 
						|
            channel_data = {
 | 
						|
                "location": value.loc,
 | 
						|
                "channel": value.cha,
 | 
						|
                "type": value.typ,
 | 
						|
                "channelType": channel_type,
 | 
						|
                "last_data": value.last_data.isoformat() if value.last_data else None,
 | 
						|
                "last_feed": value.last_feed.isoformat() if value.last_feed else None,
 | 
						|
                "latency": latency_seconds,
 | 
						|
                "status": status
 | 
						|
            }
 | 
						|
 | 
						|
            # Add to main channels list
 | 
						|
            stations_data[net_sta]["channels"].append(channel_data)
 | 
						|
 | 
						|
            # Add to channel group for separated status calculation
 | 
						|
            if channel_type in stations_data[net_sta]["channelGroups"]:
 | 
						|
                stations_data[net_sta]["channelGroups"][channel_type].append(channel_data)
 | 
						|
            else:
 | 
						|
                stations_data[net_sta]["channelGroups"]["other"].append(channel_data)
 | 
						|
 | 
						|
        # Convert to list for easier JavaScript processing
 | 
						|
        stations_list = []
 | 
						|
        for net_sta, data in stations_data.items():
 | 
						|
            # Calculate overall station status based on priority channels (non-LH channels)
 | 
						|
            # First try HH channels
 | 
						|
            if data["channelGroups"]["HH"]:
 | 
						|
                worst_latency = max([ch["latency"] for ch in data["channelGroups"]["HH"]])
 | 
						|
                data["status"] = get_status_from_seconds(worst_latency)
 | 
						|
                data["primaryChannelType"] = "HH"
 | 
						|
            # Then try BH channels
 | 
						|
            elif data["channelGroups"]["BH"]:
 | 
						|
                worst_latency = max([ch["latency"] for ch in data["channelGroups"]["BH"]])
 | 
						|
                data["status"] = get_status_from_seconds(worst_latency)
 | 
						|
                data["primaryChannelType"] = "BH"
 | 
						|
            # Then try SH channels
 | 
						|
            elif data["channelGroups"]["SH"]:
 | 
						|
                worst_latency = max([ch["latency"] for ch in data["channelGroups"]["SH"]])
 | 
						|
                data["status"] = get_status_from_seconds(worst_latency)
 | 
						|
                data["primaryChannelType"] = "SH"
 | 
						|
            # Then try EH channels
 | 
						|
            elif data["channelGroups"]["EH"]:
 | 
						|
                worst_latency = max([ch["latency"] for ch in data["channelGroups"]["EH"]])
 | 
						|
                data["status"] = get_status_from_seconds(worst_latency)
 | 
						|
                data["primaryChannelType"] = "EH"
 | 
						|
            # Only use LH if nothing else is available
 | 
						|
            elif data["channelGroups"]["LH"]:
 | 
						|
                worst_latency = max([ch["latency"] for ch in data["channelGroups"]["LH"]])
 | 
						|
                data["status"] = get_status_from_seconds(worst_latency, "LH")
 | 
						|
                data["primaryChannelType"] = "LH"
 | 
						|
            # Fall back to other channels
 | 
						|
            elif data["channelGroups"]["other"]:
 | 
						|
                worst_latency = max([ch["latency"] for ch in data["channelGroups"]["other"]])
 | 
						|
                data["status"] = get_status_from_seconds(worst_latency)
 | 
						|
                data["primaryChannelType"] = "other"
 | 
						|
            else:
 | 
						|
                # Failsafe if no channels
 | 
						|
                data["status"] = "unavailable"
 | 
						|
                data["primaryChannelType"] = "none"
 | 
						|
                worst_latency = 0
 | 
						|
 | 
						|
            data["latency"] = worst_latency
 | 
						|
            data["id"] = net_sta
 | 
						|
            stations_list.append(data)
 | 
						|
 | 
						|
        return json.dumps(stations_list)
 | 
						|
 | 
						|
def get_map_settings(config):
 | 
						|
    """Extract map settings from config for JavaScript use"""
 | 
						|
    map_settings = {
 | 
						|
        'center': {
 | 
						|
            'lat': -25.6,  # Default latitude
 | 
						|
            'lon': 134.3,  # Default longitude
 | 
						|
            'zoom': 6   # Default zoom
 | 
						|
        },
 | 
						|
        'defaultLayer': 'street',
 | 
						|
        'enableClustering': True,
 | 
						|
        'showFullscreenControl': True,
 | 
						|
        'showLayerControl': True,
 | 
						|
        'showLocateControl': True,
 | 
						|
        'darkModeLayer': 'dark',
 | 
						|
        'lightModeLayer': 'street'
 | 
						|
    }
 | 
						|
 | 
						|
    # Extract center coordinates from config
 | 
						|
    if 'center_map' in config['setup']:
 | 
						|
        if 'lat' in config['setup']['center_map']:
 | 
						|
            map_settings['center']['lat'] = float(config['setup']['center_map']['lat'])
 | 
						|
        if 'lon' in config['setup']['center_map']:
 | 
						|
            map_settings['center']['lon'] = float(config['setup']['center_map']['lon'])
 | 
						|
        if 'zoom' in config['setup']['center_map']:
 | 
						|
            map_settings['center']['zoom'] = int(config['setup']['center_map']['zoom'])
 | 
						|
 | 
						|
    # Extract other map settings
 | 
						|
    if 'map_settings' in config['setup']:
 | 
						|
        map_config = config['setup']['map_settings']
 | 
						|
 | 
						|
        if 'default_layer' in map_config:
 | 
						|
            map_settings['defaultLayer'] = map_config['default_layer']
 | 
						|
 | 
						|
        if 'enable_clustering' in map_config:
 | 
						|
            map_settings['enableClustering'] = map_config['enable_clustering'] == 'true' or map_config['enable_clustering'] is True
 | 
						|
 | 
						|
        if 'show_fullscreen_control' in map_config:
 | 
						|
            map_settings['showFullscreenControl'] = map_config['show_fullscreen_control'] == 'true' or map_config['show_fullscreen_control'] is True
 | 
						|
 | 
						|
        if 'show_layer_control' in map_config:
 | 
						|
            map_settings['showLayerControl'] = map_config['show_layer_control'] == 'true' or map_config['show_layer_control'] is True
 | 
						|
 | 
						|
        if 'show_locate_control' in map_config:
 | 
						|
            map_settings['showLocateControl'] = map_config['show_locate_control'] == 'true' or map_config['show_locate_control'] is True
 | 
						|
 | 
						|
        if 'dark_mode_layer' in map_config:
 | 
						|
            map_settings['darkModeLayer'] = map_config['dark_mode_layer']
 | 
						|
 | 
						|
        if 'light_mode_layer' in map_config:
 | 
						|
            map_settings['lightModeLayer'] = map_config['light_mode_layer']
 | 
						|
 | 
						|
    return map_settings
 | 
						|
 | 
						|
def get_status_from_seconds(seconds, channel_type=None):
 | 
						|
    """
 | 
						|
    Get status code based on latency in seconds with channel-specific thresholds
 | 
						|
 | 
						|
    Args:
 | 
						|
        seconds (float): Latency in seconds
 | 
						|
        channel_type (str): Channel type (e.g., 'LH', 'BH', 'HH', 'EH')
 | 
						|
 | 
						|
    Returns:
 | 
						|
        str: Status code (good, delayed, etc.)
 | 
						|
    """
 | 
						|
    # Special handling for LH channels - they're naturally delayed
 | 
						|
    if channel_type == 'LH':
 | 
						|
        # More lenient thresholds for LH channels
 | 
						|
        if seconds > 604800:  # > 7 days
 | 
						|
            return "unavailable"
 | 
						|
        elif seconds > 518400:  # > 6 days
 | 
						|
            return "four-day"
 | 
						|
        elif seconds > 432000:  # > 5 days
 | 
						|
            return "three-day"
 | 
						|
        elif seconds > 345600:  # > 4 days
 | 
						|
            return "multi-day"
 | 
						|
        elif seconds > 259200:  # > 3 days
 | 
						|
            return "day-delayed"
 | 
						|
        elif seconds > 86400:  # > 1 day
 | 
						|
            return "critical"
 | 
						|
        elif seconds > 43200:  # > 12 hours
 | 
						|
            return "warning"
 | 
						|
        elif seconds > 21600:  # > 6 hours
 | 
						|
            return "hour-delayed"
 | 
						|
        elif seconds > 10800:  # > 3 hours
 | 
						|
            return "very-delayed"
 | 
						|
        elif seconds > 3600:  # > 1 hour
 | 
						|
            return "long-delayed"
 | 
						|
        elif seconds > 1800:  # > 30 minutes
 | 
						|
            return "delayed"
 | 
						|
        else:  # <= 30 minutes (LH channels are considered good even with moderate delay)
 | 
						|
            return "good"
 | 
						|
 | 
						|
    # Standard thresholds for other channels
 | 
						|
    if seconds > 432000:  # > 5 days
 | 
						|
        return "unavailable"
 | 
						|
    elif seconds > 345600:  # > 4 days
 | 
						|
        return "four-day"
 | 
						|
    elif seconds > 259200:  # > 3 days
 | 
						|
        return "three-day"
 | 
						|
    elif seconds > 172800:  # > 2 days
 | 
						|
        return "multi-day"
 | 
						|
    elif seconds > 86400:  # > 1 day
 | 
						|
        return "day-delayed"
 | 
						|
    elif seconds > 21600:  # > 6 hours
 | 
						|
        return "critical"
 | 
						|
    elif seconds > 7200:  # > 2 hours
 | 
						|
        return "warning"
 | 
						|
    elif seconds > 3600:  # > 1 hour
 | 
						|
        return "hour-delayed"
 | 
						|
    elif seconds > 1800:  # > 30 minutes
 | 
						|
        return "very-delayed"
 | 
						|
    elif seconds > 600:  # > 10 minutes
 | 
						|
        return "long-delayed"
 | 
						|
    elif seconds > 60:  # > 1 minute
 | 
						|
        return "delayed"
 | 
						|
    else:  # <= 1 minute
 | 
						|
        return "good"
 | 
						|
 | 
						|
 | 
						|
def getColor(delta):
 | 
						|
    delay = total_seconds(delta)
 | 
						|
    if delay > 432000: return '#666666'  # > 5 days
 | 
						|
    elif delay > 345600: return '#999999'  # > 4 days
 | 
						|
    elif delay > 259200: return '#CCCCCC'  # > 3 days
 | 
						|
    elif delay > 172800: return '#FFB3B3'  # > 2 days
 | 
						|
    elif delay > 86400: return '#FF3333'  # > 1 day
 | 
						|
    elif delay > 21600: return '#FF9966'  # > 6 hours
 | 
						|
    elif delay > 7200: return '#FFFF00'  # > 2 hours
 | 
						|
    elif delay > 3600: return '#00FF00'  # > 1 hour
 | 
						|
    elif delay > 1800: return '#3399FF'  # > 30 minutes
 | 
						|
    elif delay > 600: return '#9470BB'  # > 10 minutes
 | 
						|
    elif delay > 60: return '#EBD6FF'  # > 1 minute
 | 
						|
    else: return '#FFFFFF'  # <= 1 minute
 | 
						|
 | 
						|
 | 
						|
def total_seconds(td):
 | 
						|
    return td.seconds + (td.days*86400)
 | 
						|
 | 
						|
 | 
						|
def myrename(name1, name2):
 | 
						|
    # fault-tolerant rename that doesn't cause an exception if it fails, which
 | 
						|
    # may happen e.g. if the target is on a non-reachable NFS directory
 | 
						|
    try:
 | 
						|
        os.rename(name1, name2)
 | 
						|
    except OSError:
 | 
						|
        print("failed to rename(%s,%s)" % (name1, name2), file=sys.stderr)
 | 
						|
 | 
						|
 | 
						|
def formatLatency(delta):
 | 
						|
    """Format latency for display"""
 | 
						|
    if delta is None: return 'n/a'
 | 
						|
 | 
						|
    t = total_seconds(delta)
 | 
						|
 | 
						|
    if t > 86400: return f"{t/86400:.1f} d"
 | 
						|
    elif t > 7200: return f"{t/3600:.1f} h"
 | 
						|
    elif t > 120: return f"{t/60:.1f} m"
 | 
						|
    else: return f"{t:.1f} s"
 | 
						|
 | 
						|
 | 
						|
def generate_css_file(config):
 | 
						|
    """Generate the CSS file with theme support"""
 | 
						|
    css_content = """
 | 
						|
:root {
 | 
						|
    /* Light theme variables */
 | 
						|
    --primary-color: #4f46e5;
 | 
						|
    --primary-hover: #4338ca;
 | 
						|
    --text-primary: #1f2937;
 | 
						|
    --text-secondary: #6b7280;
 | 
						|
    --bg-primary: #ffffff;
 | 
						|
    --bg-secondary: #f9fafb;
 | 
						|
    --bg-tertiary: #f3f4f6;
 | 
						|
    --border-color: #e5e7eb;
 | 
						|
    --border-radius: 8px;
 | 
						|
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
 | 
						|
    --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
 | 
						|
    --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
 | 
						|
 | 
						|
    /* Status colors */
 | 
						|
    --status-good: #ffffff;
 | 
						|
    --status-delayed: #c084fc;
 | 
						|
    --status-long-delayed: #8b5cf6;
 | 
						|
    --status-very-delayed: #3b82f6;
 | 
						|
    --status-hour-delayed: #10b981;
 | 
						|
    --status-warning: #fbbf24;
 | 
						|
    --status-critical: #f97316;
 | 
						|
    --status-day-delayed: #ef4444;
 | 
						|
    --status-multi-day: #f87171;
 | 
						|
    --status-three-day: #d1d5db;
 | 
						|
    --status-four-day: #9ca3af;
 | 
						|
    --status-unavailable: #6b7280;
 | 
						|
}
 | 
						|
 | 
						|
.dark-mode {
 | 
						|
    /* Dark theme variables */
 | 
						|
    --primary-color: #818cf8;
 | 
						|
    --primary-hover: #a5b4fc;
 | 
						|
    --text-primary: #f9fafb;
 | 
						|
    --text-secondary: #9ca3af;
 | 
						|
    --bg-primary: #1f2937;
 | 
						|
    --bg-secondary: #111827;
 | 
						|
    --bg-tertiary: #374151;
 | 
						|
    --border-color: #374151;
 | 
						|
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
 | 
						|
    --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
 | 
						|
    --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3);
 | 
						|
 | 
						|
    /* Dark theme status colors - background stays dark */
 | 
						|
    --status-good: #1f2937;
 | 
						|
}
 | 
						|
 | 
						|
/* General Styles */
 | 
						|
* {
 | 
						|
    box-sizing: border-box;
 | 
						|
    margin: 0;
 | 
						|
    padding: 0;
 | 
						|
}
 | 
						|
 | 
						|
body {
 | 
						|
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
 | 
						|
    line-height: 1.6;
 | 
						|
    color: var(--text-primary);
 | 
						|
    background-color: var(--bg-secondary);
 | 
						|
    padding: 0;
 | 
						|
    margin: 0;
 | 
						|
}
 | 
						|
 | 
						|
.container {
 | 
						|
    max-width: 1400px;
 | 
						|
    margin: 20px auto;
 | 
						|
    padding: 30px;
 | 
						|
    background-color: var(--bg-primary);
 | 
						|
    border-radius: var(--border-radius);
 | 
						|
    box-shadow: var(--shadow-md);
 | 
						|
}
 | 
						|
 | 
						|
.header {
 | 
						|
    display: flex;
 | 
						|
    justify-content: space-between;
 | 
						|
    align-items: center;
 | 
						|
    margin-bottom: 20px;
 | 
						|
    border-bottom: 1px solid var(--border-color);
 | 
						|
    padding-bottom: 15px;
 | 
						|
}
 | 
						|
 | 
						|
h1 {
 | 
						|
    font-size: 28px;
 | 
						|
    font-weight: 600;
 | 
						|
    color: var(--text-primary);
 | 
						|
    letter-spacing: -0.5px;
 | 
						|
}
 | 
						|
 | 
						|
.subtitle {
 | 
						|
    color: var(--text-secondary);
 | 
						|
    font-size: 16px;
 | 
						|
    margin-bottom: 20px;
 | 
						|
}
 | 
						|
 | 
						|
/* Navigation Tabs */
 | 
						|
.view-toggle {
 | 
						|
    display: flex;
 | 
						|
    gap: 10px;
 | 
						|
}
 | 
						|
 | 
						|
.view-toggle a {
 | 
						|
    padding: 8px 15px;
 | 
						|
    border-radius: 6px;
 | 
						|
    color: var(--text-secondary);
 | 
						|
    text-decoration: none;
 | 
						|
    transition: all 0.2s ease;
 | 
						|
    font-weight: 500;
 | 
						|
    font-size: 14px;
 | 
						|
}
 | 
						|
 | 
						|
.view-toggle a:hover {
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
    color: var(--primary-color);
 | 
						|
}
 | 
						|
 | 
						|
.view-toggle a.active {
 | 
						|
    background-color: var(--primary-color);
 | 
						|
    color: white;
 | 
						|
}
 | 
						|
 | 
						|
/* Controls */
 | 
						|
.controls {
 | 
						|
    display: flex;
 | 
						|
    justify-content: space-between;
 | 
						|
    align-items: center;
 | 
						|
    margin: 20px 0;
 | 
						|
    flex-wrap: wrap;
 | 
						|
    gap: 15px;
 | 
						|
}
 | 
						|
 | 
						|
.actions {
 | 
						|
    display: flex;
 | 
						|
    gap: 10px;
 | 
						|
}
 | 
						|
 | 
						|
.action-button {
 | 
						|
    padding: 8px 15px;
 | 
						|
    display: flex;
 | 
						|
    align-items: center;
 | 
						|
    gap: 6px;
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
    border: 1px solid var(--border-color);
 | 
						|
    border-radius: 6px;
 | 
						|
    color: var(--text-secondary);
 | 
						|
    cursor: pointer;
 | 
						|
    font-size: 14px;
 | 
						|
    font-weight: 500;
 | 
						|
    transition: all 0.2s ease;
 | 
						|
}
 | 
						|
 | 
						|
.action-button:hover {
 | 
						|
    background-color: var(--bg-primary);
 | 
						|
    color: var(--primary-color);
 | 
						|
}
 | 
						|
 | 
						|
.action-button svg {
 | 
						|
    width: 16px;
 | 
						|
    height: 16px;
 | 
						|
}
 | 
						|
 | 
						|
.refresh-control {
 | 
						|
    display: flex;
 | 
						|
    align-items: center;
 | 
						|
    gap: 12px;
 | 
						|
    padding: 10px 15px;
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
    border-radius: 6px;
 | 
						|
}
 | 
						|
 | 
						|
.input-group {
 | 
						|
    display: flex;
 | 
						|
    align-items: center;
 | 
						|
    gap: 8px;
 | 
						|
}
 | 
						|
 | 
						|
.refresh-control input {
 | 
						|
    width: 60px;
 | 
						|
    padding: 6px 10px;
 | 
						|
    border: 1px solid var(--border-color);
 | 
						|
    border-radius: 4px;
 | 
						|
    font-size: 14px;
 | 
						|
    background-color: var(--bg-primary);
 | 
						|
    color: var(--text-primary);
 | 
						|
    text-align: center;
 | 
						|
}
 | 
						|
 | 
						|
.refresh-control button {
 | 
						|
    padding: 6px 12px;
 | 
						|
    background-color: var(--primary-color);
 | 
						|
    color: white;
 | 
						|
    border: none;
 | 
						|
    border-radius: 4px;
 | 
						|
    cursor: pointer;
 | 
						|
    font-weight: 500;
 | 
						|
    transition: background-color 0.2s ease;
 | 
						|
}
 | 
						|
 | 
						|
.refresh-control button:hover {
 | 
						|
    background-color: var(--primary-hover);
 | 
						|
}
 | 
						|
 | 
						|
.status-counter {
 | 
						|
    display: flex;
 | 
						|
    flex-direction: column;
 | 
						|
    align-items: flex-end;
 | 
						|
    gap: 2px;
 | 
						|
}
 | 
						|
 | 
						|
#refresh-status {
 | 
						|
    font-size: 13px;
 | 
						|
    color: var(--text-secondary);
 | 
						|
}
 | 
						|
 | 
						|
.countdown {
 | 
						|
    font-size: 13px;
 | 
						|
    color: var(--text-secondary);
 | 
						|
}
 | 
						|
 | 
						|
#next-refresh {
 | 
						|
    color: var(--primary-color);
 | 
						|
    font-weight: 500;
 | 
						|
}
 | 
						|
 | 
						|
/* Filter and Search */
 | 
						|
.filters {
 | 
						|
    display: flex;
 | 
						|
    flex-wrap: wrap;
 | 
						|
    gap: 10px;
 | 
						|
    margin-bottom: 20px;
 | 
						|
    padding: 15px;
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
    border-radius: var(--border-radius);
 | 
						|
}
 | 
						|
 | 
						|
.filter-group {
 | 
						|
    display: flex;
 | 
						|
    align-items: center;
 | 
						|
    gap: 8px;
 | 
						|
}
 | 
						|
 | 
						|
.filter-group label {
 | 
						|
    font-size: 14px;
 | 
						|
    color: var(--text-secondary);
 | 
						|
    font-weight: 500;
 | 
						|
}
 | 
						|
 | 
						|
.filter-group select {
 | 
						|
    padding: 6px 10px;
 | 
						|
    border: 1px solid var(--border-color);
 | 
						|
    border-radius: 4px;
 | 
						|
    background-color: var(--bg-primary);
 | 
						|
    color: var(--text-primary);
 | 
						|
    font-size: 14px;
 | 
						|
}
 | 
						|
 | 
						|
.search-box {
 | 
						|
    padding: 6px 12px;
 | 
						|
    border: 1px solid var(--border-color);
 | 
						|
    border-radius: 4px;
 | 
						|
    background-color: var(--bg-primary);
 | 
						|
    color: var(--text-primary);
 | 
						|
    font-size: 14px;
 | 
						|
    min-width: 200px;
 | 
						|
}
 | 
						|
 | 
						|
/* Table View */
 | 
						|
.table-container {
 | 
						|
    overflow-x: auto;
 | 
						|
    margin-bottom: 20px;
 | 
						|
}
 | 
						|
 | 
						|
table {
 | 
						|
    width: 100%;
 | 
						|
    border-collapse: collapse;
 | 
						|
}
 | 
						|
 | 
						|
table th {
 | 
						|
    padding: 12px 15px;
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
    color: var(--text-secondary);
 | 
						|
    font-weight: 600;
 | 
						|
    text-align: left;
 | 
						|
    border-bottom: 1px solid var(--border-color);
 | 
						|
    position: sticky;
 | 
						|
    top: 0;
 | 
						|
    z-index: 10;
 | 
						|
}
 | 
						|
 | 
						|
table td {
 | 
						|
    padding: 10px 15px;
 | 
						|
    border-bottom: 1px solid var(--border-color);
 | 
						|
}
 | 
						|
 | 
						|
table tr:hover {
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
}
 | 
						|
 | 
						|
/* Grid View */
 | 
						|
.grid-container {
 | 
						|
    display: table;
 | 
						|
    width: 100%;
 | 
						|
    border-collapse: collapse;
 | 
						|
    margin-top: 20px;
 | 
						|
}
 | 
						|
 | 
						|
.grid-row {
 | 
						|
    display: table-row;
 | 
						|
}
 | 
						|
 | 
						|
.network-label {
 | 
						|
    display: table-cell;
 | 
						|
    vertical-align: middle;
 | 
						|
    text-align: center;
 | 
						|
    font-weight: 600;
 | 
						|
    width: 60px;
 | 
						|
    min-width: 60px;
 | 
						|
    height: 34px;
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
    border-radius: 6px;
 | 
						|
    color: var(--text-secondary);
 | 
						|
    box-shadow: var(--shadow-sm);
 | 
						|
    padding: 4px;
 | 
						|
    margin: 2px;
 | 
						|
    border: 1px solid var(--border-color);
 | 
						|
}
 | 
						|
 | 
						|
.stations-container {
 | 
						|
    display: table-cell;
 | 
						|
    padding-left: 6px;
 | 
						|
}
 | 
						|
 | 
						|
.stations-row {
 | 
						|
    display: flex;
 | 
						|
    flex-wrap: wrap;
 | 
						|
    gap: 4px;
 | 
						|
    margin: 2px 0;
 | 
						|
}
 | 
						|
 | 
						|
.grid-cell {
 | 
						|
    width: 60px;
 | 
						|
    height: 34px;
 | 
						|
    display: flex;
 | 
						|
    align-items: center;
 | 
						|
    justify-content: center;
 | 
						|
    border-radius: 6px;
 | 
						|
    font-size: 13px;
 | 
						|
    font-weight: 500;
 | 
						|
    box-shadow: var(--shadow-sm);
 | 
						|
    text-decoration: none;
 | 
						|
    color: var(--text-primary);
 | 
						|
    transition: all 0.15s ease;
 | 
						|
    position: relative;
 | 
						|
    border: 1px solid var(--border-color);
 | 
						|
    background-color: var(--status-good);
 | 
						|
}
 | 
						|
 | 
						|
.grid-cell:hover {
 | 
						|
    transform: translateY(-2px);
 | 
						|
    box-shadow: var(--shadow-md);
 | 
						|
    z-index: 10;
 | 
						|
}
 | 
						|
 | 
						|
/* Map View */
 | 
						|
.map-container {
 | 
						|
    width: 100%;
 | 
						|
    height: 600px;
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
    border-radius: var(--border-radius);
 | 
						|
    margin-bottom: 20px;
 | 
						|
    position: relative;
 | 
						|
}
 | 
						|
 | 
						|
/* Status Colors */
 | 
						|
.station-unavailable {
 | 
						|
    background-color: var(--status-unavailable);
 | 
						|
    color: white;
 | 
						|
    border-color: var(--status-unavailable);
 | 
						|
}
 | 
						|
 | 
						|
.station-warning {
 | 
						|
    background-color: var(--status-warning);
 | 
						|
    color: #7c2d12;
 | 
						|
    border-color: var(--status-warning);
 | 
						|
}
 | 
						|
 | 
						|
.station-critical {
 | 
						|
    background-color: var(--status-critical);
 | 
						|
    color: white;
 | 
						|
    border-color: var(--status-critical);
 | 
						|
}
 | 
						|
 | 
						|
.station-delayed {
 | 
						|
    background-color: var(--status-delayed);
 | 
						|
    color: #4a044e;
 | 
						|
    border-color: var(--status-delayed);
 | 
						|
}
 | 
						|
 | 
						|
.station-long-delayed {
 | 
						|
    background-color: var(--status-long-delayed);
 | 
						|
    color: white;
 | 
						|
    border-color: var(--status-long-delayed);
 | 
						|
}
 | 
						|
 | 
						|
.station-very-delayed {
 | 
						|
    background-color: var(--status-very-delayed);
 | 
						|
    color: white;
 | 
						|
    border-color: var(--status-very-delayed);
 | 
						|
}
 | 
						|
 | 
						|
.station-hour-delayed {
 | 
						|
    background-color: var(--status-hour-delayed);
 | 
						|
    color: white;
 | 
						|
    border-color: var(--status-hour-delayed);
 | 
						|
}
 | 
						|
 | 
						|
.station-day-delayed {
 | 
						|
    background-color: var(--status-day-delayed);
 | 
						|
    color: white;
 | 
						|
    border-color: var(--status-day-delayed);
 | 
						|
}
 | 
						|
 | 
						|
.station-multi-day {
 | 
						|
    background-color: var(--status-multi-day);
 | 
						|
    color: #7f1d1d;
 | 
						|
    border-color: var(--status-multi-day);
 | 
						|
}
 | 
						|
 | 
						|
.station-three-day {
 | 
						|
    background-color: var(--status-three-day);
 | 
						|
    color: #1f2937;
 | 
						|
    border-color: var(--status-three-day);
 | 
						|
}
 | 
						|
 | 
						|
.station-four-day {
 | 
						|
    background-color: var(--status-four-day);
 | 
						|
    color: white;
 | 
						|
    border-color: var(--status-four-day);
 | 
						|
}
 | 
						|
 | 
						|
.station-good {
 | 
						|
    background-color: var(--status-good);
 | 
						|
    color: var(--text-primary);
 | 
						|
    border-color: var(--border-color);
 | 
						|
}
 | 
						|
 | 
						|
/* Tooltip */
 | 
						|
.grid-cell::after {
 | 
						|
    content: attr(data-tooltip);
 | 
						|
    position: absolute;
 | 
						|
    bottom: 120%;
 | 
						|
    left: 50%;
 | 
						|
    transform: translateX(-50%);
 | 
						|
    background-color: #1f2937;
 | 
						|
    color: white;
 | 
						|
    text-align: center;
 | 
						|
    padding: 8px 12px;
 | 
						|
    border-radius: 6px;
 | 
						|
    font-size: 12px;
 | 
						|
    white-space: nowrap;
 | 
						|
    z-index: 20;
 | 
						|
    opacity: 0;
 | 
						|
    visibility: hidden;
 | 
						|
    transition: all 0.2s ease;
 | 
						|
    pointer-events: none;
 | 
						|
    box-shadow: var(--shadow-md);
 | 
						|
}
 | 
						|
 | 
						|
.grid-cell::before {
 | 
						|
    content: '';
 | 
						|
    position: absolute;
 | 
						|
    top: -6px;
 | 
						|
    left: 50%;
 | 
						|
    transform: translateX(-50%);
 | 
						|
    border-width: 6px 6px 0;
 | 
						|
    border-style: solid;
 | 
						|
    border-color: #1f2937 transparent transparent;
 | 
						|
    z-index: 20;
 | 
						|
    opacity: 0;
 | 
						|
    visibility: hidden;
 | 
						|
    transition: all 0.2s ease;
 | 
						|
    pointer-events: none;
 | 
						|
}
 | 
						|
 | 
						|
.grid-cell:hover::after,
 | 
						|
.grid-cell:hover::before {
 | 
						|
    opacity: 1;
 | 
						|
    visibility: visible;
 | 
						|
}
 | 
						|
 | 
						|
/* Stats */
 | 
						|
.stats-container {
 | 
						|
    margin: 20px 0;
 | 
						|
    padding: 20px;
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
    border-radius: var(--border-radius);
 | 
						|
    display: none;
 | 
						|
}
 | 
						|
 | 
						|
.stats-header {
 | 
						|
    display: flex;
 | 
						|
    justify-content: space-between;
 | 
						|
    align-items: center;
 | 
						|
    margin-bottom: 20px;
 | 
						|
}
 | 
						|
 | 
						|
.stats-title {
 | 
						|
    font-weight: 600;
 | 
						|
    font-size: 16px;
 | 
						|
    color: var(--text-primary);
 | 
						|
}
 | 
						|
 | 
						|
.network-stats {
 | 
						|
    display: grid;
 | 
						|
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
 | 
						|
    gap: 15px;
 | 
						|
}
 | 
						|
 | 
						|
.network-stat {
 | 
						|
    display: flex;
 | 
						|
    flex-direction: column;
 | 
						|
    gap: 8px;
 | 
						|
}
 | 
						|
 | 
						|
.network-name {
 | 
						|
    font-weight: 600;
 | 
						|
    font-size: 14px;
 | 
						|
    color: var(--text-primary);
 | 
						|
    display: flex;
 | 
						|
    justify-content: space-between;
 | 
						|
}
 | 
						|
 | 
						|
.progress-bar {
 | 
						|
    height: 8px;
 | 
						|
    background-color: var(--border-color);
 | 
						|
    border-radius: 4px;
 | 
						|
    overflow: hidden;
 | 
						|
}
 | 
						|
 | 
						|
.progress {
 | 
						|
    height: 100%;
 | 
						|
    background-color: var(--primary-color);
 | 
						|
    border-radius: 4px;
 | 
						|
}
 | 
						|
 | 
						|
/* Legend */
 | 
						|
.legend {
 | 
						|
    display: flex;
 | 
						|
    flex-wrap: wrap;
 | 
						|
    gap: 10px;
 | 
						|
    margin: 25px 0;
 | 
						|
    padding: 15px;
 | 
						|
    background-color: var(--bg-tertiary);
 | 
						|
    border-radius: var(--border-radius);
 | 
						|
    justify-content: center;
 | 
						|
}
 | 
						|
 | 
						|
.legend-item {
 | 
						|
    display: flex;
 | 
						|
    align-items: center;
 | 
						|
    gap: 5px;
 | 
						|
    font-size: 13px;
 | 
						|
    color: var(--text-secondary);
 | 
						|
}
 | 
						|
 | 
						|
.legend-color {
 | 
						|
    width: 16px;
 | 
						|
    height: 16px;
 | 
						|
    border-radius: 4px;
 | 
						|
    border: 1px solid rgba(0, 0, 0, 0.1);
 | 
						|
}
 | 
						|
 | 
						|
/* Footer */
 | 
						|
.footer {
 | 
						|
    margin-top: 30px;
 | 
						|
    padding-top: 15px;
 | 
						|
    border-top: 1px solid var(--border-color);
 | 
						|
    display: flex;
 | 
						|
    justify-content: space-between;
 | 
						|
    color: var(--text-secondary);
 | 
						|
    font-size: 14px;
 | 
						|
}
 | 
						|
 | 
						|
.footer a {
 | 
						|
    color: var(--primary-color);
 | 
						|
    text-decoration: none;
 | 
						|
}
 | 
						|
 | 
						|
.footer a:hover {
 | 
						|
    text-decoration: underline;
 | 
						|
}
 | 
						|
 | 
						|
/* Loading */
 | 
						|
#loading {
 | 
						|
    display: flex;
 | 
						|
    align-items: center;
 | 
						|
    justify-content: center;
 | 
						|
    margin: 30px 0;
 | 
						|
    color: var(--text-secondary);
 | 
						|
}
 | 
						|
 | 
						|
.loading-spinner {
 | 
						|
    width: 24px;
 | 
						|
    height: 24px;
 | 
						|
    border: 3px solid var(--bg-tertiary);
 | 
						|
    border-top: 3px solid var(--primary-color);
 | 
						|
    border-radius: 50%;
 | 
						|
    margin-right: 12px;
 | 
						|
    animation: spin 1s linear infinite;
 | 
						|
}
 | 
						|
 | 
						|
@keyframes spin {
 | 
						|
    0% { transform: rotate(0deg); }
 | 
						|
    100% { transform: rotate(360deg); }
 | 
						|
}
 | 
						|
 | 
						|
/* Error Message */
 | 
						|
#error-message {
 | 
						|
    padding: 15px;
 | 
						|
    margin: 20px 0;
 | 
						|
    border-radius: var(--border-radius);
 | 
						|
    background-color: #fee2e2;
 | 
						|
    color: #b91c1c;
 | 
						|
    border-left: 4px solid #ef4444;
 | 
						|
    display: none;
 | 
						|
}
 | 
						|
 | 
						|
/* Responsive Design */
 | 
						|
@media (max-width: 768px) {
 | 
						|
    .container {
 | 
						|
        margin: 10px;
 | 
						|
        padding: 15px;
 | 
						|
        border-radius: 6px;
 | 
						|
    }
 | 
						|
 | 
						|
    .header {
 | 
						|
        flex-direction: column;
 | 
						|
        align-items: flex-start;
 | 
						|
        gap: 10px;
 | 
						|
    }
 | 
						|
 | 
						|
    .view-toggle {
 | 
						|
        align-self: flex-end;
 | 
						|
    }
 | 
						|
 | 
						|
    .controls {
 | 
						|
        flex-direction: column;
 | 
						|
        align-items: stretch;
 | 
						|
    }
 | 
						|
 | 
						|
    .actions {
 | 
						|
        justify-content: space-between;
 | 
						|
    }
 | 
						|
 | 
						|
    .refresh-control {
 | 
						|
        flex-direction: column;
 | 
						|
        align-items: flex-start;
 | 
						|
        gap: 10px;
 | 
						|
    }
 | 
						|
 | 
						|
    .filters {
 | 
						|
        flex-direction: column;
 | 
						|
        gap: 10px;
 | 
						|
    }
 | 
						|
 | 
						|
    .filter-group {
 | 
						|
        width: 100%;
 | 
						|
    }
 | 
						|
 | 
						|
    .search-box {
 | 
						|
        width: 100%;
 | 
						|
    }
 | 
						|
 | 
						|
    .network-stats {
 | 
						|
        grid-template-columns: 1fr;
 | 
						|
    }
 | 
						|
 | 
						|
    .map-container {
 | 
						|
            height: 400px;
 | 
						|
        }
 | 
						|
    }
 | 
						|
    /* Marker Cluster Styles */
 | 
						|
    .marker-cluster {
 | 
						|
        background-clip: padding-box;
 | 
						|
        border-radius: 20px;
 | 
						|
    }
 | 
						|
 | 
						|
    .marker-cluster div {
 | 
						|
        width: 36px;
 | 
						|
        height: 36px;
 | 
						|
        margin-left: 2px;
 | 
						|
        margin-top: 2px;
 | 
						|
        text-align: center;
 | 
						|
        border-radius: 18px;
 | 
						|
        font-size: 12px;
 | 
						|
        display: flex;
 | 
						|
        align-items: center;
 | 
						|
        justify-content: center;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Map Controls */
 | 
						|
    .leaflet-control-locate {
 | 
						|
        border: 2px solid rgba(0,0,0,0.2);
 | 
						|
        background-clip: padding-box;
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-locate a {
 | 
						|
        background-color: var(--bg-primary);
 | 
						|
        background-position: 50% 50%;
 | 
						|
        background-repeat: no-repeat;
 | 
						|
        display: block;
 | 
						|
        width: 30px;
 | 
						|
        height: 30px;
 | 
						|
        line-height: 30px;
 | 
						|
        color: var(--text-primary);
 | 
						|
        text-align: center;
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-locate a:hover {
 | 
						|
        background-color: var(--bg-tertiary);
 | 
						|
        color: var(--primary-color);
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-locate.active a {
 | 
						|
        color: var(--primary-color);
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-fullscreen {
 | 
						|
        border: 2px solid rgba(0,0,0,0.2);
 | 
						|
        background-clip: padding-box;
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-fullscreen a {
 | 
						|
        background-color: var(--bg-primary);
 | 
						|
        background-position: 50% 50%;
 | 
						|
        background-repeat: no-repeat;
 | 
						|
        display: block;
 | 
						|
        width: 30px;
 | 
						|
        height: 30px;
 | 
						|
        line-height: 30px;
 | 
						|
        color: var(--text-primary);
 | 
						|
        text-align: center;
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-fullscreen a:hover {
 | 
						|
        background-color: var(--bg-tertiary);
 | 
						|
        color: var(--primary-color);
 | 
						|
    }
 | 
						|
 | 
						|
    /* Map layers control */
 | 
						|
    .leaflet-control-layers {
 | 
						|
        border-radius: var(--border-radius);
 | 
						|
        background-color: var(--bg-primary);
 | 
						|
        color: var(--text-primary);
 | 
						|
        border: 1px solid var(--border-color);
 | 
						|
        box-shadow: var(--shadow-sm);
 | 
						|
    }
 | 
						|
 | 
						|
    .dark-mode .leaflet-control-layers {
 | 
						|
        background-color: var(--bg-tertiary);
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-layers-toggle {
 | 
						|
        width: 36px;
 | 
						|
        height: 36px;
 | 
						|
        background-size: 20px 20px;
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-layers-expanded {
 | 
						|
        padding: 10px;
 | 
						|
        background-color: var(--bg-primary);
 | 
						|
        color: var(--text-primary);
 | 
						|
        border-radius: var(--border-radius);
 | 
						|
    }
 | 
						|
 | 
						|
    .dark-mode .leaflet-control-layers-expanded {
 | 
						|
        background-color: var(--bg-tertiary);
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-layers-list {
 | 
						|
        margin-top: 8px;
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-control-layers label {
 | 
						|
        margin-bottom: 5px;
 | 
						|
        display: block;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Map layer selection buttons */
 | 
						|
    .map-layers-control {
 | 
						|
        position: absolute;
 | 
						|
        top: 10px;
 | 
						|
        right: 10px;
 | 
						|
        z-index: 1000;
 | 
						|
        background: white;
 | 
						|
        padding: 5px;
 | 
						|
        border-radius: 4px;
 | 
						|
        box-shadow: 0 1px 5px rgba(0,0,0,0.65);
 | 
						|
    }
 | 
						|
 | 
						|
    .map-layers-control button {
 | 
						|
        display: block;
 | 
						|
        margin: 5px 0;
 | 
						|
        padding: 5px;
 | 
						|
        width: 100%;
 | 
						|
        border: none;
 | 
						|
        background: #f8f8f8;
 | 
						|
        cursor: pointer;
 | 
						|
    }
 | 
						|
 | 
						|
    .map-layers-control button:hover {
 | 
						|
        background: #f0f0f0;
 | 
						|
    }
 | 
						|
 | 
						|
    .map-layers-control button.active {
 | 
						|
        background: #ddd;
 | 
						|
        font-weight: bold;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Map tools control */
 | 
						|
    .map-tools-control {
 | 
						|
        position: absolute;
 | 
						|
        bottom: 30px;
 | 
						|
        right: 10px;
 | 
						|
        z-index: 1000;
 | 
						|
        display: flex;
 | 
						|
        flex-direction: column;
 | 
						|
        gap: 5px;
 | 
						|
    }
 | 
						|
 | 
						|
    .map-tools-control button {
 | 
						|
        width: 34px;
 | 
						|
        height: 34px;
 | 
						|
        background: white;
 | 
						|
        border: 2px solid rgba(0,0,0,0.2);
 | 
						|
        border-radius: 4px;
 | 
						|
        display: flex;
 | 
						|
        align-items: center;
 | 
						|
        justify-content: center;
 | 
						|
        cursor: pointer;
 | 
						|
        color: #333;
 | 
						|
    }
 | 
						|
 | 
						|
    .map-tools-control button:hover {
 | 
						|
        background: #f4f4f4;
 | 
						|
    }
 | 
						|
 | 
						|
    .dark-mode .map-tools-control button {
 | 
						|
        background: #333;
 | 
						|
        color: #fff;
 | 
						|
        border-color: rgba(255,255,255,0.2);
 | 
						|
    }
 | 
						|
 | 
						|
    .dark-mode .map-tools-control button:hover {
 | 
						|
        background: #444;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Map measurement widget */
 | 
						|
    .leaflet-measure-path-measurement {
 | 
						|
        position: absolute;
 | 
						|
        font-size: 12px;
 | 
						|
        color: black;
 | 
						|
        text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white;
 | 
						|
        white-space: nowrap;
 | 
						|
        transform-origin: 0;
 | 
						|
        pointer-events: none;
 | 
						|
    }
 | 
						|
 | 
						|
    .dark-mode .leaflet-measure-path-measurement {
 | 
						|
        color: white;
 | 
						|
        text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Popup styling */
 | 
						|
    .leaflet-popup-content-wrapper {
 | 
						|
        border-radius: var(--border-radius);
 | 
						|
        background-color: var(--bg-primary);
 | 
						|
        color: var(--text-primary);
 | 
						|
        box-shadow: var(--shadow-md);
 | 
						|
    }
 | 
						|
 | 
						|
    .dark-mode .leaflet-popup-content-wrapper {
 | 
						|
        background-color: var(--bg-tertiary);
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-popup-content {
 | 
						|
        margin: 12px;
 | 
						|
        line-height: 1.5;
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-popup-tip {
 | 
						|
        background-color: var(--bg-primary);
 | 
						|
    }
 | 
						|
 | 
						|
    .dark-mode .leaflet-popup-tip {
 | 
						|
        background-color: var(--bg-tertiary);
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-popup-content a {
 | 
						|
        color: var(--primary-color);
 | 
						|
        text-decoration: none;
 | 
						|
    }
 | 
						|
 | 
						|
    .leaflet-popup-content a:hover {
 | 
						|
        text-decoration: underline;
 | 
						|
    }
 | 
						|
 | 
						|
    /* Make the map more responsive on mobile */
 | 
						|
    @media (max-width: 768px) {
 | 
						|
        .map-container {
 | 
						|
            height: 450px;
 | 
						|
        }
 | 
						|
 | 
						|
        .leaflet-control-layers,
 | 
						|
        .leaflet-control-zoom,
 | 
						|
        .leaflet-control-fullscreen,
 | 
						|
        .leaflet-control-locate {
 | 
						|
            margin-right: 10px !important;
 | 
						|
        }
 | 
						|
 | 
						|
        .leaflet-control-scale {
 | 
						|
            margin-bottom: 40px !important;
 | 
						|
        }
 | 
						|
    }
 | 
						|
    """
 | 
						|
 | 
						|
    try:
 | 
						|
        css_path = os.path.join(config['setup']['wwwdir'], 'styles.css')
 | 
						|
        with open(css_path, 'w') as f:
 | 
						|
            f.write(css_content)
 | 
						|
        print(f"CSS file generated at {css_path}")
 | 
						|
        return css_path
 | 
						|
    except Exception as e:
 | 
						|
        print(f"Error generating CSS file: {str(e)}")
 | 
						|
        return None
 | 
						|
 | 
						|
 | 
						|
def generate_js_file(config):
 | 
						|
    """Generate the JavaScript file with interactive features"""
 | 
						|
    js_content = """
 | 
						|
// Global variables
 | 
						|
let refreshTimer = null;
 | 
						|
let currentRefreshInterval = 60;
 | 
						|
let lastRefreshTime = 0;
 | 
						|
let isRefreshing = false;
 | 
						|
let stationsData = [];
 | 
						|
let viewMode = 'table'; // 'table', 'grid', or 'map'
 | 
						|
let mapInitialized = false;
 | 
						|
let map = null;
 | 
						|
let markers = [];
 | 
						|
 | 
						|
// Function to initialize the application
 | 
						|
document.addEventListener('DOMContentLoaded', function() {
 | 
						|
    // Load saved preferences
 | 
						|
    loadPreferences();
 | 
						|
 | 
						|
    // Set up event listeners
 | 
						|
    setupEventListeners();
 | 
						|
 | 
						|
    //Add channel type filter
 | 
						|
    setupFilters();
 | 
						|
 | 
						|
    // Set active view based on URL or default
 | 
						|
    setActiveView();
 | 
						|
 | 
						|
    // Initial data load
 | 
						|
    fetchData();
 | 
						|
});
 | 
						|
 | 
						|
// Function to load user preferences from localStorage
 | 
						|
function loadPreferences() {
 | 
						|
    // Load refresh interval
 | 
						|
    const savedInterval = parseInt(localStorage.getItem('seedlinkRefreshInterval'));
 | 
						|
    if (savedInterval && savedInterval >= 10) {
 | 
						|
        document.getElementById('refresh-interval').value = savedInterval;
 | 
						|
        currentRefreshInterval = savedInterval;
 | 
						|
    }
 | 
						|
 | 
						|
    // Load dark mode preference
 | 
						|
    const darkModeEnabled = localStorage.getItem('seedlink-dark-mode') === 'true';
 | 
						|
    if (darkModeEnabled) {
 | 
						|
        document.body.classList.add('dark-mode');
 | 
						|
        updateThemeToggleButton(true);
 | 
						|
    }
 | 
						|
 | 
						|
    // Load view mode
 | 
						|
    const savedViewMode = localStorage.getItem('seedlink-view-mode');
 | 
						|
    if (savedViewMode) {
 | 
						|
        viewMode = savedViewMode;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Function to set up all event listeners
 | 
						|
function setupEventListeners() {
 | 
						|
    // View toggle buttons
 | 
						|
    document.querySelectorAll('.view-toggle a').forEach(link => {
 | 
						|
        link.addEventListener('click', function(e) {
 | 
						|
            e.preventDefault();
 | 
						|
            const view = this.getAttribute('data-view');
 | 
						|
            switchView(view);
 | 
						|
        });
 | 
						|
    });
 | 
						|
 | 
						|
    // Refresh controls
 | 
						|
    document.getElementById('apply-refresh').addEventListener('click', function() {
 | 
						|
        const interval = parseInt(document.getElementById('refresh-interval').value);
 | 
						|
        if (interval && interval >= 10) {
 | 
						|
            updateRefreshInterval(interval);
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    document.getElementById('refresh-now').addEventListener('click', function() {
 | 
						|
        if (refreshTimer) {
 | 
						|
            clearTimeout(refreshTimer);
 | 
						|
        }
 | 
						|
        fetchData();
 | 
						|
    });
 | 
						|
 | 
						|
    // Theme toggle
 | 
						|
    document.getElementById('theme-toggle').addEventListener('click', toggleDarkMode);
 | 
						|
 | 
						|
    // Export CSV
 | 
						|
    document.getElementById('export-csv').addEventListener('click', exportToCsv);
 | 
						|
 | 
						|
    // Stats toggle
 | 
						|
    document.getElementById('stats-toggle').addEventListener('click', toggleStats);
 | 
						|
    document.getElementById('close-stats').addEventListener('click', function() {
 | 
						|
        document.getElementById('stats-container').style.display = 'none';
 | 
						|
    });
 | 
						|
 | 
						|
    // Filter inputs
 | 
						|
    document.getElementById('network-filter').addEventListener('change', applyFilters);
 | 
						|
    document.getElementById('status-filter').addEventListener('change', applyFilters);
 | 
						|
    document.getElementById('search-input').addEventListener('input', debounce(applyFilters, 300));
 | 
						|
 | 
						|
    // Sort headers in table view
 | 
						|
    document.querySelectorAll('th[data-sort]').forEach(header => {
 | 
						|
        header.addEventListener('click', function() {
 | 
						|
            sortTable(this.getAttribute('data-sort'));
 | 
						|
        });
 | 
						|
    });
 | 
						|
 | 
						|
    // Handle visibility changes (tab switching)
 | 
						|
    document.addEventListener('visibilitychange', function() {
 | 
						|
        if (document.visibilityState === 'visible') {
 | 
						|
            // If data is stale (not refreshed in over half the interval)
 | 
						|
            const timeSinceLastRefresh = Date.now() - lastRefreshTime;
 | 
						|
            if (timeSinceLastRefresh > (currentRefreshInterval * 500)) {
 | 
						|
                if (refreshTimer) {
 | 
						|
                    clearTimeout(refreshTimer);
 | 
						|
                }
 | 
						|
                fetchData();
 | 
						|
            }
 | 
						|
        }
 | 
						|
    });
 | 
						|
}
 | 
						|
 | 
						|
// Function to set active view based on URL or saved preference
 | 
						|
function setActiveView() {
 | 
						|
    // Extract view from URL if present
 | 
						|
    const urlParams = new URLSearchParams(window.location.search);
 | 
						|
    const urlView = urlParams.get('view');
 | 
						|
 | 
						|
    if (urlView && ['table', 'grid', 'map'].includes(urlView)) {
 | 
						|
        viewMode = urlView;
 | 
						|
    }
 | 
						|
 | 
						|
    // Set active class on the appropriate link
 | 
						|
    document.querySelectorAll('.view-toggle a').forEach(link => {
 | 
						|
        if (link.getAttribute('data-view') === viewMode) {
 | 
						|
            link.classList.add('active');
 | 
						|
        } else {
 | 
						|
            link.classList.remove('active');
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    // Show the appropriate view container
 | 
						|
    document.querySelectorAll('.view-container').forEach(container => {
 | 
						|
        if (container.id === `${viewMode}-view`) {
 | 
						|
            container.style.display = 'block';
 | 
						|
        } else {
 | 
						|
            container.style.display = 'none';
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    // Initialize map if needed
 | 
						|
    if (viewMode === 'map' && !mapInitialized && typeof L !== 'undefined') {
 | 
						|
        initializeMap();
 | 
						|
    }
 | 
						|
 | 
						|
    // Save preference
 | 
						|
    localStorage.setItem('seedlink-view-mode', viewMode);
 | 
						|
}
 | 
						|
 | 
						|
// Function to switch between views
 | 
						|
function switchView(view) {
 | 
						|
    viewMode = view;
 | 
						|
 | 
						|
    // Update URL without reloading the page
 | 
						|
    const url = new URL(window.location);
 | 
						|
    url.searchParams.set('view', view);
 | 
						|
    window.history.pushState({}, '', url);
 | 
						|
 | 
						|
    setActiveView();
 | 
						|
 | 
						|
    // Refresh data display for the new view
 | 
						|
    renderData();
 | 
						|
}
 | 
						|
 | 
						|
// Function to toggle dark mode
 | 
						|
function toggleDarkMode() {
 | 
						|
    document.body.classList.toggle('dark-mode');
 | 
						|
    const isDarkMode = document.body.classList.contains('dark-mode');
 | 
						|
    localStorage.setItem('seedlink-dark-mode', isDarkMode ? 'true' : 'false');
 | 
						|
 | 
						|
    updateThemeToggleButton(isDarkMode);
 | 
						|
 | 
						|
    // Update map tiles if map is initialized
 | 
						|
    if (mapInitialized && map) {
 | 
						|
        updateMapTiles(isDarkMode);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Function to update theme toggle button appearance
 | 
						|
function updateThemeToggleButton(isDarkMode) {
 | 
						|
    const themeToggle = document.getElementById('theme-toggle');
 | 
						|
    if (isDarkMode) {
 | 
						|
        themeToggle.innerHTML = `
 | 
						|
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 | 
						|
                <circle cx="12" cy="12" r="5"></circle>
 | 
						|
                <line x1="12" y1="1" x2="12" y2="3"></line>
 | 
						|
                <line x1="12" y1="21" x2="12" y2="23"></line>
 | 
						|
                <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
 | 
						|
                <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
 | 
						|
                <line x1="1" y1="12" x2="3" y2="12"></line>
 | 
						|
                <line x1="21" y1="12" x2="23" y2="12"></line>
 | 
						|
                <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
 | 
						|
                <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
 | 
						|
            </svg>
 | 
						|
            Light Mode
 | 
						|
        `;
 | 
						|
    } else {
 | 
						|
        themeToggle.innerHTML = `
 | 
						|
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 | 
						|
                <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
 | 
						|
            </svg>
 | 
						|
            Dark Mode
 | 
						|
        `;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Function to initialize the map view
 | 
						|
// 1. Enhanced map initialization function
 | 
						|
// Updated initializeMap function with markerCluster safety checks
 | 
						|
function initializeMap() {
 | 
						|
    // Check if Leaflet is available
 | 
						|
    if (typeof L === 'undefined') {
 | 
						|
        console.error('Leaflet library not loaded');
 | 
						|
        document.getElementById('map-container').innerHTML = '<div class="error-message">Map library not available. Please check your internet connection.</div>';
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Initialize markerCluster as null so it's defined even if the plugin isn't available
 | 
						|
    markerCluster = null;
 | 
						|
 | 
						|
    // Read map settings from the page data if available
 | 
						|
    const mapSettings = window.mapSettings || {
 | 
						|
        center: { lat: 20, lon: 0, zoom: 2 },
 | 
						|
        defaultLayer: 'street',
 | 
						|
        enableClustering: true,
 | 
						|
        showFullscreenControl: true,
 | 
						|
        showLayerControl: true,
 | 
						|
        showLocateControl: true
 | 
						|
    };
 | 
						|
 | 
						|
    // Create map instance
 | 
						|
    map = L.map('map-container', {
 | 
						|
        center: [mapSettings.center.lat, mapSettings.center.lon],
 | 
						|
        zoom: mapSettings.center.zoom,
 | 
						|
        zoomControl: false // We'll add this separately for better positioning
 | 
						|
    });
 | 
						|
 | 
						|
    // Define available base layers
 | 
						|
    const baseLayers = {
 | 
						|
        'Street': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
 | 
						|
            attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
 | 
						|
            maxZoom: 19
 | 
						|
        }),
 | 
						|
        'Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
 | 
						|
            attribution: 'Imagery © Esri © ArcGIS',
 | 
						|
            maxZoom: 19
 | 
						|
        }),
 | 
						|
        'Terrain': L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
 | 
						|
            attribution: 'Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: © <a href="https://opentopomap.org">OpenTopoMap</a>',
 | 
						|
            maxZoom: 17
 | 
						|
        }),
 | 
						|
        'Dark': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
 | 
						|
            attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
 | 
						|
            subdomains: 'abcd',
 | 
						|
            maxZoom: 19
 | 
						|
        })
 | 
						|
    };
 | 
						|
 | 
						|
    // Add appropriate layer based on settings or dark mode
 | 
						|
    const isDarkMode = document.body.classList.contains('dark-mode');
 | 
						|
    let defaultLayer = isDarkMode ? 'Dark' : (mapSettings.defaultLayer || 'Street');
 | 
						|
    defaultLayer = defaultLayer.charAt(0).toUpperCase() + defaultLayer.slice(1); // Capitalize
 | 
						|
 | 
						|
    // Add the default layer to the map
 | 
						|
    if (baseLayers[defaultLayer]) {
 | 
						|
        baseLayers[defaultLayer].addTo(map);
 | 
						|
    } else {
 | 
						|
        // Fallback to the first available layer
 | 
						|
        baseLayers[Object.keys(baseLayers)[0]].addTo(map);
 | 
						|
    }
 | 
						|
 | 
						|
    // Add layer control if enabled
 | 
						|
    if (mapSettings.showLayerControl !== false) {
 | 
						|
        L.control.layers(baseLayers, {}, {
 | 
						|
            position: 'topright',
 | 
						|
            collapsed: true
 | 
						|
        }).addTo(map);
 | 
						|
    }
 | 
						|
 | 
						|
    // Add zoom control in a better position
 | 
						|
    L.control.zoom({
 | 
						|
        position: 'bottomright'
 | 
						|
    }).addTo(map);
 | 
						|
 | 
						|
    // Add scale control
 | 
						|
    L.control.scale().addTo(map);
 | 
						|
 | 
						|
    // Add fullscreen control if enabled and the plugin is available
 | 
						|
    if (mapSettings.showFullscreenControl !== false && typeof L.Control.Fullscreen !== 'undefined') {
 | 
						|
        L.control.fullscreen({
 | 
						|
            position: 'topright',
 | 
						|
            title: {
 | 
						|
                'false': 'View Fullscreen',
 | 
						|
                'true': 'Exit Fullscreen'
 | 
						|
            }
 | 
						|
        }).addTo(map);
 | 
						|
    }
 | 
						|
 | 
						|
    // Add locate control if enabled and the plugin is available
 | 
						|
    if (mapSettings.showLocateControl !== false && typeof L.Control.Locate !== 'undefined') {
 | 
						|
        L.control.locate({
 | 
						|
            position: 'bottomright',
 | 
						|
            icon: 'fa fa-location-arrow',
 | 
						|
            strings: {
 | 
						|
                title: 'Show my location'
 | 
						|
            },
 | 
						|
            locateOptions: {
 | 
						|
                enableHighAccuracy: true,
 | 
						|
                maxZoom: 10
 | 
						|
            }
 | 
						|
        }).addTo(map);
 | 
						|
    }
 | 
						|
 | 
						|
    // Initialize marker cluster group if enabled and the plugin is available
 | 
						|
    if (mapSettings.enableClustering !== false && typeof L.MarkerClusterGroup !== 'undefined') {
 | 
						|
        try {
 | 
						|
            markerCluster = L.markerClusterGroup({
 | 
						|
                disableClusteringAtZoom: 10,
 | 
						|
                spiderfyOnMaxZoom: true,
 | 
						|
                showCoverageOnHover: false,
 | 
						|
                iconCreateFunction: function(cluster) {
 | 
						|
                    const count = cluster.getChildCount();
 | 
						|
 | 
						|
                    // Determine color based on worst status in the cluster
 | 
						|
                    let worstStatus = 'good';
 | 
						|
                    const markers = cluster.getAllChildMarkers();
 | 
						|
 | 
						|
                    for (const marker of markers) {
 | 
						|
                        const status = marker.options.status || 'good';
 | 
						|
 | 
						|
                        // Simple ordering of statuses from least to most severe
 | 
						|
                        const statusOrder = {
 | 
						|
                            'good': 0,
 | 
						|
                            'delayed': 1,
 | 
						|
                            'long-delayed': 2,
 | 
						|
                            'very-delayed': 3,
 | 
						|
                            'hour-delayed': 4,
 | 
						|
                            'warning': 5,
 | 
						|
                            'critical': 6,
 | 
						|
                            'day-delayed': 7,
 | 
						|
                            'multi-day': 8,
 | 
						|
                            'three-day': 9,
 | 
						|
                            'four-day': 10,
 | 
						|
                            'unavailable': 11
 | 
						|
                        };
 | 
						|
 | 
						|
                        if ((statusOrder[status] || 0) > (statusOrder[worstStatus] || 0)) {
 | 
						|
                            worstStatus = status;
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
 | 
						|
                    // Get color for worst status
 | 
						|
                    const color = getStatusColor(worstStatus);
 | 
						|
 | 
						|
                    const textColor = getBestTextColor(color);
 | 
						|
 | 
						|
                    return L.divIcon({
 | 
						|
                        html: `<div style="background-color: ${color}; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; border-radius: 50%; border: 2px solid white; color: ${textColor}; font-weight: bold;">${count}</div>`,
 | 
						|
                        className: 'marker-cluster',
 | 
						|
                        iconSize: new L.Point(40, 40)
 | 
						|
                    });
 | 
						|
                }
 | 
						|
            });
 | 
						|
 | 
						|
            map.addLayer(markerCluster);
 | 
						|
            console.log("Marker clustering initialized successfully");
 | 
						|
        } catch (e) {
 | 
						|
            console.error("Error initializing marker clustering:", e);
 | 
						|
            markerCluster = null; // Reset to null if initialization failed
 | 
						|
        }
 | 
						|
    } else {
 | 
						|
        console.log("Marker clustering is disabled or not available");
 | 
						|
    }
 | 
						|
 | 
						|
    // Mark as initialized
 | 
						|
    mapInitialized = true;
 | 
						|
 | 
						|
    // Update markers if we already have data
 | 
						|
    if (stationsData.length > 0) {
 | 
						|
        updateMapMarkers();
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Helper function to determine best text color (black or white) based on background color
 | 
						|
function getBestTextColor(bgColor) {
 | 
						|
    // Handle named colors
 | 
						|
    if (bgColor.toLowerCase() === '#ffffff') return '#000000';
 | 
						|
    if (bgColor.toLowerCase() === '#000000') return '#ffffff';
 | 
						|
 | 
						|
    // Convert hex to rgb
 | 
						|
    let hex = bgColor.replace('#', '');
 | 
						|
    let r, g, b;
 | 
						|
 | 
						|
    if (hex.length === 3) {
 | 
						|
        r = parseInt(hex.charAt(0) + hex.charAt(0), 16);
 | 
						|
        g = parseInt(hex.charAt(1) + hex.charAt(1), 16);
 | 
						|
        b = parseInt(hex.charAt(2) + hex.charAt(2), 16);
 | 
						|
    } else {
 | 
						|
        r = parseInt(hex.substring(0, 2), 16);
 | 
						|
        g = parseInt(hex.substring(2, 4), 16);
 | 
						|
        b = parseInt(hex.substring(4, 6), 16);
 | 
						|
    }
 | 
						|
 | 
						|
    // Calculate luminance
 | 
						|
    const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
 | 
						|
 | 
						|
    // Return white for dark backgrounds, black for light backgrounds
 | 
						|
    return luminance > 0.5 ? '#000000' : '#ffffff';
 | 
						|
}
 | 
						|
 | 
						|
function updateMapTiles(isDarkMode) {
 | 
						|
    if (!map) return;
 | 
						|
 | 
						|
    // Get available layers from map's layer control
 | 
						|
    const baseLayers = {};
 | 
						|
    map.eachLayer(layer => {
 | 
						|
        if (layer instanceof L.TileLayer) {
 | 
						|
            map.removeLayer(layer);
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    // Add the default layer based on theme
 | 
						|
    if (isDarkMode) {
 | 
						|
        if (window.mapSettings && window.mapSettings.darkModeLayer) {
 | 
						|
            // Use configured dark mode layer
 | 
						|
            const darkLayer = window.mapSettings.darkModeLayer.toLowerCase();
 | 
						|
            if (darkLayer === 'satellite') {
 | 
						|
                L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
 | 
						|
                    attribution: 'Imagery © Esri © ArcGIS',
 | 
						|
                    maxZoom: 19
 | 
						|
                }).addTo(map);
 | 
						|
            } else {
 | 
						|
                L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
 | 
						|
                    attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
 | 
						|
                    subdomains: 'abcd',
 | 
						|
                    maxZoom: 19
 | 
						|
                }).addTo(map);
 | 
						|
            }
 | 
						|
        } else {
 | 
						|
            // Default dark theme
 | 
						|
            L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
 | 
						|
                attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
 | 
						|
                subdomains: 'abcd',
 | 
						|
                maxZoom: 19
 | 
						|
            }).addTo(map);
 | 
						|
        }
 | 
						|
    } else {
 | 
						|
        if (window.mapSettings && window.mapSettings.lightModeLayer) {
 | 
						|
            // Use configured light mode layer
 | 
						|
            const lightLayer = window.mapSettings.lightModeLayer.toLowerCase();
 | 
						|
            if (lightLayer === 'satellite') {
 | 
						|
                L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
 | 
						|
                    attribution: 'Imagery © Esri © ArcGIS',
 | 
						|
                    maxZoom: 19
 | 
						|
                }).addTo(map);
 | 
						|
            } else if (lightLayer === 'terrain') {
 | 
						|
                L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
 | 
						|
                    attribution: 'Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: © <a href="https://opentopomap.org">OpenTopoMap</a>',
 | 
						|
                    maxZoom: 17
 | 
						|
                }).addTo(map);
 | 
						|
            } else {
 | 
						|
                L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
 | 
						|
                    attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
 | 
						|
                    maxZoom: 19
 | 
						|
                }).addTo(map);
 | 
						|
            }
 | 
						|
        } else {
 | 
						|
            // Default light theme
 | 
						|
            L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
 | 
						|
                attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
 | 
						|
                maxZoom: 19
 | 
						|
            }).addTo(map);
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
function updateMapMarkers() {
 | 
						|
    if (!mapInitialized || !map) return;
 | 
						|
 | 
						|
    // Clear existing markers
 | 
						|
    if (markerCluster) {
 | 
						|
        try {
 | 
						|
            markerCluster.clearLayers();
 | 
						|
        } catch (e) {
 | 
						|
            console.error("Error clearing marker cluster:", e);
 | 
						|
            // Fall back to standard markers if cluster fails
 | 
						|
            markerCluster = null;
 | 
						|
            markers.forEach(marker => {
 | 
						|
                try { map.removeLayer(marker); } catch(e) {}
 | 
						|
            });
 | 
						|
        }
 | 
						|
    } else {
 | 
						|
        markers.forEach(marker => {
 | 
						|
            try { map.removeLayer(marker); } catch(e) {}
 | 
						|
        });
 | 
						|
    }
 | 
						|
    markers = [];
 | 
						|
 | 
						|
    // Variables to track bounds for auto-zooming
 | 
						|
    let validCoordinates = false;
 | 
						|
    const bounds = L.latLngBounds();
 | 
						|
 | 
						|
    // Add markers for each station
 | 
						|
    stationsData.forEach(station => {
 | 
						|
        // Skip stations without coordinates
 | 
						|
        if (!station.coordinates || !station.coordinates.lat || !station.coordinates.lon) {
 | 
						|
            console.log(`Station ${station.network}_${station.station} has no coordinates`);
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        validCoordinates = true;
 | 
						|
        const lat = station.coordinates.lat;
 | 
						|
        const lon = station.coordinates.lon;
 | 
						|
 | 
						|
        // Add to bounds for auto-zooming
 | 
						|
        bounds.extend([lat, lon]);
 | 
						|
 | 
						|
        // Create marker with appropriate color based on status
 | 
						|
        const markerColor = getStatusColor(station.status);
 | 
						|
 | 
						|
        // Create marker with a badge if it's using LH channels
 | 
						|
        const isLH = station.primaryChannelType === 'LH';
 | 
						|
        const markerIcon = L.divIcon({
 | 
						|
            html: isLH
 | 
						|
                ? `<div style="background-color: ${markerColor}; width: 14px; height: 14px; border-radius: 50%; border: 2px solid white; position: relative;">
 | 
						|
                      <span style="position: absolute; top: -8px; right: -8px; background: #f3f4f6; border-radius: 50%; width: 10px; height: 10px; font-size: 7px; display: flex; align-items: center; justify-content: center; font-weight: bold; color: #1f2937;">L</span>
 | 
						|
                   </div>`
 | 
						|
                : `<div style="background-color: ${markerColor}; width: 14px; height: 14px; border-radius: 50%; border: 2px solid white;"></div>`,
 | 
						|
            className: 'station-marker',
 | 
						|
            iconSize: [18, 18],
 | 
						|
            iconAnchor: [9, 9]
 | 
						|
        });
 | 
						|
 | 
						|
        const marker = L.marker([lat, lon], {
 | 
						|
            icon: markerIcon,
 | 
						|
            title: `${station.network}_${station.station}`,
 | 
						|
            status: station.status // Store status for cluster coloring
 | 
						|
        });
 | 
						|
 | 
						|
        // Create channel group summary for popup
 | 
						|
        const channelGroupsText = station.channels.reduce((groups, channel) => {
 | 
						|
            const type = channel.channelType || 'other';
 | 
						|
            if (!groups[type]) groups[type] = 0;
 | 
						|
            groups[type]++;
 | 
						|
            return groups;
 | 
						|
        }, {});
 | 
						|
 | 
						|
        const channelGroupsHTML = Object.entries(channelGroupsText)
 | 
						|
            .map(([type, count]) => `${type}: ${count}`)
 | 
						|
            .join(', ');
 | 
						|
 | 
						|
        // Add popup with station info
 | 
						|
        marker.bindPopup(`
 | 
						|
            <strong>${station.network}_${station.station}</strong><br>
 | 
						|
            Primary channel type: <b>${station.primaryChannelType || 'N/A'}</b><br>
 | 
						|
            Status: ${formatStatus(station.status, station.primaryChannelType)}<br>
 | 
						|
            Latency: ${formatLatency(station.latency)}<br>
 | 
						|
            Channels: ${channelGroupsHTML}<br>
 | 
						|
            Coordinates: ${lat.toFixed(4)}, ${lon.toFixed(4)}
 | 
						|
            ${station.coordinates.elevation ? '<br>Elevation: ' + station.coordinates.elevation.toFixed(1) + ' m' : ''}
 | 
						|
            <br><a href="${station.network}_${station.station}.html" target="_blank">View Details</a>
 | 
						|
        `);
 | 
						|
 | 
						|
        // Add to the cluster group or directly to the map
 | 
						|
        try {
 | 
						|
            if (markerCluster) {
 | 
						|
                markerCluster.addLayer(marker);
 | 
						|
            } else {
 | 
						|
                marker.addTo(map);
 | 
						|
            }
 | 
						|
            markers.push(marker);
 | 
						|
        } catch (e) {
 | 
						|
            console.error("Error adding marker:", e);
 | 
						|
            // If cluster fails, try adding directly to map
 | 
						|
            try {
 | 
						|
                marker.addTo(map);
 | 
						|
                markers.push(marker);
 | 
						|
            } catch (e2) {
 | 
						|
                console.error("Also failed to add directly to map:", e2);
 | 
						|
            }
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    // Auto-zoom to fit all markers if we have valid coordinates
 | 
						|
    if (validCoordinates && markers.length > 0) {
 | 
						|
        // Don't zoom too close if there's only one station
 | 
						|
        if (markers.length === 1) {
 | 
						|
            map.setView(bounds.getCenter(), 8);
 | 
						|
        } else {
 | 
						|
            try {
 | 
						|
                map.fitBounds(bounds, {
 | 
						|
                    padding: [30, 30],
 | 
						|
                    maxZoom: 12
 | 
						|
                });
 | 
						|
            } catch (e) {
 | 
						|
                console.error("Error fitting bounds:", e);
 | 
						|
                // Fallback to a default view
 | 
						|
                map.setView([20, 0], 2);
 | 
						|
            }
 | 
						|
        }
 | 
						|
    } else if (!validCoordinates && markers.length === 0) {
 | 
						|
        // Show message if no stations have coordinates
 | 
						|
        const noCoordinatesMsg = document.createElement('div');
 | 
						|
        noCoordinatesMsg.className = 'error-message';
 | 
						|
        noCoordinatesMsg.style.position = 'absolute';
 | 
						|
        noCoordinatesMsg.style.top = '50%';
 | 
						|
        noCoordinatesMsg.style.left = '50%';
 | 
						|
        noCoordinatesMsg.style.transform = 'translate(-50%, -50%)';
 | 
						|
        noCoordinatesMsg.style.background = 'rgba(255, 255, 255, 0.9)';
 | 
						|
        noCoordinatesMsg.style.padding = '15px';
 | 
						|
        noCoordinatesMsg.style.borderRadius = '8px';
 | 
						|
        noCoordinatesMsg.style.zIndex = 1000;
 | 
						|
        noCoordinatesMsg.innerHTML = `
 | 
						|
            <p><strong>No station coordinates available</strong></p>
 | 
						|
            <p>Make sure your FDSNWS service is properly configured and accessible.</p>
 | 
						|
        `;
 | 
						|
        document.getElementById('map-container').appendChild(noCoordinatesMsg);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Custom legend for the map
 | 
						|
function addMapLegend() {
 | 
						|
    if (!mapInitialized || !map) return;
 | 
						|
 | 
						|
    // Remove existing legend if any
 | 
						|
    const existingLegend = document.querySelector('.map-legend');
 | 
						|
    if (existingLegend) {
 | 
						|
        existingLegend.remove();
 | 
						|
    }
 | 
						|
 | 
						|
    // Create a custom legend
 | 
						|
    const legend = L.control({position: 'bottomright'});
 | 
						|
 | 
						|
    legend.onAdd = function() {
 | 
						|
        const div = L.DomUtil.create('div', 'map-legend');
 | 
						|
        div.innerHTML = `
 | 
						|
            <h4>Station Status</h4>
 | 
						|
            <div><span style="background-color: #FFFFFF"></span> Good (≤ 1 min)</div>
 | 
						|
            <div><span style="background-color: #c084fc"></span> > 1 min</div>
 | 
						|
            <div><span style="background-color: #8b5cf6"></span> > 10 min</div>
 | 
						|
            <div><span style="background-color: #3b82f6"></span> > 30 min</div>
 | 
						|
            <div><span style="background-color: #10b981"></span> > 1 hour</div>
 | 
						|
            <div><span style="background-color: #fbbf24"></span> > 2 hours</div>
 | 
						|
            <div><span style="background-color: #f97316"></span> > 6 hours</div>
 | 
						|
            <div><span style="background-color: #ef4444"></span> > 1 day</div>
 | 
						|
        `;
 | 
						|
 | 
						|
        // Add custom styles to the legend
 | 
						|
        const style = document.createElement('style');
 | 
						|
        style.textContent = `
 | 
						|
            .map-legend {
 | 
						|
                padding: 10px;
 | 
						|
                background: white;
 | 
						|
                background: rgba(255, 255, 255, 0.9);
 | 
						|
                border-radius: 5px;
 | 
						|
                line-height: 1.8;
 | 
						|
                color: #333;
 | 
						|
                box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
 | 
						|
            }
 | 
						|
            .dark-mode .map-legend {
 | 
						|
                background: rgba(31, 41, 55, 0.9);
 | 
						|
                color: #f9fafb;
 | 
						|
            }
 | 
						|
            .map-legend h4 {
 | 
						|
                margin: 0 0 5px;
 | 
						|
                font-size: 14px;
 | 
						|
                font-weight: 600;
 | 
						|
            }
 | 
						|
            .map-legend div {
 | 
						|
                display: flex;
 | 
						|
                align-items: center;
 | 
						|
                font-size: 12px;
 | 
						|
                margin-bottom: 3px;
 | 
						|
            }
 | 
						|
            .map-legend span {
 | 
						|
                display: inline-block;
 | 
						|
                width: 16px;
 | 
						|
                height: 16px;
 | 
						|
                margin-right: 8px;
 | 
						|
                border-radius: 50%;
 | 
						|
                border: 1px solid rgba(0, 0, 0, 0.2);
 | 
						|
            }
 | 
						|
            .dark-mode .map-legend span {
 | 
						|
                border-color: rgba(255, 255, 255, 0.2);
 | 
						|
            }
 | 
						|
        `;
 | 
						|
 | 
						|
        div.appendChild(style);
 | 
						|
        return div;
 | 
						|
    };
 | 
						|
 | 
						|
    legend.addTo(map);
 | 
						|
}
 | 
						|
function setupFilters() {
 | 
						|
    // Add a channel type filter to the filters area
 | 
						|
    const filtersArea = document.querySelector('.filters');
 | 
						|
 | 
						|
    if (!filtersArea) return;
 | 
						|
 | 
						|
    // Check if the filter already exists
 | 
						|
    if (!document.getElementById('channel-filter')) {
 | 
						|
        const channelFilterGroup = document.createElement('div');
 | 
						|
        channelFilterGroup.className = 'filter-group';
 | 
						|
        channelFilterGroup.innerHTML = `
 | 
						|
            <label for="channel-filter">Channel Type:</label>
 | 
						|
            <select id="channel-filter">
 | 
						|
                <option value="">All Types</option>
 | 
						|
                <option value="HH">HH (High Frequency)</option>
 | 
						|
                <option value="BH">BH (Broadband)</option>
 | 
						|
                <option value="LH">LH (Long Period)</option>
 | 
						|
                <option value="SH">SH (Short Period)</option>
 | 
						|
                <option value="EH">EH (Extremely Short Period)</option>
 | 
						|
                <option value="other">Other</option>
 | 
						|
            </select>
 | 
						|
        `;
 | 
						|
 | 
						|
        filtersArea.appendChild(channelFilterGroup);
 | 
						|
 | 
						|
        // Add event listener
 | 
						|
        document.getElementById('channel-filter').addEventListener('change', applyFilters);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Enhanced station filters for the map
 | 
						|
function setupMapFilters() {
 | 
						|
    if (!mapInitialized || !map) return;
 | 
						|
 | 
						|
    const mapFilters = L.control({position: 'topleft'});
 | 
						|
 | 
						|
    mapFilters.onAdd = function() {
 | 
						|
        const div = L.DomUtil.create('div', 'map-filters');
 | 
						|
        div.innerHTML = `
 | 
						|
            <div class="filter-select">
 | 
						|
                <label for="map-network-filter">Network:</label>
 | 
						|
                <select id="map-network-filter">
 | 
						|
                    <option value="">All Networks</option>
 | 
						|
                </select>
 | 
						|
            </div>
 | 
						|
            <div class="filter-select">
 | 
						|
                <label for="map-status-filter">Status:</label>
 | 
						|
                <select id="map-status-filter">
 | 
						|
                    <option value="">All Statuses</option>
 | 
						|
                    <option value="good">Good</option>
 | 
						|
                    <option value="warning">Warning</option>
 | 
						|
                    <option value="critical">Critical</option>
 | 
						|
                    <option value="unavailable">Unavailable</option>
 | 
						|
                </select>
 | 
						|
            </div>
 | 
						|
        `;
 | 
						|
 | 
						|
        // Add styles
 | 
						|
        const style = document.createElement('style');
 | 
						|
        style.textContent = `
 | 
						|
            .map-filters {
 | 
						|
                padding: 10px;
 | 
						|
                background: rgba(255, 255, 255, 0.9);
 | 
						|
                border-radius: 5px;
 | 
						|
                box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
 | 
						|
                width: 200px;
 | 
						|
            }
 | 
						|
            .dark-mode .map-filters {
 | 
						|
                background: rgba(31, 41, 55, 0.9);
 | 
						|
                color: #f9fafb;
 | 
						|
            }
 | 
						|
            .map-filters .filter-select {
 | 
						|
                margin-bottom: 8px;
 | 
						|
            }
 | 
						|
            .map-filters label {
 | 
						|
                display: block;
 | 
						|
                margin-bottom: 3px;
 | 
						|
                font-weight: 500;
 | 
						|
                font-size: 12px;
 | 
						|
            }
 | 
						|
            .map-filters select {
 | 
						|
                width: 100%;
 | 
						|
                padding: 4px 8px;
 | 
						|
                border-radius: 4px;
 | 
						|
                border: 1px solid #ddd;
 | 
						|
                font-size: 12px;
 | 
						|
            }
 | 
						|
            .dark-mode .map-filters select {
 | 
						|
                background: #374151;
 | 
						|
                color: #f9fafb;
 | 
						|
                border-color: #4b5563;
 | 
						|
            }
 | 
						|
        `;
 | 
						|
 | 
						|
        div.appendChild(style);
 | 
						|
 | 
						|
        // Prevent map interactions when using the filters
 | 
						|
        L.DomEvent.disableClickPropagation(div);
 | 
						|
        L.DomEvent.disableScrollPropagation(div);
 | 
						|
 | 
						|
        // Setup network filter options
 | 
						|
        const networkFilter = div.querySelector('#map-network-filter');
 | 
						|
        const networks = [...new Set(stationsData.map(station => station.network))].sort();
 | 
						|
 | 
						|
        networks.forEach(network => {
 | 
						|
            const option = document.createElement('option');
 | 
						|
            option.value = network;
 | 
						|
            option.textContent = network;
 | 
						|
            networkFilter.appendChild(option);
 | 
						|
        });
 | 
						|
 | 
						|
        // Add event listeners
 | 
						|
        networkFilter.addEventListener('change', function() {
 | 
						|
            const selectedNetwork = this.value;
 | 
						|
            updateMapMarkersFilter(selectedNetwork, div.querySelector('#map-status-filter').value);
 | 
						|
        });
 | 
						|
 | 
						|
        div.querySelector('#map-status-filter').addEventListener('change', function() {
 | 
						|
            const selectedStatus = this.value;
 | 
						|
            updateMapMarkersFilter(networkFilter.value, selectedStatus);
 | 
						|
        });
 | 
						|
 | 
						|
        return div;
 | 
						|
    };
 | 
						|
 | 
						|
    mapFilters.addTo(map);
 | 
						|
}
 | 
						|
 | 
						|
// Filter map markers based on selected criteria
 | 
						|
function updateMapMarkersFilter(network, status) {
 | 
						|
    if (!mapInitialized || !map) return;
 | 
						|
 | 
						|
    // Clear existing markers
 | 
						|
    markers.forEach(marker => map.removeLayer(marker));
 | 
						|
    markers = [];
 | 
						|
 | 
						|
    // Apply filters to data
 | 
						|
    let filteredData = stationsData;
 | 
						|
 | 
						|
    if (network) {
 | 
						|
        filteredData = filteredData.filter(station => station.network === network);
 | 
						|
    }
 | 
						|
 | 
						|
    if (status) {
 | 
						|
        filteredData = filteredData.filter(station => {
 | 
						|
            if (status === 'good') {
 | 
						|
                return station.status === 'good';
 | 
						|
            } else if (status === 'warning') {
 | 
						|
                return ['delayed', 'long-delayed', 'very-delayed', 'hour-delayed', 'warning'].includes(station.status);
 | 
						|
            } else if (status === 'critical') {
 | 
						|
                return ['critical', 'day-delayed', 'multi-day', 'three-day', 'four-day'].includes(station.status);
 | 
						|
            } else if (status === 'unavailable') {
 | 
						|
                return station.status === 'unavailable';
 | 
						|
            }
 | 
						|
            return true;
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    // Add filtered markers
 | 
						|
    const bounds = L.latLngBounds();
 | 
						|
    let validCoordinates = false;
 | 
						|
 | 
						|
    filteredData.forEach(station => {
 | 
						|
        // Skip stations without coordinates
 | 
						|
        if (!station.coordinates || !station.coordinates.lat || !station.coordinates.lon) return;
 | 
						|
 | 
						|
        validCoordinates = true;
 | 
						|
        const lat = station.coordinates.lat;
 | 
						|
        const lon = station.coordinates.lon;
 | 
						|
 | 
						|
        // Add to bounds for auto-zooming
 | 
						|
        bounds.extend([lat, lon]);
 | 
						|
 | 
						|
        // Create marker with appropriate color
 | 
						|
        const markerColor = getStatusColor(station.status);
 | 
						|
        const markerIcon = L.divIcon({
 | 
						|
            html: `<div style="background-color: ${markerColor}; width: 14px; height: 14px; border-radius: 50%; border: 2px solid white;"></div>`,
 | 
						|
            className: 'station-marker',
 | 
						|
            iconSize: [18, 18],
 | 
						|
            iconAnchor: [9, 9]
 | 
						|
        });
 | 
						|
 | 
						|
        const marker = L.marker([lat, lon], {
 | 
						|
            icon: markerIcon,
 | 
						|
            title: `${station.network}_${station.station}`
 | 
						|
        });
 | 
						|
 | 
						|
        // Add popup with station info
 | 
						|
        marker.bindPopup(`
 | 
						|
            <strong>${station.network}_${station.station}</strong><br>
 | 
						|
            Status: ${formatStatus(station.status)}<br>
 | 
						|
            Latency: ${formatLatency(station.latency)}<br>
 | 
						|
            Coordinates: ${lat.toFixed(4)}, ${lon.toFixed(4)}
 | 
						|
            ${station.coordinates.elevation ? '<br>Elevation: ' + station.coordinates.elevation.toFixed(1) + ' m' : ''}
 | 
						|
            <br><a href="${station.network}_${station.station}.html" target="_blank">View Details</a>
 | 
						|
        `);
 | 
						|
 | 
						|
        marker.addTo(map);
 | 
						|
        markers.push(marker);
 | 
						|
    });
 | 
						|
 | 
						|
    // Auto-zoom to fit all markers if we have valid coordinates
 | 
						|
    if (validCoordinates && markers.length > 0) {
 | 
						|
        // Don't zoom too close if there's only one station
 | 
						|
        if (markers.length === 1) {
 | 
						|
            map.setView(bounds.getCenter(), 8);
 | 
						|
        } else {
 | 
						|
            map.fitBounds(bounds, {
 | 
						|
                padding: [30, 30],
 | 
						|
                maxZoom: 12
 | 
						|
            });
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Enhanced version of the setActiveView function to handle map initialization
 | 
						|
function setActiveView() {
 | 
						|
    // Extract view from URL if present
 | 
						|
    const urlParams = new URLSearchParams(window.location.search);
 | 
						|
    const urlView = urlParams.get('view');
 | 
						|
 | 
						|
    if (urlView && ['table', 'grid', 'map'].includes(urlView)) {
 | 
						|
        viewMode = urlView;
 | 
						|
    }
 | 
						|
 | 
						|
    // Set active class on the appropriate link
 | 
						|
    document.querySelectorAll('.view-toggle a').forEach(link => {
 | 
						|
        if (link.getAttribute('data-view') === viewMode) {
 | 
						|
            link.classList.add('active');
 | 
						|
        } else {
 | 
						|
            link.classList.remove('active');
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    // Show the appropriate view container
 | 
						|
    document.querySelectorAll('.view-container').forEach(container => {
 | 
						|
        if (container.id === `${viewMode}-view`) {
 | 
						|
            container.style.display = 'block';
 | 
						|
        } else {
 | 
						|
            container.style.display = 'none';
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    // Initialize map if needed
 | 
						|
    if (viewMode === 'map') {
 | 
						|
        if (!mapInitialized && typeof L !== 'undefined') {
 | 
						|
            initializeMap();
 | 
						|
            // Add map-specific UI elements after initialization
 | 
						|
            setTimeout(() => {
 | 
						|
                addMapLegend();
 | 
						|
                setupMapFilters();
 | 
						|
            }, 100);
 | 
						|
        } else if (mapInitialized) {
 | 
						|
            // If map is already initialized, ensure it's up to date
 | 
						|
            map.invalidateSize();
 | 
						|
            updateMapMarkers();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    // Save preference
 | 
						|
    localStorage.setItem('seedlink-view-mode', viewMode);
 | 
						|
}
 | 
						|
 | 
						|
// Function to fetch data from the server
 | 
						|
function fetchData() {
 | 
						|
    if (isRefreshing) return;
 | 
						|
 | 
						|
    isRefreshing = true;
 | 
						|
    lastRefreshTime = Date.now();
 | 
						|
 | 
						|
    // Show loading state
 | 
						|
    //document.getElementById('loading').style.display = 'flex';
 | 
						|
    document.getElementById('error-message').style.display = 'none';
 | 
						|
    document.getElementById('refresh-status').textContent = 'Refreshing...';
 | 
						|
 | 
						|
    // Use cache-busting to prevent stale data
 | 
						|
    const timestamp = Date.now();
 | 
						|
 | 
						|
    // Fetch the JSON data
 | 
						|
    fetch(`stations_data.json?_=${timestamp}`, {
 | 
						|
        cache: 'no-cache',
 | 
						|
        headers: {
 | 
						|
            'Cache-Control': 'no-cache, no-store, must-revalidate',
 | 
						|
            'Pragma': 'no-cache',
 | 
						|
            'Expires': '0'
 | 
						|
        }
 | 
						|
    })
 | 
						|
    .then(response => {
 | 
						|
        if (!response.ok) {
 | 
						|
            throw new Error(`Server returned ${response.status}: ${response.statusText}`);
 | 
						|
        }
 | 
						|
        return response.json();
 | 
						|
    })
 | 
						|
    .then(data => {
 | 
						|
        stationsData = data;
 | 
						|
 | 
						|
        // Update the filter dropdowns
 | 
						|
        updateFilters();
 | 
						|
 | 
						|
        // Render the data based on current view
 | 
						|
        renderData();
 | 
						|
 | 
						|
        // Update timestamp
 | 
						|
        const updateTime = new Date().toUTCString();
 | 
						|
        document.getElementById('update-time').textContent = updateTime;
 | 
						|
        document.getElementById('refresh-status').textContent = 'Last refresh: ' + new Date().toLocaleTimeString();
 | 
						|
 | 
						|
        // Hide loading state
 | 
						|
        document.getElementById('loading').style.display = 'none';
 | 
						|
        document.getElementById('error-message').style.display = 'none';
 | 
						|
 | 
						|
        // Setup next refresh
 | 
						|
        setupNextRefresh();
 | 
						|
    })
 | 
						|
    .catch(error => {
 | 
						|
        console.error('Error fetching data:', error);
 | 
						|
        document.getElementById('error-message').textContent = `Error loading data: ${error.message}`;
 | 
						|
        document.getElementById('error-message').style.display = 'block';
 | 
						|
        document.getElementById('loading').style.display = 'none';
 | 
						|
 | 
						|
        // Still setup next refresh to try again
 | 
						|
        setupNextRefresh();
 | 
						|
    })
 | 
						|
    .finally(() => {
 | 
						|
        isRefreshing = false;
 | 
						|
    });
 | 
						|
}
 | 
						|
 | 
						|
// Function to update the filter dropdowns based on available data
 | 
						|
function updateFilters() {
 | 
						|
    // Get unique networks
 | 
						|
    const networks = [...new Set(stationsData.map(station => station.network))].sort();
 | 
						|
 | 
						|
    // Update network filter
 | 
						|
    const networkFilter = document.getElementById('network-filter');
 | 
						|
    const selectedNetwork = networkFilter.value;
 | 
						|
 | 
						|
    // Clear existing options except the first one
 | 
						|
    while (networkFilter.options.length > 1) {
 | 
						|
        networkFilter.remove(1);
 | 
						|
    }
 | 
						|
 | 
						|
    // Add new options
 | 
						|
    networks.forEach(network => {
 | 
						|
        const option = document.createElement('option');
 | 
						|
        option.value = network;
 | 
						|
        option.textContent = network;
 | 
						|
        networkFilter.appendChild(option);
 | 
						|
    });
 | 
						|
 | 
						|
    // Restore selection if possible
 | 
						|
    if (selectedNetwork && networks.includes(selectedNetwork)) {
 | 
						|
        networkFilter.value = selectedNetwork;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Function to apply filters to the data
 | 
						|
function applyFilters() {
 | 
						|
    const networkFilter = document.getElementById('network-filter').value;
 | 
						|
    const statusFilter = document.getElementById('status-filter').value;
 | 
						|
    const channelFilter = document.getElementById('channel-filter')?.value || '';
 | 
						|
    const searchText = document.getElementById('search-input').value.toLowerCase();
 | 
						|
 | 
						|
    // Apply filters to data
 | 
						|
    let filteredData = stationsData;
 | 
						|
 | 
						|
    if (networkFilter) {
 | 
						|
        filteredData = filteredData.filter(station => station.network === networkFilter);
 | 
						|
    }
 | 
						|
 | 
						|
    if (statusFilter) {
 | 
						|
        filteredData = filteredData.filter(station => {
 | 
						|
            if (statusFilter === 'good') {
 | 
						|
                return station.status === 'good';
 | 
						|
            } else if (statusFilter === 'warning') {
 | 
						|
                return ['delayed', 'long-delayed', 'very-delayed', 'hour-delayed', 'warning'].includes(station.status);
 | 
						|
            } else if (statusFilter === 'critical') {
 | 
						|
                return ['critical', 'day-delayed', 'multi-day', 'three-day', 'four-day'].includes(station.status);
 | 
						|
            } else if (statusFilter === 'unavailable') {
 | 
						|
                return station.status === 'unavailable';
 | 
						|
            }
 | 
						|
            return true;
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    if (channelFilter) {
 | 
						|
        filteredData = filteredData.filter(station =>
 | 
						|
            station.primaryChannelType === channelFilter ||
 | 
						|
            (channelFilter === 'other' && !['HH', 'BH', 'LH', 'SH', 'EH'].includes(station.primaryChannelType))
 | 
						|
        );
 | 
						|
    }
 | 
						|
 | 
						|
    if (searchText) {
 | 
						|
        filteredData = filteredData.filter(station =>
 | 
						|
            `${station.network}_${station.station}`.toLowerCase().includes(searchText)
 | 
						|
        );
 | 
						|
    }
 | 
						|
 | 
						|
    // Render filtered data
 | 
						|
    renderData(filteredData);
 | 
						|
}
 | 
						|
 | 
						|
// Function to render the data in the current view
 | 
						|
function renderData(data = stationsData) {
 | 
						|
    // Default to all data if not specified
 | 
						|
    const displayData = data || stationsData;
 | 
						|
 | 
						|
    // Render based on current view mode
 | 
						|
    if (viewMode === 'table') {
 | 
						|
        renderTableView(displayData);
 | 
						|
    } else if (viewMode === 'grid') {
 | 
						|
        renderGridView(displayData);
 | 
						|
    } else if (viewMode === 'map') {
 | 
						|
        // Update map markers if map is initialized
 | 
						|
        if (mapInitialized) {
 | 
						|
            updateMapMarkers();
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Function to render table view
 | 
						|
function renderTableView(data) {
 | 
						|
    const tableBody = document.getElementById('table-body');
 | 
						|
    tableBody.innerHTML = '';
 | 
						|
 | 
						|
    data.forEach(station => {
 | 
						|
        const row = document.createElement('tr');
 | 
						|
 | 
						|
        // Network-Station cell
 | 
						|
        const nameCell = document.createElement('td');
 | 
						|
        nameCell.innerHTML = `<small>${station.network}</small> <a href="${station.network}_${station.station}.html">${station.station}</a>`;
 | 
						|
        // Add a badge for primary channel type
 | 
						|
        if (station.primaryChannelType) {
 | 
						|
            nameCell.innerHTML += ` <span class="channel-badge">${station.primaryChannelType}</span>`;
 | 
						|
        }
 | 
						|
        row.appendChild(nameCell);
 | 
						|
 | 
						|
        // Status cell
 | 
						|
        const statusCell = document.createElement('td');
 | 
						|
        statusCell.classList.add(`station-${station.status}`);
 | 
						|
        statusCell.textContent = formatStatus(station.status, station.primaryChannelType);
 | 
						|
        row.appendChild(statusCell);
 | 
						|
 | 
						|
        // Latency cell
 | 
						|
        const latencyCell = document.createElement('td');
 | 
						|
        latencyCell.textContent = formatLatency(station.latency);
 | 
						|
        latencyCell.style.backgroundColor = getStatusColor(station.status);
 | 
						|
        row.appendChild(latencyCell);
 | 
						|
 | 
						|
        // Channels cell
 | 
						|
        const channelsCell = document.createElement('td');
 | 
						|
 | 
						|
        // Create channel type summary
 | 
						|
        const channelGroups = {};
 | 
						|
        station.channels.forEach(channel => {
 | 
						|
            const type = channel.channelType || 'other';
 | 
						|
            if (!channelGroups[type]) {
 | 
						|
                channelGroups[type] = 0;
 | 
						|
            }
 | 
						|
            channelGroups[type]++;
 | 
						|
        });
 | 
						|
 | 
						|
        // Format channel groups
 | 
						|
        const groupsHTML = Object.keys(channelGroups).map(type =>
 | 
						|
            `<span class="channel-group">${type}: ${channelGroups[type]}</span>`
 | 
						|
        ).join(' ');
 | 
						|
 | 
						|
        channelsCell.innerHTML = `<div>${station.channels.length} total</div><div class="channel-groups">${groupsHTML}</div>`;
 | 
						|
        row.appendChild(channelsCell);
 | 
						|
 | 
						|
        // Last updated cell
 | 
						|
        const lastDataCell = document.createElement('td');
 | 
						|
        if (station.channels.length > 0 && station.channels[0].last_data) {
 | 
						|
            const lastDataTime = new Date(station.channels[0].last_data);
 | 
						|
            lastDataCell.textContent = lastDataTime.toLocaleString();
 | 
						|
        } else {
 | 
						|
            lastDataCell.textContent = 'Unknown';
 | 
						|
        }
 | 
						|
        row.appendChild(lastDataCell);
 | 
						|
 | 
						|
        tableBody.appendChild(row);
 | 
						|
    });
 | 
						|
 | 
						|
    // Add CSS for channel badges if not already added
 | 
						|
    if (!document.getElementById('channel-badges-css')) {
 | 
						|
        const style = document.createElement('style');
 | 
						|
        style.id = 'channel-badges-css';
 | 
						|
        style.textContent = `
 | 
						|
            .channel-badge {
 | 
						|
                background-color: var(--bg-tertiary);
 | 
						|
                color: var(--text-secondary);
 | 
						|
                font-size: 10px;
 | 
						|
                padding: 2px 4px;
 | 
						|
                border-radius: 4px;
 | 
						|
                margin-left: 4px;
 | 
						|
                font-weight: 500;
 | 
						|
                vertical-align: middle;
 | 
						|
            }
 | 
						|
 | 
						|
            .channel-groups {
 | 
						|
                display: flex;
 | 
						|
                flex-wrap: wrap;
 | 
						|
                gap: 4px;
 | 
						|
                margin-top: 2px;
 | 
						|
                font-size: 11px;
 | 
						|
            }
 | 
						|
 | 
						|
            .channel-group {
 | 
						|
                background-color: var(--bg-tertiary);
 | 
						|
                border-radius: 3px;
 | 
						|
                padding: 1px 4px;
 | 
						|
                color: var(--text-secondary);
 | 
						|
            }
 | 
						|
        `;
 | 
						|
        document.head.appendChild(style);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Function to render grid view
 | 
						|
function renderGridView(data) {
 | 
						|
    const gridContainer = document.getElementById('grid-container');
 | 
						|
    gridContainer.innerHTML = '';
 | 
						|
 | 
						|
    // Group stations by network
 | 
						|
    const networks = {};
 | 
						|
    data.forEach(station => {
 | 
						|
        if (!networks[station.network]) {
 | 
						|
            networks[station.network] = [];
 | 
						|
        }
 | 
						|
        networks[station.network].push(station);
 | 
						|
    });
 | 
						|
 | 
						|
    // Sort networks by name
 | 
						|
    const sortedNetworks = Object.keys(networks).sort();
 | 
						|
 | 
						|
    for (const network of sortedNetworks) {
 | 
						|
        const stations = networks[network];
 | 
						|
 | 
						|
        // Create a row for the network
 | 
						|
        const networkRow = document.createElement('div');
 | 
						|
        networkRow.className = 'grid-row';
 | 
						|
 | 
						|
        // Add network label as a separate cell
 | 
						|
        const networkLabel = document.createElement('div');
 | 
						|
        networkLabel.className = 'network-label';
 | 
						|
        networkLabel.textContent = network;
 | 
						|
        networkRow.appendChild(networkLabel);
 | 
						|
 | 
						|
        // Create a container for the stations
 | 
						|
        const stationsContainer = document.createElement('div');
 | 
						|
        stationsContainer.className = 'stations-container';
 | 
						|
 | 
						|
        // Create a row for the stations
 | 
						|
        const stationsRow = document.createElement('div');
 | 
						|
        stationsRow.className = 'stations-row';
 | 
						|
 | 
						|
        // Sort stations by name
 | 
						|
        stations.sort((a, b) => a.station.localeCompare(b.station));
 | 
						|
 | 
						|
        // Add stations
 | 
						|
        for (const station of stations) {
 | 
						|
            const stationCell = document.createElement('a');
 | 
						|
            stationCell.className = `grid-cell station-${station.status}`;
 | 
						|
            stationCell.href = `${station.network}_${station.station}.html`;
 | 
						|
            stationCell.textContent = station.station;
 | 
						|
            stationCell.setAttribute('data-tooltip', `${station.network}_${station.station}: ${formatLatency(station.latency)}`);
 | 
						|
            stationCell.setAttribute('data-network', station.network);
 | 
						|
            stationCell.setAttribute('data-station', station.station);
 | 
						|
            stationCell.setAttribute('data-status', station.status);
 | 
						|
            stationCell.setAttribute('data-latency', formatLatency(station.latency));
 | 
						|
            stationsRow.appendChild(stationCell);
 | 
						|
        }
 | 
						|
 | 
						|
        stationsContainer.appendChild(stationsRow);
 | 
						|
        networkRow.appendChild(stationsContainer);
 | 
						|
        gridContainer.appendChild(networkRow);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Function to format latency for display
 | 
						|
function formatLatency(seconds) {
 | 
						|
    if (seconds === null || seconds === undefined) return 'n/a';
 | 
						|
 | 
						|
    if (seconds > 86400) return `${(seconds/86400).toFixed(1)} d`;
 | 
						|
    if (seconds > 3600) return `${(seconds/3600).toFixed(1)} h`;
 | 
						|
    if (seconds > 60) return `${(seconds/60).toFixed(1)} m`;
 | 
						|
    return `${seconds.toFixed(1)} s`;
 | 
						|
}
 | 
						|
 | 
						|
// Function to format status for display
 | 
						|
function formatStatus(status, channelType) {
 | 
						|
    const isLH = channelType === 'LH';
 | 
						|
 | 
						|
    // Add (LH) tag to status labels for LH channels
 | 
						|
    const lhSuffix = isLH ? ' (LH)' : '';
 | 
						|
 | 
						|
    if (status === 'good') return 'Good' + lhSuffix;
 | 
						|
    if (status === 'delayed') return 'Delayed (>1m)' + lhSuffix;
 | 
						|
    if (status === 'long-delayed') return 'Delayed (>10m)' + lhSuffix;
 | 
						|
    if (status === 'very-delayed') return 'Delayed (>30m)' + lhSuffix;
 | 
						|
    if (status === 'hour-delayed') return 'Delayed (>1h)' + lhSuffix;
 | 
						|
    if (status === 'warning') return 'Warning (>2h)' + lhSuffix;
 | 
						|
    if (status === 'critical') return 'Critical (>6h)' + lhSuffix;
 | 
						|
    if (status === 'day-delayed') return 'Delayed (>1d)' + lhSuffix;
 | 
						|
    if (status === 'multi-day') return 'Delayed (>2d)' + lhSuffix;
 | 
						|
    if (status === 'three-day') return 'Delayed (>3d)' + lhSuffix;
 | 
						|
    if (status === 'four-day') return 'Delayed (>4d)' + lhSuffix;
 | 
						|
    if (status === 'unavailable') return 'Unavailable (>5d)' + lhSuffix;
 | 
						|
    return status.charAt(0).toUpperCase() + status.slice(1) + lhSuffix;
 | 
						|
}
 | 
						|
 | 
						|
// Function to get color for a status
 | 
						|
function getStatusColor(status) {
 | 
						|
    const colors = {
 | 
						|
        'good': '#FFFFFF',
 | 
						|
        'delayed': '#EBD6FF',
 | 
						|
        'long-delayed': '#9470BB',
 | 
						|
        'very-delayed': '#3399FF',
 | 
						|
        'hour-delayed': '#00FF00',
 | 
						|
        'warning': '#FFFF00',
 | 
						|
        'critical': '#FF9966',
 | 
						|
        'day-delayed': '#FF3333',
 | 
						|
        'multi-day': '#FFB3B3',
 | 
						|
        'three-day': '#CCCCCC',
 | 
						|
        'four-day': '#999999',
 | 
						|
        'unavailable': '#666666'
 | 
						|
    };
 | 
						|
 | 
						|
    return colors[status] || '#FFFFFF';
 | 
						|
}
 | 
						|
 | 
						|
// Function to update the refresh interval
 | 
						|
function updateRefreshInterval(seconds) {
 | 
						|
    // Update current refresh interval
 | 
						|
    currentRefreshInterval = seconds;
 | 
						|
 | 
						|
    // Clear existing timer
 | 
						|
    if (refreshTimer) {
 | 
						|
        clearTimeout(refreshTimer);
 | 
						|
    }
 | 
						|
 | 
						|
    // Store the preference in localStorage
 | 
						|
    localStorage.setItem('seedlinkRefreshInterval', seconds);
 | 
						|
 | 
						|
    // Set up next refresh
 | 
						|
    setupNextRefresh();
 | 
						|
 | 
						|
    return seconds;
 | 
						|
}
 | 
						|
 | 
						|
// Function to set up the next refresh
 | 
						|
function setupNextRefresh() {
 | 
						|
    // Calculate time until next refresh
 | 
						|
    const timeUntilNextRefresh = Math.max(1000, (currentRefreshInterval * 1000));
 | 
						|
 | 
						|
    // Clear existing timer
 | 
						|
    if (refreshTimer) {
 | 
						|
        clearTimeout(refreshTimer);
 | 
						|
    }
 | 
						|
 | 
						|
    // Set new timer
 | 
						|
    refreshTimer = setTimeout(fetchData, timeUntilNextRefresh);
 | 
						|
 | 
						|
    // Update the display
 | 
						|
    document.getElementById('refresh-interval').value = currentRefreshInterval;
 | 
						|
    document.getElementById('next-refresh').textContent = currentRefreshInterval;
 | 
						|
 | 
						|
    // Start countdown
 | 
						|
    startRefreshCountdown();
 | 
						|
}
 | 
						|
 | 
						|
// Function to start countdown to next refresh
 | 
						|
function startRefreshCountdown() {
 | 
						|
    const countdownElement = document.getElementById('next-refresh');
 | 
						|
    const currentValue = parseInt(countdownElement.textContent);
 | 
						|
 | 
						|
    // Clear any existing interval
 | 
						|
    if (window.countdownInterval) {
 | 
						|
        clearInterval(window.countdownInterval);
 | 
						|
    }
 | 
						|
 | 
						|
    // Set new interval
 | 
						|
    window.countdownInterval = setInterval(() => {
 | 
						|
        const newValue = parseInt(countdownElement.textContent) - 1;
 | 
						|
        if (newValue > 0) {
 | 
						|
            countdownElement.textContent = newValue;
 | 
						|
        } else {
 | 
						|
            clearInterval(window.countdownInterval);
 | 
						|
        }
 | 
						|
    }, 1000);
 | 
						|
}
 | 
						|
 | 
						|
// Function to export data to CSV
 | 
						|
function exportToCsv() {
 | 
						|
    // Create CSV content
 | 
						|
    let csvContent = 'Network,Station,Status,Latency,Channels,Last Updated\\n';
 | 
						|
 | 
						|
    stationsData.forEach(station => {
 | 
						|
        const lastUpdate = station.channels.length > 0 && station.channels[0].last_data
 | 
						|
            ? new Date(station.channels[0].last_data).toISOString()
 | 
						|
            : 'Unknown';
 | 
						|
 | 
						|
        csvContent += `${station.network},${station.station},${station.status},${station.latency},${station.channels.length},${lastUpdate}\\n`;
 | 
						|
    });
 | 
						|
 | 
						|
    // Create download link
 | 
						|
    const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
 | 
						|
    const url = URL.createObjectURL(blob);
 | 
						|
    const link = document.createElement('a');
 | 
						|
    link.setAttribute('href', url);
 | 
						|
    link.setAttribute('download', `seedlink-status-${new Date().toISOString().split('T')[0]}.csv`);
 | 
						|
    link.style.display = 'none';
 | 
						|
    document.body.appendChild(link);
 | 
						|
    link.click();
 | 
						|
    document.body.removeChild(link);
 | 
						|
 | 
						|
    // Clean up
 | 
						|
    setTimeout(() => {
 | 
						|
        URL.revokeObjectURL(url);
 | 
						|
    }, 100);
 | 
						|
}
 | 
						|
 | 
						|
// Function to toggle stats display
 | 
						|
function toggleStats() {
 | 
						|
    const statsContainer = document.getElementById('stats-container');
 | 
						|
 | 
						|
    if (statsContainer.style.display === 'block') {
 | 
						|
        statsContainer.style.display = 'none';
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Show the stats container
 | 
						|
    statsContainer.style.display = 'block';
 | 
						|
 | 
						|
    // Calculate statistics
 | 
						|
    calculateStats();
 | 
						|
}
 | 
						|
 | 
						|
// Function to calculate and display statistics
 | 
						|
function calculateStats() {
 | 
						|
    const networks = {};
 | 
						|
 | 
						|
    let totalStations = stationsData.length;
 | 
						|
    let goodStations = 0;
 | 
						|
    let warningStations = 0;
 | 
						|
    let criticalStations = 0;
 | 
						|
    let unavailableStations = 0;
 | 
						|
 | 
						|
    // Group by network and count statuses
 | 
						|
    stationsData.forEach(station => {
 | 
						|
        // Create network entry if it doesn't exist
 | 
						|
        if (!networks[station.network]) {
 | 
						|
            networks[station.network] = {
 | 
						|
                total: 0,
 | 
						|
                good: 0,
 | 
						|
                warning: 0,
 | 
						|
                critical: 0,
 | 
						|
                unavailable: 0
 | 
						|
            };
 | 
						|
        }
 | 
						|
 | 
						|
        // Count by network
 | 
						|
        networks[station.network].total++;
 | 
						|
 | 
						|
        // Count by status
 | 
						|
        if (station.status === 'good') {
 | 
						|
            networks[station.network].good++;
 | 
						|
            goodStations++;
 | 
						|
        } else if (['delayed', 'long-delayed', 'very-delayed', 'hour-delayed', 'warning'].includes(station.status)) {
 | 
						|
            networks[station.network].warning++;
 | 
						|
            warningStations++;
 | 
						|
        } else if (['critical', 'day-delayed', 'multi-day', 'three-day', 'four-day'].includes(station.status)) {
 | 
						|
            networks[station.network].critical++;
 | 
						|
            criticalStations++;
 | 
						|
        } else if (station.status === 'unavailable') {
 | 
						|
            networks[station.network].unavailable++;
 | 
						|
            unavailableStations++;
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    // Update status counter
 | 
						|
    const statusCounter = document.getElementById('status-counter');
 | 
						|
    statusCounter.innerHTML = `
 | 
						|
        <div style="font-weight: 600; margin-bottom: 10px; font-size: 16px;">
 | 
						|
            ${totalStations - unavailableStations} active of ${totalStations} total stations
 | 
						|
        </div>
 | 
						|
        <div style="display: flex; gap: 20px; margin-bottom: 15px; justify-content: center; flex-wrap: wrap;">
 | 
						|
            <div>
 | 
						|
                <span style="color: var(--text-primary); font-weight: 500;">${goodStations}</span> good
 | 
						|
            </div>
 | 
						|
            <div>
 | 
						|
                <span style="color: var(--status-warning); font-weight: 500;">${warningStations}</span> warning
 | 
						|
            </div>
 | 
						|
            <div>
 | 
						|
                <span style="color: var(--status-critical); font-weight: 500;">${criticalStations}</span> critical
 | 
						|
            </div>
 | 
						|
            <div>
 | 
						|
                <span style="color: var(--status-unavailable); font-weight: 500;">${unavailableStations}</span> unavailable
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
    `;
 | 
						|
 | 
						|
    // Update network stats
 | 
						|
    const networkStats = document.getElementById('network-stats');
 | 
						|
    networkStats.innerHTML = '';
 | 
						|
 | 
						|
    Object.keys(networks).sort().forEach(network => {
 | 
						|
        const stats = networks[network];
 | 
						|
        const activePercentage = Math.round(((stats.total - stats.unavailable) / stats.total) * 100);
 | 
						|
 | 
						|
        const networkStat = document.createElement('div');
 | 
						|
        networkStat.className = 'network-stat';
 | 
						|
 | 
						|
        // Create the name and count display
 | 
						|
        const nameContainer = document.createElement('div');
 | 
						|
        nameContainer.className = 'network-name';
 | 
						|
        nameContainer.innerHTML = `
 | 
						|
            <span>${network}</span>
 | 
						|
            <span>${stats.total - stats.unavailable}/${stats.total}</span>
 | 
						|
        `;
 | 
						|
 | 
						|
        // Create the progress bar
 | 
						|
        const progressContainer = document.createElement('div');
 | 
						|
        progressContainer.className = 'progress-bar';
 | 
						|
 | 
						|
        const progressBar = document.createElement('div');
 | 
						|
        progressBar.className = 'progress';
 | 
						|
        progressBar.style.width = `${activePercentage}%`;
 | 
						|
 | 
						|
        progressContainer.appendChild(progressBar);
 | 
						|
 | 
						|
        networkStat.appendChild(nameContainer);
 | 
						|
        networkStat.appendChild(progressContainer);
 | 
						|
 | 
						|
        networkStats.appendChild(networkStat);
 | 
						|
    });
 | 
						|
}
 | 
						|
 | 
						|
function sortTable(column) {
 | 
						|
    // Get current sort direction from the header
 | 
						|
    const header = document.querySelector(`th[data-sort="${column}"]`);
 | 
						|
    const currentDirection = header.getAttribute('data-direction') || 'asc';
 | 
						|
    const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
 | 
						|
 | 
						|
    // Update all headers to remove sort indicators
 | 
						|
    document.querySelectorAll('th[data-sort]').forEach(th => {
 | 
						|
        th.setAttribute('data-direction', '');
 | 
						|
        th.classList.remove('sort-asc', 'sort-desc');
 | 
						|
    });
 | 
						|
 | 
						|
    // Set direction on current header
 | 
						|
    header.setAttribute('data-direction', newDirection);
 | 
						|
    header.classList.add(`sort-${newDirection}`);
 | 
						|
 | 
						|
    // Sort data based on column
 | 
						|
    stationsData.sort((a, b) => {
 | 
						|
        let valueA, valueB;
 | 
						|
 | 
						|
        if (column === 'name') {
 | 
						|
            valueA = `${a.network}_${a.station}`;
 | 
						|
            valueB = `${b.network}_${b.station}`;
 | 
						|
        } else if (column === 'status') {
 | 
						|
            valueA = a.status;
 | 
						|
            valueB = b.status;
 | 
						|
        } else if (column === 'latency') {
 | 
						|
            valueA = a.latency;
 | 
						|
            valueB = b.latency;
 | 
						|
        } else if (column === 'channels') {
 | 
						|
            valueA = a.channels.length;
 | 
						|
            valueB = b.channels.length;
 | 
						|
        } else if (column === 'updated') {
 | 
						|
            valueA = a.channels.length > 0 ? new Date(a.channels[0].last_data || 0).getTime() : 0;
 | 
						|
            valueB = b.channels.length > 0 ? new Date(b.channels[0].last_data || 0).getTime() : 0;
 | 
						|
        }
 | 
						|
 | 
						|
        // Handle string comparison
 | 
						|
        if (typeof valueA === 'string') {
 | 
						|
            if (newDirection === 'asc') {
 | 
						|
                return valueA.localeCompare(valueB);
 | 
						|
            } else {
 | 
						|
                return valueB.localeCompare(valueA);
 | 
						|
            }
 | 
						|
        }
 | 
						|
        // Handle number comparison
 | 
						|
        else {
 | 
						|
            if (newDirection === 'asc') {
 | 
						|
                return valueA - valueB;
 | 
						|
            } else {
 | 
						|
                return valueB - valueA;
 | 
						|
            }
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    // Re-render the table with sorted data
 | 
						|
    renderTableView(stationsData);
 | 
						|
}
 | 
						|
 | 
						|
// Utility function for debouncing
 | 
						|
function debounce(func, wait) {
 | 
						|
    let timeout;
 | 
						|
    return function(...args) {
 | 
						|
        const context = this;
 | 
						|
        clearTimeout(timeout);
 | 
						|
        timeout = setTimeout(() => func.apply(context, args), wait);
 | 
						|
    };
 | 
						|
}
 | 
						|
"""
 | 
						|
 | 
						|
    try:
 | 
						|
        js_path = os.path.join(config['setup']['wwwdir'], 'script.js')
 | 
						|
        with open(js_path, 'w') as f:
 | 
						|
            f.write(js_content)
 | 
						|
        print(f"JavaScript file generated at {js_path}")
 | 
						|
        return js_path
 | 
						|
    except Exception as e:
 | 
						|
        print(f"Error generating JavaScript file: {str(e)}")
 | 
						|
        return None
 | 
						|
 | 
						|
 | 
						|
def generate_html_base(config, title, active_view):
 | 
						|
    """Generate base HTML structure for all pages"""
 | 
						|
 | 
						|
    # Determine if map plugins should be included
 | 
						|
    include_map_plugins = 'enable_map' in config['setup'] and config['setup']['enable_map']
 | 
						|
 | 
						|
    # Start with the first part using proper variable interpolation
 | 
						|
    html = f"""<!DOCTYPE html>
 | 
						|
<html lang="en">
 | 
						|
<head>
 | 
						|
    <meta charset="UTF-8">
 | 
						|
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
						|
    <title>{config['setup']['title']} - {title}</title>
 | 
						|
    <link rel="preconnect" href="https://fonts.googleapis.com">
 | 
						|
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 | 
						|
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
 | 
						|
    <link rel="stylesheet" href="styles.css">
 | 
						|
    <link rel="shortcut icon" href="{config['setup']['icon']}">
 | 
						|
    <!-- meta http-equiv="refresh" content="{int(config['setup']['refresh'])}" -->
 | 
						|
 | 
						|
    <!-- Include Leaflet for map view -->
 | 
						|
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css" />
 | 
						|
    <script src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"></script>
 | 
						|
"""
 | 
						|
 | 
						|
    # Include additional map plugins if map is enabled
 | 
						|
    if include_map_plugins:
 | 
						|
        html += """
 | 
						|
    <!-- Leaflet plugins -->
 | 
						|
    <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
 | 
						|
    <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
 | 
						|
    <link rel="stylesheet" href="https://unpkg.com/leaflet.fullscreen@1.6.0/Control.FullScreen.css" />
 | 
						|
    <link rel="stylesheet" href="https://unpkg.com/leaflet.locatecontrol@0.76.0/dist/L.Control.Locate.min.css" />
 | 
						|
 | 
						|
    <script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
 | 
						|
    <script src="https://unpkg.com/leaflet.fullscreen@1.6.0/Control.FullScreen.js"></script>
 | 
						|
    <script src="https://unpkg.com/leaflet.locatecontrol@0.76.0/dist/L.Control.Locate.min.js"></script>
 | 
						|
    <script src="https://unpkg.com/leaflet-measure-path@1.5.0/leaflet-measure-path.js"></script>
 | 
						|
"""
 | 
						|
 | 
						|
    # Continue with the second part, but using f-string instead of .format()
 | 
						|
    html += f"""
 | 
						|
</head>
 | 
						|
<body>
 | 
						|
    <div class="container">
 | 
						|
        <div class="header">
 | 
						|
            <h1>{config['setup']['title']}</h1>
 | 
						|
            <div class="view-toggle">
 | 
						|
                <a href="index.html" data-view="table" class="{'active' if active_view == 'table' else ''}">Table View</a>
 | 
						|
                <a href="index.html?view=grid" data-view="grid" class="{'active' if active_view == 'grid' else ''}">Grid View</a>
 | 
						|
                <a href="index.html?view=map" data-view="map" class="{'active' if active_view == 'map' else ''}">Map View</a>
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
 | 
						|
        <p class="subtitle">Real-time seismic station monitoring dashboard</p>
 | 
						|
 | 
						|
        <div class="controls">
 | 
						|
            <div class="actions">
 | 
						|
                <button id="stats-toggle" class="action-button">
 | 
						|
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 | 
						|
                        <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
 | 
						|
                    </svg>
 | 
						|
                    Station Stats
 | 
						|
                </button>
 | 
						|
                <button id="export-csv" class="action-button">
 | 
						|
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 | 
						|
                        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
 | 
						|
                        <polyline points="7 10 12 15 17 10"></polyline>
 | 
						|
                        <line x1="12" y1="15" x2="12" y2="3"></line>
 | 
						|
                    </svg>
 | 
						|
                    Export CSV
 | 
						|
                </button>
 | 
						|
                <button id="theme-toggle" class="action-button">
 | 
						|
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 | 
						|
                        <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
 | 
						|
                    </svg>
 | 
						|
                    Dark Mode
 | 
						|
                </button>
 | 
						|
            </div>
 | 
						|
 | 
						|
            <div class="refresh-control">
 | 
						|
                <div class="input-group">
 | 
						|
                    <label for="refresh-interval">Auto refresh:</label>
 | 
						|
                    <input type="number" id="refresh-interval" min="10" value="{int(config['setup']['refresh'])}">
 | 
						|
                    <span>seconds</span>
 | 
						|
                </div>
 | 
						|
                <button id="apply-refresh">Apply</button>
 | 
						|
                <button id="refresh-now">Refresh Now</button>
 | 
						|
                <div class="status-counter">
 | 
						|
                    <div id="refresh-status">Last refresh: -</div>
 | 
						|
                    <div class="countdown">Next in <span id="next-refresh">{int(config['setup']['refresh'])}</span> seconds</div>
 | 
						|
                </div>
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
"""
 | 
						|
 | 
						|
    return html
 | 
						|
 | 
						|
def generate_main_html(config, status):
 | 
						|
    """Generate the main index.html with all three views"""
 | 
						|
 | 
						|
    html = generate_html_base(config, "Dashboard", "table")
 | 
						|
 | 
						|
    map_settings = get_map_settings(config)
 | 
						|
 | 
						|
    # Add filters
 | 
						|
    html += f"""
 | 
						|
        <script>
 | 
						|
            //Map configuration settings
 | 
						|
            windows.mapSettings = {json.dumps(map_settings)};
 | 
						|
        </script>
 | 
						|
        <div class="filters">
 | 
						|
            <div class="filter-group">
 | 
						|
                <label for="network-filter">Network:</label>
 | 
						|
                <select id="network-filter">
 | 
						|
                    <option value="">All Networks</option>
 | 
						|
                </select>
 | 
						|
            </div>
 | 
						|
            <div class="filter-group">
 | 
						|
                <label for="status-filter">Status:</label>
 | 
						|
                <select id="status-filter">
 | 
						|
                    <option value="">All Statuses</option>
 | 
						|
                    <option value="good">Good</option>
 | 
						|
                    <option value="warning">Warning</option>
 | 
						|
                    <option value="critical">Critical</option>
 | 
						|
                    <option value="unavailable">Unavailable</option>
 | 
						|
                </select>
 | 
						|
            </div>
 | 
						|
            <div class="filter-group">
 | 
						|
                <input type="text" id="search-input" placeholder="Search stations..." class="search-box">
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
 | 
						|
        <div id="stats-container" class="stats-container">
 | 
						|
            <div class="stats-header">
 | 
						|
                <div class="stats-title">Station Statistics</div>
 | 
						|
                <button id="close-stats" class="action-button">Close</button>
 | 
						|
            </div>
 | 
						|
            <div id="status-counter"></div>
 | 
						|
            <div id="network-stats" class="network-stats">
 | 
						|
                <!-- Network stats will be inserted here -->
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
 | 
						|
        <div id="loading">
 | 
						|
            <div class="loading-spinner"></div>
 | 
						|
            Loading station data...
 | 
						|
        </div>
 | 
						|
 | 
						|
        <div id="error-message"></div>
 | 
						|
 | 
						|
        <!-- Table View -->
 | 
						|
        <div id="table-view" class="view-container">
 | 
						|
            <div class="table-container">
 | 
						|
                <table>
 | 
						|
                    <thead>
 | 
						|
                        <tr>
 | 
						|
                            <th data-sort="name">Station</th>
 | 
						|
                            <th data-sort="status">Status</th>
 | 
						|
                            <th data-sort="latency">Latency</th>
 | 
						|
                            <th data-sort="channels">Channels</th>
 | 
						|
                            <th data-sort="updated">Last Updated</th>
 | 
						|
                        </tr>
 | 
						|
                    </thead>
 | 
						|
                    <tbody id="table-body">
 | 
						|
                        <!-- Table rows will be inserted here -->
 | 
						|
                    </tbody>
 | 
						|
                </table>
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
 | 
						|
        <!-- Grid View -->
 | 
						|
        <div id="grid-view" class="view-container" style="display: none;">
 | 
						|
            <div id="grid-container" class="grid-container">
 | 
						|
                <!-- Grid will be inserted here -->
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
 | 
						|
        <!-- Map View -->
 | 
						|
        <div id="map-view" class="view-container" style="display: none;">
 | 
						|
            <div id="map-container" class="map-container">
 | 
						|
                <!-- Map will be rendered here -->
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
    """
 | 
						|
 | 
						|
    # Add legend
 | 
						|
    html += """
 | 
						|
        <div class="legend">
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #ffffff; border: 1px solid #e5e7eb;"></div>
 | 
						|
                <span>Good (≤ 1 min)</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #c084fc;"></div>
 | 
						|
                <span>> 1 min</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #8b5cf6;"></div>
 | 
						|
                <span>> 10 min</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #3b82f6;"></div>
 | 
						|
                <span>> 30 min</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #10b981;"></div>
 | 
						|
                <span>> 1 hour</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #fbbf24;"></div>
 | 
						|
                <span>> 2 hours</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #f97316;"></div>
 | 
						|
                <span>> 6 hours</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #ef4444;"></div>
 | 
						|
                <span>> 1 day</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #f87171;"></div>
 | 
						|
                <span>> 2 days</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #d1d5db;"></div>
 | 
						|
                <span>> 3 days</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #9ca3af;"></div>
 | 
						|
                <span>> 4 days</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #6b7280;"></div>
 | 
						|
                <span>> 5 days</span>
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
    """
 | 
						|
 | 
						|
    # Add footer and close tags
 | 
						|
    html += f"""
 | 
						|
        <div class="footer">
 | 
						|
            <div>Last updated <span id="update-time">{gmtime()[:6][0]:04d}/{gmtime()[:6][1]:02d}/{gmtime()[:6][2]:02d} {gmtime()[:6][3]:02d}:{gmtime()[:6][4]:02d}:{gmtime()[:6][5]:02d} UTC</span></div>
 | 
						|
            <div><a href="{config['setup']['linkurl']}" target="_top">{config['setup']['linkname']}</a></div>
 | 
						|
        </div>
 | 
						|
    </div>
 | 
						|
 | 
						|
    <!-- Export JSON data for JavaScript -->
 | 
						|
    <script>
 | 
						|
        // Initialize stationsData with server-side rendered data
 | 
						|
        const initialStationsData = {status.to_json()};
 | 
						|
    </script>
 | 
						|
 | 
						|
    <!-- Include the main JavaScript file -->
 | 
						|
    <script src="script.js"></script>
 | 
						|
</body>
 | 
						|
</html>
 | 
						|
"""
 | 
						|
 | 
						|
    try:
 | 
						|
        html_path = os.path.join(config['setup']['wwwdir'], 'index.html')
 | 
						|
        with open(html_path, 'w') as f:
 | 
						|
            f.write(html)
 | 
						|
        print(f"Main HTML file generated at {html_path}")
 | 
						|
        return html_path
 | 
						|
    except Exception as e:
 | 
						|
        print(f"Error generating main HTML file: {str(e)}")
 | 
						|
        return None
 | 
						|
 | 
						|
 | 
						|
def generate_station_html(net_sta, config, status):
 | 
						|
    """Generate individual station HTML page"""
 | 
						|
 | 
						|
    try:
 | 
						|
        network, station = net_sta.split("_")
 | 
						|
    except:
 | 
						|
        print(f"Invalid station identifier: {net_sta}")
 | 
						|
        return None
 | 
						|
 | 
						|
    html = generate_html_base(config, f"Station {station}", "table")
 | 
						|
 | 
						|
    # Add station info
 | 
						|
    html += f"""
 | 
						|
        <div class="station-header">
 | 
						|
            <h2>{network}_{station}</h2>
 | 
						|
    """
 | 
						|
 | 
						|
    # Add station information if available
 | 
						|
    try:
 | 
						|
        if 'info' in config.station[net_sta]:
 | 
						|
            html += f'<div class="station-info">{config.station[net_sta]["info"]}</div>'
 | 
						|
    except:
 | 
						|
        pass
 | 
						|
 | 
						|
    html += """
 | 
						|
        </div>
 | 
						|
    """
 | 
						|
 | 
						|
    # Add custom text if available
 | 
						|
    try:
 | 
						|
        if 'text' in config.station[net_sta]:
 | 
						|
            html += f'<p>{config.station[net_sta]["text"]}</p>'
 | 
						|
    except:
 | 
						|
        pass
 | 
						|
 | 
						|
    # Station details table
 | 
						|
    html += """
 | 
						|
        <div class="table-container">
 | 
						|
            <table>
 | 
						|
                <thead>
 | 
						|
                    <tr>
 | 
						|
                        <th>Channel</th>
 | 
						|
                        <th>Last Sample</th>
 | 
						|
                        <th>Data Latency</th>
 | 
						|
                        <th>Last Received</th>
 | 
						|
                        <th>Feed Latency</th>
 | 
						|
                        <th>Diff</th>
 | 
						|
                    </tr>
 | 
						|
                </thead>
 | 
						|
                <tbody>
 | 
						|
    """
 | 
						|
 | 
						|
    now = datetime.utcnow()
 | 
						|
    netsta2 = net_sta.replace("_", ".")
 | 
						|
    streams = [x for x in list(status.keys()) if x.find(netsta2) == 0]
 | 
						|
    streams.sort()
 | 
						|
 | 
						|
    for label in streams:
 | 
						|
        tim1 = status[label].last_data
 | 
						|
        tim2 = status[label].last_feed
 | 
						|
 | 
						|
        lat1, lat2, lat3 = now-tim1, now-tim2, tim2-tim1
 | 
						|
        col1, col2, col3 = getColor(lat1), getColor(lat2), getColor(lat3)
 | 
						|
 | 
						|
        if lat1 == lat2:
 | 
						|
            lat2 = lat3 = None
 | 
						|
 | 
						|
        if label[-2] == '.' and label[-1] in "DE":
 | 
						|
            label = label[:-2]
 | 
						|
 | 
						|
        n, s, loc, c = label.split(".")
 | 
						|
        c = ("%s.%s" % (loc, c)).strip(".")
 | 
						|
 | 
						|
        time1_str = tim1.strftime("%Y/%m/%d %H:%M:%S") if tim1 else "n/a"
 | 
						|
        time2_str = tim2.strftime("%Y/%m/%d %H:%M:%S") if tim2 else "n/a"
 | 
						|
 | 
						|
        html += f"""
 | 
						|
                <tr>
 | 
						|
                    <td>{s} {c}</td>
 | 
						|
                    <td>{time1_str}</td>
 | 
						|
                    <td style="background-color:{col1}">{formatLatency(lat1)}</td>
 | 
						|
                    <td>{time2_str}</td>
 | 
						|
                    <td style="background-color:{col2}">{formatLatency(lat2)}</td>
 | 
						|
                    <td style="background-color:{col3}">{formatLatency(lat3)}</td>
 | 
						|
                </tr>
 | 
						|
        """
 | 
						|
 | 
						|
    html += """
 | 
						|
                </tbody>
 | 
						|
            </table>
 | 
						|
        </div>
 | 
						|
    """
 | 
						|
 | 
						|
    # Legend
 | 
						|
    html += """
 | 
						|
        <div class="legend">
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #ffffff; border: 1px solid #e5e7eb;"></div>
 | 
						|
                <span>Good (≤ 1 min)</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #c084fc;"></div>
 | 
						|
                <span>> 1 min</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #8b5cf6;"></div>
 | 
						|
                <span>> 10 min</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #3b82f6;"></div>
 | 
						|
                <span>> 30 min</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #10b981;"></div>
 | 
						|
                <span>> 1 hour</span>
 | 
						|
            </div>
 | 
						|
            <div class="legend-item">
 | 
						|
                <div class="legend-color" style="background-color: #fbbf24;"></div>
 | 
						|
                <span>> 2 hours</span>
 | 
						|
            </div>
 | 
						|
        </div>
 | 
						|
    """
 | 
						|
 | 
						|
    # Links
 | 
						|
    html += '<div class="links">\n'
 | 
						|
    html += '<p>Click here to <a href="index.html?view=grid" target="_blank">view in Grid View</a><br>\n'
 | 
						|
 | 
						|
    if 'liveurl' in config['setup']:
 | 
						|
        # Substitute '%s' in live_url by station name
 | 
						|
        s = net_sta.split("_")[-1]
 | 
						|
        url = config['setup']['liveurl'] % s
 | 
						|
        html += f'View a <a href="{url}" target="_blank">live seismogram</a> of station {s}</p>\n'
 | 
						|
 | 
						|
    html += '</div>\n'
 | 
						|
 | 
						|
    # Add footer and close tags
 | 
						|
    html += f"""
 | 
						|
        <div class="footer">
 | 
						|
            <div>Last updated {gmtime()[:6][0]:04d}/{gmtime()[:6][1]:02d}/{gmtime()[:6][2]:02d} {gmtime()[:6][3]:02d}:{gmtime()[:6][4]:02d}:{gmtime()[:6][5]:02d} UTC</div>
 | 
						|
            <div><a href="{config['setup']['linkurl']}" target="_top">{config['setup']['linkname']}</a></div>
 | 
						|
        </div>
 | 
						|
    </div>
 | 
						|
 | 
						|
    <script src="script.js"></script>
 | 
						|
</body>
 | 
						|
</html>
 | 
						|
"""
 | 
						|
 | 
						|
    try:
 | 
						|
        html_path = os.path.join(config['setup']['wwwdir'], f'{net_sta}.html')
 | 
						|
        with open(html_path, 'w') as f:
 | 
						|
            f.write(html)
 | 
						|
        print(f"Station HTML file generated at {html_path}")
 | 
						|
        return html_path
 | 
						|
    except Exception as e:
 | 
						|
        print(f"Error generating station HTML file: {str(e)}")
 | 
						|
        return None
 | 
						|
 | 
						|
 | 
						|
def generate_json_data(status):
 | 
						|
    """Generate a JSON file with station data for JavaScript use"""
 | 
						|
    try:
 | 
						|
        json_data = status.to_json()
 | 
						|
        json_path = os.path.join(config['setup']['wwwdir'], 'stations_data.json')
 | 
						|
        with open(json_path, 'w') as f:
 | 
						|
            f.write(json_data)
 | 
						|
        print(f"JSON data file generated at {json_path}")
 | 
						|
        return json_path
 | 
						|
    except Exception as e:
 | 
						|
        print(f"Error generating JSON data file: {str(e)}")
 | 
						|
        return None
 | 
						|
 | 
						|
 | 
						|
def generate_all_files(config, status):
 | 
						|
    """Generate all the static files needed for the web interface"""
 | 
						|
 | 
						|
    # Create the directory if it doesn't exist
 | 
						|
    try:
 | 
						|
        os.makedirs(config['setup']['wwwdir'], exist_ok=True)
 | 
						|
    except Exception as e:
 | 
						|
        print(f"Error creating directory: {str(e)}")
 | 
						|
        return False
 | 
						|
 | 
						|
    # Generate files
 | 
						|
    css_path = generate_css_file(config)
 | 
						|
    js_path = generate_js_file(config)
 | 
						|
    json_path = generate_json_data(status)
 | 
						|
    main_html = generate_main_html(config, status)
 | 
						|
 | 
						|
    # Generate station pages - Get UNIQUE station identifiers
 | 
						|
    unique_stations = set()
 | 
						|
    for k in status:
 | 
						|
        net_sta = f"{status[k].net}_{status[k].sta}"
 | 
						|
        unique_stations.add(net_sta)
 | 
						|
 | 
						|
    # Now generate each station page exactly once
 | 
						|
    station_htmls = []
 | 
						|
    for net_sta in unique_stations:
 | 
						|
        html_path = generate_station_html(net_sta, config, status)
 | 
						|
        station_htmls.append(html_path is not None)
 | 
						|
 | 
						|
    # Return success only if all files were generated
 | 
						|
    all_stations_success = len(station_htmls) > 0 and all(station_htmls)
 | 
						|
 | 
						|
    # Log success or failure
 | 
						|
    if all_stations_success:
 | 
						|
        print(f"Successfully generated {len(station_htmls)} station HTML files")
 | 
						|
    else:
 | 
						|
        print(f"ERROR: Failed to generate some station HTML files")
 | 
						|
 | 
						|
    # Return success if all files were generated
 | 
						|
    return all([css_path, js_path, json_path, main_html, all_stations_success])
 | 
						|
 | 
						|
 | 
						|
def read_ini():
 | 
						|
    """Read configuration files"""
 | 
						|
    global config, ini_setup, ini_stations
 | 
						|
    print("reading setup config from '%s'" % ini_setup)
 | 
						|
    if not os.path.isfile(ini_setup):
 | 
						|
        print("[error] setup config '%s' does not exist" % ini_setup, file=sys.stderr)
 | 
						|
        usage(exitcode=2)
 | 
						|
 | 
						|
    config = MyConfig(ini_setup)
 | 
						|
    print("reading station config from '%s'" % ini_stations)
 | 
						|
    if not os.path.isfile(ini_stations):
 | 
						|
        print("[error] station config '%s' does not exist" % ini_stations, file=sys.stderr)
 | 
						|
        usage(exitcode=2)
 | 
						|
    config.station = MyConfig(ini_stations)
 | 
						|
 | 
						|
 | 
						|
def SIGINT_handler(signum, frame):
 | 
						|
    """Handle interruption signals"""
 | 
						|
    global status
 | 
						|
    print("received signal #%d => will write status file and exit" % signum)
 | 
						|
    sys.exit(0)
 | 
						|
 | 
						|
 | 
						|
def main():
 | 
						|
    """Main function to run the program"""
 | 
						|
    global config, status, verbose, generate_only, ini_setup, ini_stations
 | 
						|
 | 
						|
    # Parse command line arguments
 | 
						|
    try:
 | 
						|
        opts, args = getopt(sys.argv[1:], "c:s:t:hvg", ["help", "generate"])
 | 
						|
    except GetoptError:
 | 
						|
        print("\nUnknown option in "+str(sys.argv[1:])+" - EXIT.", file=sys.stderr)
 | 
						|
        usage(exitcode=2)
 | 
						|
 | 
						|
    for flag, arg in opts:
 | 
						|
        if flag == "-c":        ini_setup = arg
 | 
						|
        elif flag == "-s":      ini_stations = arg
 | 
						|
        elif flag == "-t":      refresh = float(arg)  # XXX not yet used
 | 
						|
        elif flag in ("-h", "--help"):     usage(exitcode=0)
 | 
						|
        elif flag == "-v":      verbose = 1
 | 
						|
        elif flag in ("-g", "--generate"): generate_only = True
 | 
						|
 | 
						|
    # Set up signal handlers
 | 
						|
    signal.signal(signal.SIGHUP, SIGINT_handler)
 | 
						|
    signal.signal(signal.SIGINT, SIGINT_handler)
 | 
						|
    signal.signal(signal.SIGQUIT, SIGINT_handler)
 | 
						|
    signal.signal(signal.SIGTERM, SIGINT_handler)
 | 
						|
 | 
						|
    # Read configuration
 | 
						|
    read_ini()
 | 
						|
 | 
						|
    # Load station coordinates from the FDSN web service
 | 
						|
    try:
 | 
						|
        load_station_coordinates(config)
 | 
						|
    except Exception as e:
 | 
						|
        print(f"Warning: Failed to load station coordinates: {str(e)}")
 | 
						|
 | 
						|
    # Prepare station information
 | 
						|
    s = config.station
 | 
						|
    net_sta = ["%s_%s" % (s[k]['net'], s[k]['sta']) for k in s]
 | 
						|
    s_arg = ','.join(net_sta)
 | 
						|
 | 
						|
    # Set server from config or use default
 | 
						|
    if 'server' in config['setup']:
 | 
						|
        server = config['setup']['server']
 | 
						|
    else:
 | 
						|
        server = "localhost"
 | 
						|
 | 
						|
    # Initialize status dictionary
 | 
						|
    status = StatusDict()
 | 
						|
 | 
						|
    print("generating output to '%s'" % config['setup']['wwwdir'])
 | 
						|
 | 
						|
    if generate_only:
 | 
						|
        # Generate template files without fetching data
 | 
						|
        print("Generating template files only...")
 | 
						|
 | 
						|
        # Create dummy data for template rendering
 | 
						|
        for net_sta_item in net_sta:
 | 
						|
            net, sta = net_sta_item.split('_')
 | 
						|
 | 
						|
            d = Status()
 | 
						|
            d.net = net
 | 
						|
            d.sta = sta
 | 
						|
            d.loc = ""
 | 
						|
            d.cha = "HHZ"
 | 
						|
            d.typ = "D"
 | 
						|
            d.last_data = datetime.utcnow()
 | 
						|
            d.last_feed = datetime.utcnow()
 | 
						|
 | 
						|
            sec = "%s.%s.%s.%s.%c" % (d.net, d.sta, d.loc, d.cha, d.typ)
 | 
						|
            status[sec] = d
 | 
						|
 | 
						|
        # Generate all files
 | 
						|
        if generate_all_files(config, status):
 | 
						|
            print("Template files generated successfully.")
 | 
						|
        else:
 | 
						|
            print("Error generating template files.")
 | 
						|
 | 
						|
        sys.exit(0)
 | 
						|
 | 
						|
    # Get initial data
 | 
						|
    print("getting initial time windows from SeedLink server '%s'" % server)
 | 
						|
    status.fromSlinkTool(server, stations=net_sta)
 | 
						|
    if verbose:
 | 
						|
        status.write(sys.stderr)
 | 
						|
 | 
						|
    # Generate initial files
 | 
						|
    generate_all_files(config, status)
 | 
						|
 | 
						|
    # Set up the next time to generate files
 | 
						|
    nextTimeGenerateHTML = time()
 | 
						|
 | 
						|
    print("setting up connection to SeedLink server '%s'" % server)
 | 
						|
 | 
						|
    # Connect to the SeedLink server and start receiving data
 | 
						|
    input = seiscomp.slclient.Input(server, [(s[k]['net'], s[k]['sta'], "", "???") for k in s])
 | 
						|
    for rec in input:
 | 
						|
        id = '.'.join([rec.net, rec.sta, rec.loc, rec.cha, rec.rectype])
 | 
						|
 | 
						|
        try:
 | 
						|
            status[id].last_data = rec.end_time
 | 
						|
            status[id].last_feed = datetime.utcnow()
 | 
						|
        except:
 | 
						|
            continue
 | 
						|
 | 
						|
        if time() > nextTimeGenerateHTML:
 | 
						|
            generate_all_files(config, status)
 | 
						|
            nextTimeGenerateHTML = time() + int(config['setup']['refresh'])
 | 
						|
 | 
						|
 | 
						|
if __name__ == "__main__":
 | 
						|
    main()
 |