<melker>
    <title>Earthquake Dashboard</title>

    <policy>
        {
        "name": "earthquake-dashboard",
        "description": "Real-time USGS earthquake monitor",
        "permissions": {
        "net": ["earthquake.usgs.gov", "raw.githubusercontent.com"],
        "map": true
        }
        }
    </policy>

    <help>
        ## USGS Earthquake Dashboard

        Live earthquake data from the United States Geological Survey.

        | Key        | Action                    |
        |------------|---------------------------|
        | Tab        | Switch between panels     |
        | Arrow keys | Navigate table rows       |
        | Enter      | View earthquake details   |
        | R          | Refresh data              |
        | T          | Toggle tectonic plates    |
        | F12        | Dev tools                 |

        Data refreshes every 60 seconds from the USGS GeoJSON feed.
    </help>

    <style>
        .header {
          display: flex;
          flex-direction: row;
          justify-content: space-between;
          padding: 0 1;
          flex-shrink: 0;
        }
        .footer {
          display: flex;
          flex-direction: row;
          justify-content: space-between;
          padding: 0 1;
          flex-shrink: 0;
        }
        .panel-label {
          font-weight: bold;
          padding: 0 1;
          flex-shrink: 0;
        }
        tile-map {
          tile-key-color: #abd0e0;         /* Water color on voyager maps */
          tile-key-threshold: 0.04;
          tile-key-match-color: #000000;
          tile-key-other-color: #007700;
          tile-blur: 1;
        }
    </style>

    <container style="display: flex; flex-direction: column; height: 100%; width: 100%">

        <!-- Header -->
        <container class="header">
            <container style="display: flex; flex-direction: row; gap: 2; align-items: center">
                <text style="font-weight: bold;">USGS Earthquake Monitor</text>
                <select id="feedSelect" onChange="$app.changeFeed(event)" style="width: 30" selectedValue="day">
                    <option value="hour">Past hour (all)</option>
                    <option value="day">Past day (all)</option>
                    <option value="day25">Past day (M2.5+)</option>
                    <option value="day45">Past day (M4.5+)</option>
                    <option value="week">Past 7 days (M2.5+)</option>
                    <option value="week45">Past 7 days (M4.5+)</option>
                    <option value="month">Past 30 days (M4.5+)</option>
                    <option value="monthSig">Past 30 days (significant)</option>
                </select>
            </container>
            <text id="status">Loading...</text>
        </container>

        <!-- Main content -->
        <split-pane style="width: fill; flex: 1; direction: horizontal;" sizes="3,1">

            <!-- Left: table + magnitude sparkline -->
            <container style="display: flex; flex-direction: column; height: fill">

                <!-- Earthquake table -->
                <data-table
                        id="quakeTable"
                        style="width: fill; flex: 1;"
                        selectable="single"
                        sortColumn="0"
                        sortDirection="desc"
                        tooltip="auto"
                        onActivate="$app.showDetails(event)"
                        onChange="$app.handleSelect(event)"
                        bind:selection="selectedQuake"
                        onGetId="event[4]"
                >
                    {
                    "columns": [
                    { "header": "Mag", "width": 6, "align": "right" },
                    { "header": "Location", "width": "fill" },
                    { "header": "Depth km", "width": 10, "align": "right" },
                    { "header": "Time (UTC)", "width": 20 }
                    ]
                    }
                </data-table>

                <!-- Magnitude sparkline -->
                <container style="flex-grow: 0; flex-shrink: 0; height: 4">
                    <text class="panel-label">Recent magnitudes</text>
                    <data-bars
                            id="magSparkline"
                            series='[{"name": "Mag"}]'
                            bars='[]'
                            showValues="false"
                            showLabels="false"
                            min="0"
                            onTooltip="$app.sparklineTooltip(event)"
                            style="orientation: vertical; height: 3; gap: 0; margin: 1;"
                    />
                </container>
            </container>

            <!-- Right: map (top) + summary panels (bottom) -->
            <split-pane style="width: fill; height: fill; direction: vertical" sizes="1,3">

                <!-- World map -->
                <tile-map
                        id="worldMap"
                        lat="20"
                        lon="0"
                        zoom="1"
                        provider="voyager-nolabels"
                        width="100%"
                        height="100%"
                        interactive="true"
                        onOverlay="$app.drawOverlay(event)"
                        onTooltip="$app.mapTooltip(event)"
                        onClick="$app.mapClick(event)"
                />

                <!-- Summary panels -->
                <container style="display: flex; flex-direction: column; height: fill; padding: 0 1; gap: 1">

                <!-- Summary stats -->
                <container style="padding: 0 1; flex-shrink: 0">
                    <text id="summaryLabel" style="font-weight: bold;">Summary (past hour)</text>
                    <text id="totalCount">Total: --</text>
                    <text id="maxMag">Largest: --</text>
                    <text id="avgMag">Average: --</text>
                    <text id="avgDepth">Avg depth: --</text>
                </container>

                <!-- Magnitude distribution -->
                <container style="padding: 0 1; flex-shrink: 0">
                    <text style="font-weight: bold;">By magnitude</text>
                    <data-bars
                            id="magDist"
                            series='[{"name": "Count"}]'
                            bars='[]'
                            labels='[]'
                            showValues="true"
                            tooltip="auto"
                            style="bar-width: 1; gap: 0"
                    />
                </container>

                <!-- Depth distribution (heatmap-like bars) -->
                <container style="padding: 0 1; flex-shrink: 0">
                    <text style="font-weight: bold;">By depth</text>
                    <data-bars
                            id="depthDist"
                            series='[{"name": "Count"}]'
                            bars='[]'
                            labels='[]'
                            showValues="true"
                            tooltip="auto"
                            style="bar-width: 1; gap: 0"
                    />
                </container>

            </container>

            </split-pane>

        </split-pane>

        <command key="r" label="Refresh Data" global onExecute="$app.refresh()" />
        <command key="t" label="Toggle Plates" global onExecute="$app.togglePlates()" />

        <!-- Footer -->
        <container class="footer">
            <text>Arrow keys: navigate | Enter: details | R: refresh | T: toggle plates | Data: USGS</text>
            <text id="lastUpdate">--</text>
        </container>
    </container>

    <script type="typescript">
      // Types
      interface Quake {
        mag: number;
        place: string;
        time: number;
        depth: number;
        lat: number;
        lon: number;
        url: string;
        id: string;
      }

      // Feed URLs
      const FEEDS: Record<string, string> = {
        hour: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson',
        day: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson',
        day25: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson',
        day45: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson',
        week: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_week.geojson',
        week45: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson',
        month: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_month.geojson',
        monthSig: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/significant_month.geojson',
      };

      const FEED_LABELS: Record<string, string> = {
        hour: 'past hour',
        day: 'past day',
        day25: 'past day, M2.5+',
        day45: 'past day, M4.5+',
        week: 'past 7 days, M2.5+',
        week45: 'past 7 days, M4.5+',
        month: 'past 30 days, M4.5+',
        monthSig: 'past 30 days, significant',
      };

      const PLATES_URL = 'https://raw.githubusercontent.com/fraxen/tectonicplates/master/GeoJSON/PB2002_boundaries.json';

      let currentFeed = 'day';
      let quakes: Quake[] = [];
      let sparklineQuakes: Quake[] = [];
      let refreshTimer: number | null = null;
      let previousCount = 0;
      const notifiedBigQuakes = new Set<string>();
      let platesSvgOverlay = '';
      let platesVisible = true;

      const state = $melker.createState({ selectedQuake: [] as string[] });

      // Format a timestamp to ISO 8601 UTC string
      function fmtTime(ms: number): string {
        return new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
      }

      // Fetch earthquake data from USGS
      async function fetchQuakes(): Promise<Quake[]> {
        const url = FEEDS[currentFeed];
        const res = await fetch(url);
        if (!res.ok) throw new Error('HTTP ' + res.status);
        const json = await res.json();
        return json.features.map((f: any) => ({
          mag: f.properties.mag ?? 0,
          place: f.properties.place ?? 'Unknown',
          time: f.properties.time ?? 0,
          depth: f.geometry.coordinates[2] ?? 0,
          lat: f.geometry.coordinates[1] ?? 0,
          lon: f.geometry.coordinates[0] ?? 0,
          url: f.properties.url ?? '',
          id: f.id ?? '',
        }));
      }

      // Compute magnitude distribution buckets
      // Labels padded to match depth label width so bars align
      function magDistribution(data: Quake[]): { labels: string[]; bars: number[][] } {
        const buckets: Record<string, number> = {
          '<1': 0, '1-2': 0, '2-3': 0, '3-4': 0, '4-5': 0, '5+': 0,
        };
        for (const q of data) {
          const m = q.mag;
          if (m < 1) buckets['<1']++;
          else if (m < 2) buckets['1-2']++;
          else if (m < 3) buckets['2-3']++;
          else if (m < 4) buckets['3-4']++;
          else if (m < 5) buckets['4-5']++;
          else buckets['5+']++;
        }
        const labels = Object.keys(buckets).map(k => k.padEnd(7));
        const bars = Object.values(buckets).map(v => [v]);
        return { labels, bars };
      }

      // Compute depth distribution buckets
      function depthDistribution(data: Quake[]): { labels: string[]; bars: number[][] } {
        const buckets: Record<string, number> = {
          '0-10': 0, '10-30': 0, '30-70': 0, '70-150': 0, '150-300': 0, '300+': 0,
        };
        for (const q of data) {
          const d = q.depth;
          if (d < 10) buckets['0-10']++;
          else if (d < 30) buckets['10-30']++;
          else if (d < 70) buckets['30-70']++;
          else if (d < 150) buckets['70-150']++;
          else if (d < 300) buckets['150-300']++;
          else buckets['300+']++;
        }
        const labels = Object.keys(buckets).map(k => k.padEnd(7));
        const bars = Object.values(buckets).map(v => [v]);
        return { labels, bars };
      }

      // Update all UI components with current data
      function updateUI(): void {
        // Summary label
        const summaryLabel = $melker.getElementById('summaryLabel');
        if (summaryLabel) summaryLabel.setValue('Summary (' + FEED_LABELS[currentFeed] + ')');

        // Table rows: [mag, location, depth, time]
        const table = $melker.getElementById('quakeTable');
        if (table) {
          const rows = quakes.map(q => [
            q.mag.toFixed(1),
            q.place,
            q.depth.toFixed(1),
            fmtTime(q.time),
            q.id,
          ]);
          table.setValue(rows);
          table.props.footer = [['', quakes.length + ' earthquakes (' + FEED_LABELS[currentFeed] + ')', '', '']];
        }

        // Sparkline: most recent magnitudes (newest on right)
        const sparkline = $melker.getElementById('magSparkline');
        if (sparkline) {
          sparklineQuakes = quakes.slice(0, 40).reverse();
          sparkline.setValue(sparklineQuakes.map(q => [q.mag]));
        }

        // Summary stats
        if (quakes.length > 0) {
          const mags = quakes.map(q => q.mag);
          const depths = quakes.map(q => q.depth);
          const maxM = Math.max(...mags);
          const avgM = mags.reduce((a, b) => a + b, 0) / mags.length;
          const avgD = depths.reduce((a, b) => a + b, 0) / depths.length;

          const totalEl = $melker.getElementById('totalCount');
          if (totalEl) totalEl.setValue('Total: ' + quakes.length);

          const maxEl = $melker.getElementById('maxMag');
          if (maxEl) maxEl.setValue('Largest: M' + maxM.toFixed(1));

          const avgMEl = $melker.getElementById('avgMag');
          if (avgMEl) avgMEl.setValue('Average: M' + avgM.toFixed(1));

          const avgDEl = $melker.getElementById('avgDepth');
          if (avgDEl) avgDEl.setValue('Avg depth: ' + avgD.toFixed(1) + ' km');
        }

        // Magnitude distribution
        const magDist = $melker.getElementById('magDist');
        if (magDist) {
          const dist = magDistribution(quakes);
          magDist.setValue(dist.bars);
          magDist.props.labels = dist.labels;
        }

        // Depth distribution
        const depthDist = $melker.getElementById('depthDist');
        if (depthDist) {
          const dist = depthDistribution(quakes);
          depthDist.setValue(dist.bars);
          depthDist.props.labels = dist.labels;
        }

        // Redraw world map
        const mapCanvas = $melker.getElementById('worldMap');
        if (mapCanvas) mapCanvas.markDirty();

        // Last update time
        const lastEl = $melker.getElementById('lastUpdate');
        if (lastEl) {
          const now = new Date();
          const h = now.getHours().toString().padStart(2, '0');
          const m = now.getMinutes().toString().padStart(2, '0');
          const s = now.getSeconds().toString().padStart(2, '0');
          lastEl.setValue('Updated ' + h + ':' + m + ':' + s);
        }
      }

      // Color for magnitude
      function magColor(mag: number): string {
        if (mag >= 5) return '#FF0000';
        if (mag >= 4) return '#FF6600';
        if (mag >= 3) return '#FFAA00';
        if (mag >= 2) return '#FFFF00';
        return '#00FF88';
      }

      // Draw earthquake overlay on tile map
      export function drawOverlay(event: any): void {
        const { canvas, geo } = event;
        const selId = state.selectedQuake.length > 0 ? state.selectedQuake[0] : null;

        // Draw earthquake dots (selected last so it's on top)
        let selPos: { x: number; y: number } | null = null;
        let selQuake: Quake | null = null;
        for (const q of quakes) {
          const pos = geo.latLonToPixel(q.lat, q.lon);
          if (!pos) continue;
          if (selId && q.id === selId) {
            selPos = pos;
            selQuake = q;
            continue;
          }
          const color = magColor(q.mag);
          const s = q.mag >= 5 ? 2 : q.mag >= 3 ? 1 : 1;
          canvas.fillCircleCorrectedColor(pos.x, pos.y, s, color);
        }
        // Draw selected quake on top with ring highlight
        if (selQuake && selPos) {
          const s = selQuake.mag >= 5 ? 3 : 2;
          canvas.fillCircleCorrectedColor(selPos.x, selPos.y, s, '#FFFFFF');
          canvas.fillCircleCorrectedColor(selPos.x, selPos.y, Math.max(1, s - 1), magColor(selQuake.mag));
        }
      }

      // Bump when GeoJSON→SVG conversion logic changes to invalidate cache
      const PLATES_CACHE_VERSION = 4;

      // Fetch tectonic plate boundaries and set as SVG overlay layer (cached)
      export async function fetchPlates(): Promise<void> {
        try {
          // Try cache first (processed SVG overlay string)
          const cacheKey = 'boundaries_v' + PLATES_CACHE_VERSION;
          const cached = await $melker.cache.read('plates', cacheKey);
          if (cached) {
            platesSvgOverlay = new TextDecoder().decode(cached);
            const map = $melker.getElementById('worldMap') as any;
            if (map && platesVisible) {
              map.setSvgOverlay('plates', platesSvgOverlay);
            }
            return;
          }

          const res = await fetch(PLATES_URL);
          if (!res.ok) return;
          const json = await res.json();
          const paths: string[] = [];
          for (const feature of json.features) {
            if (feature.geometry?.type !== 'LineString') continue;
            const coords: number[][] = feature.geometry.coordinates;
            if (coords.length < 2) continue;
            // Simplify: skip every other point for lines with many vertices
            const step = coords.length > 100 ? 3 : coords.length > 50 ? 2 : 1;
            let d = 'M ' + coords[0][0].toFixed(2) + ' ' + coords[0][1].toFixed(2);
            for (let i = step; i < coords.length - 1; i += step) {
              d += ' L ' + coords[i][0].toFixed(2) + ' ' + coords[i][1].toFixed(2);
            }
            // Always include last point
            const last = coords[coords.length - 1];
            d += ' L ' + last[0].toFixed(2) + ' ' + last[1].toFixed(2);
            paths.push('<path d="' + d + '" stroke="#002200"/>');
          }
          platesSvgOverlay = paths.join('\n');

          // Cache the processed overlay string
          await $melker.cache.write('plates', cacheKey, new TextEncoder().encode(platesSvgOverlay));

          const map = $melker.getElementById('worldMap') as any;
          if (map && platesVisible) {
            map.setSvgOverlay('plates', platesSvgOverlay);
          }
        } catch (_e) {
          // Non-critical, silently ignore
        }
      }

      // Toggle tectonic plate boundaries
      export function togglePlates(): void {
        platesVisible = !platesVisible;
        const map = $melker.getElementById('worldMap') as any;
        if (map) {
          if (platesVisible && platesSvgOverlay) {
            map.setSvgOverlay('plates', platesSvgOverlay);
          } else {
            map.removeSvgOverlay('plates');
          }
        }
      }

      // Main refresh cycle
      export async function refresh(): Promise<void> {
        const statusEl = $melker.getElementById('status');
        try {
          if (statusEl) statusEl.setValue('Fetching...');
          quakes = await fetchQuakes();

          // Notify on new earthquakes
          if (previousCount > 0 && quakes.length > previousCount) {
            const newCount = quakes.length - previousCount;
            $melker.toast.show(newCount + ' new earthquake' + (newCount > 1 ? 's' : '') + ' detected', { type: 'warning', duration: 5000 });
          }
          previousCount = quakes.length;

          // Alert for significant earthquakes (M6+), once per quake
          for (const q of quakes) {
            if (q.mag > 7 && !notifiedBigQuakes.has(q.id)) {
              notifiedBigQuakes.add(q.id);
              $melker.toast.show('M' + q.mag.toFixed(1) + ' ' + q.place, { type: 'error', duration: 10000 });
            }
          }

          updateUI();
          if (statusEl) statusEl.setValue(quakes.length + ' quakes');
        } catch (e) {
          if (statusEl) statusEl.setValue('Error: ' + e.message);
          $melker.toast.show('Failed to fetch data: ' + e.message, { type: 'error' });
        }
      }

      // Show details for a selected earthquake
      export function showDetails(event: { rowIndex: number; row: (string | number)[]; id?: string }): void {
        const quake = event.id ? quakes.find(q => q.id === event.id) : quakes[event.rowIndex];
        if (!quake) return;

        $melker.alert(
          'Earthquake Details\n\n' +
          'Magnitude: M' + quake.mag.toFixed(1) + '\n' +
          'Location:  ' + quake.place + '\n' +
          'Coords:    ' + quake.lat.toFixed(3) + ', ' + quake.lon.toFixed(3) + '\n' +
          'Depth:     ' + quake.depth.toFixed(1) + ' km\n' +
          'Time:      ' + new Date(quake.time).toLocaleString() + '\n' +
          'ID:        ' + quake.id
        );
      }

      // Sparkline tooltip
      export function sparklineTooltip(event: any): string | undefined {
        if (!event.context) return undefined;
        const q = sparklineQuakes[event.context.barIndex];
        if (!q) return undefined;
        return '**M' + q.mag.toFixed(1) + '** ' + q.place + '\n' +
          q.lat.toFixed(2) + ', ' + q.lon.toFixed(2) + ' | Depth: ' + q.depth.toFixed(1) + ' km\n' +
          fmtTime(q.time);
      }

      // Map tooltip - find nearest earthquake to mouse position
      export function mapTooltip(event: any): string | undefined {
        if (!event.context || quakes.length === 0) return undefined;
        const map = $melker.getElementById('worldMap') as any;
        if (!map || typeof map.pixelToLatLon !== 'function') return undefined;

        const hover = map.pixelToLatLon(event.context.pixelX, event.context.pixelY);
        const nearest = findNearestQuake(hover.lat, hover.lon);
        if (!nearest) return undefined;

        return '**M' + nearest.mag.toFixed(1) + '** ' + nearest.place + '\n' +
          nearest.lat.toFixed(2) + ', ' + nearest.lon.toFixed(2) + ' | Depth: ' + nearest.depth.toFixed(1) + ' km\n' +
          fmtTime(nearest.time);
      }

      // Handle map click - find and select nearest earthquake
      export function mapClick(event: { lat: number; lon: number }): void {
        if (quakes.length === 0) return;

        const nearest = findNearestQuake(event.lat, event.lon);
        if (!nearest) return;

        state.selectedQuake = [nearest.id];
        const statusEl = $melker.getElementById('status');
        if (statusEl) statusEl.setValue('M' + nearest.mag.toFixed(1) + ' - ' + nearest.place);
      }

      // Handle row selection — update status bar and redraw map
      export function handleSelect(event: { id?: string }): void {
        const statusEl = $melker.getElementById('status');
        if (!event.id) {
          if (statusEl) statusEl.setValue(quakes.length + ' quakes');
        } else {
          const q = quakes.find(q => q.id === event.id);
          if (statusEl && q) {
            statusEl.setValue('M' + q.mag.toFixed(1) + ' - ' + q.place);
          }
        }
        // Redraw map to show selection
        const mapCanvas = $melker.getElementById('worldMap');
        if (mapCanvas) mapCanvas.markDirty();
      }

      // Find nearest quake to a lat/lon position (for click and tooltip)
      function findNearestQuake(lat: number, lon: number): Quake | null {
        let nearest: Quake | null = null;
        let minDist = Infinity;
        for (const q of quakes) {
          const dlat = q.lat - lat;
          const dlon = q.lon - lon;
          const dist = dlat * dlat + dlon * dlon;
          if (dist < minDist) {
            minDist = dist;
            nearest = q;
          }
        }

        const map = $melker.getElementById('worldMap') as any;
        const zoom = typeof map?.getZoom === 'function' ? map.getZoom() : 2;
        const degreesPerPixel = 360 / Math.pow(2, zoom);
        const threshold = (degreesPerPixel * 5) * (degreesPerPixel * 5);
        if (!nearest || minDist > threshold) return null;
        return nearest;
      }

      // Change feed
      export async function changeFeed(event: { value: string }): Promise<void> {
        currentFeed = event.value;
        previousCount = 0;
        await refresh();
        $melker.render();
      }

      // Start auto-refresh
      export function startAutoRefresh(): void {
        if (refreshTimer) clearInterval(refreshTimer);
        refreshTimer = setInterval(async () => {
          await refresh();
          $melker.render();
        }, 60000);
      }
    </script>

    <script type="typescript" async="ready">
      await $app.refresh();
      $melker.render();
      $app.startAutoRefresh();
      // Fetch tectonic plate boundaries in background
      $app.fetchPlates().then(() => $melker.render());
    </script>
</melker>
