/* eslint-disable no-undef */
// Live data layer — real APIs (Open-Meteo, OpenWeather, Ambee) + NASA GIBS satellite layers,
// color ramps, and inverse-distance-weighted interpolation for the sensor heatmap.
// All network calls have an 8s timeout and graceful synthetic fallback so the UI never blocks.

const LIVE_CONFIG = {
  openweather_key: "e197b0bc4f164943d33c42553c5b14f0",
  ambee_key: "14c812e8bb1f10d782c5a15668de7c488323e165b7f11dfaa13f040816619363",
  // CDSE satellite backend (Flask app in /backend). Override via window.AGB_CDSE_BACKEND.
  cdse_backend: (typeof window !== "undefined" && window.AGB_CDSE_BACKEND) || "http://localhost:5000",
  // Recent date for GIBS NDVI 8-day composite (must be a valid past date)
  gibs_date: (() => {
    const d = new Date();
    d.setDate(d.getDate() - 12);
    return d.toISOString().slice(0, 10);
  })(),
};

// ---------- NASA GIBS satellite tile layers (real, live, open, no key) ----------
const GIBS_LAYERS = {
  truecolor: {
    label: "True color (VIIRS)",
    template: "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_SNPP_CorrectedReflectance_TrueColor/default/{time}/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg",
    maxNativeZoom: 9, time: () => LIVE_CONFIG.gibs_date, attribution: "NASA GIBS / VIIRS",
  },
  ndvi: {
    label: "NDVI (MODIS 8-day)",
    template: "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/MODIS_Terra_NDVI_8Day/default/{time}/GoogleMapsCompatible_Level7/{z}/{y}/{x}.png",
    maxNativeZoom: 7, time: () => LIVE_CONFIG.gibs_date, attribution: "NASA GIBS / MODIS Terra NDVI",
  },
  lst: {
    label: "Land surface temp (MODIS)",
    template: "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/MODIS_Terra_Land_Surface_Temp_Day/default/{time}/GoogleMapsCompatible_Level7/{z}/{y}/{x}.png",
    maxNativeZoom: 7, time: () => LIVE_CONFIG.gibs_date, attribution: "NASA GIBS / MODIS LST",
  },
};

// ---------- Fetch with timeout ----------
async function fetchJSON(url, opts = {}, timeoutMs = 8000) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
  try {
    const res = await fetch(url, { ...opts, signal: ctrl.signal });
    clearTimeout(t);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return { ok: true, data: await res.json() };
  } catch (e) {
    clearTimeout(t);
    return { ok: false, error: e.message || String(e) };
  }
}

// ---------- Open-Meteo (no key; weather + soil moisture/temp at depth + daily forecast) ----------
async function getOpenMeteo(lat, lng) {
  const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}`
    + `&current=temperature_2m,apparent_temperature,relative_humidity_2m,precipitation,rain,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl,cloud_cover,soil_temperature_0cm,soil_moisture_0_to_1cm,uv_index`
    + `&hourly=soil_temperature_0cm,soil_temperature_6cm,soil_temperature_18cm,soil_moisture_0_to_1cm,soil_moisture_1_to_3cm,soil_moisture_3_to_9cm,soil_moisture_9_to_27cm,et0_fao_evapotranspiration`
    + `&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code,sunrise,sunset,uv_index_max,wind_speed_10m_max,precipitation_probability_max`
    + `&timezone=auto&forecast_days=7`;
  const r = await fetchJSON(url);
  return r;
}

// ---------- Ambee pollen + fire (user key; CORS-prone → fallback) ----------
async function getAmbeePollen(lat, lng) {
  return fetchJSON(`https://api.ambeedata.com/latest/pollen/by-lat-lng?lat=${lat}&lng=${lng}`,
    { headers: { "x-api-key": LIVE_CONFIG.ambee_key, "Content-type": "application/json" } });
}
async function getAmbeeFire(lat, lng) {
  return fetchJSON(`https://api.ambeedata.com/fire/latest/by-lat-lng?lat=${lat}&lng=${lng}`,
    { headers: { "x-api-key": LIVE_CONFIG.ambee_key, "Content-type": "application/json" } });
}

// ---------- Open-Meteo air quality (no key) ----------
async function getAirQuality(lat, lng) {
  const url = `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lng}`
    + `&current=us_aqi,pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,ozone,ammonia,methane&timezone=auto`;
  return fetchJSON(url);
}

// ---------- OpenWeather current + air pollution (user key) ----------
async function getOpenWeather(lat, lng) {
  const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lng}&units=metric&appid=${LIVE_CONFIG.openweather_key}`;
  return fetchJSON(url);
}
async function getOpenWeatherAir(lat, lng) {
  const url = `https://api.openweathermap.org/data/2.5/air_pollution?lat=${lat}&lon=${lng}&appid=${LIVE_CONFIG.openweather_key}`;
  return fetchJSON(url);
}

// ---------- Ambee soil (user key; often CORS-blocked in browser → fallback) ----------
async function getAmbeeSoil(lat, lng) {
  const url = `https://api.ambeedata.com/soil/latest/by-lat-lng?lat=${lat}&lng=${lng}`;
  return fetchJSON(url, { headers: { "x-api-key": LIVE_CONFIG.ambee_key, "Content-type": "application/json" } });
}

// ---------- Aggregate: pull everything, fill gaps with synthetic ----------
async function getLiveBundle(lat, lng, seed = 1) {
  const [om, aq, ow, owAir, ambee, pollen, fire] = await Promise.all([
    getOpenMeteo(lat, lng),
    getAirQuality(lat, lng),
    getOpenWeather(lat, lng),
    getOpenWeatherAir(lat, lng),
    getAmbeeSoil(lat, lng),
    getAmbeePollen(lat, lng),
    getAmbeeFire(lat, lng),
  ]);
  const rand = mulberry32(Math.round(Math.abs(lat * lng * 1000)) + seed);

  // Weather (prefer Open-Meteo current, then OpenWeather)
  const omc = om.ok ? om.data.current : null;
  const owd = ow.ok ? ow.data : null;
  const dirLabel = (deg) => deg == null ? "—" : ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.round(deg / 45) % 8];
  const weather = {
    source: om.ok ? "Open-Meteo" : (ow.ok ? "OpenWeather" : "estimated"),
    temp: omc?.temperature_2m ?? (owd ? owd.main.temp : +(8 + rand() * 18).toFixed(1)),
    feels: omc?.apparent_temperature ?? (owd ? owd.main.feels_like : null),
    humidity: omc?.relative_humidity_2m ?? (owd ? owd.main.humidity : Math.round(45 + rand() * 40)),
    wind: omc?.wind_speed_10m ?? (owd ? owd.wind.speed : +(2 + rand() * 18).toFixed(1)),
    gust: omc?.wind_gusts_10m ?? (owd ? owd.wind?.gust : null),
    windDir: dirLabel(omc?.wind_direction_10m ?? owd?.wind?.deg),
    precip: omc?.precipitation ?? (owd ? (owd.rain?.["1h"] || 0) : +(rand() * 4).toFixed(1)),
    pressure: omc?.pressure_msl ?? owd?.main?.pressure ?? Math.round(1005 + rand() * 25),
    clouds: omc?.cloud_cover ?? owd?.clouds?.all ?? Math.round(rand() * 100),
    uv: omc?.uv_index ?? (om.ok ? om.data.daily?.uv_index_max?.[0] : null) ?? +(rand() * 8).toFixed(1),
    visibility: owd?.visibility != null ? (owd.visibility / 1000) : null,
    sunrise: om.ok ? om.data.daily?.sunrise?.[0] : (owd ? new Date(owd.sys.sunrise * 1000).toISOString() : null),
    sunset: om.ok ? om.data.daily?.sunset?.[0] : (owd ? new Date(owd.sys.sunset * 1000).toISOString() : null),
    code: omc?.weather_code ?? (owd ? owd.weather?.[0]?.id : 1),
    desc: owd ? owd.weather?.[0]?.description : null,
    live: om.ok || ow.ok,
  };

  // Soil — Open-Meteo soil layers (real), Ambee as supplement
  const soilLayers = [];
  if (om.ok && om.data.hourly) {
    const h = om.data.hourly;
    const i = 0;
    soilLayers.push(
      { depth: "0 cm",    temp: h.soil_temperature_0cm?.[i],  moisture: h.soil_moisture_0_to_1cm?.[i] },
      { depth: "6 cm",    temp: h.soil_temperature_6cm?.[i],  moisture: h.soil_moisture_1_to_3cm?.[i] },
      { depth: "18 cm",   temp: h.soil_temperature_18cm?.[i], moisture: h.soil_moisture_3_to_9cm?.[i] },
      { depth: "27 cm",   temp: null,                          moisture: h.soil_moisture_9_to_27cm?.[i] },
    );
  } else {
    ["0 cm", "6 cm", "18 cm", "27 cm"].forEach((depth, i) => {
      soilLayers.push({ depth, temp: +(10 + rand() * 10 - i).toFixed(1), moisture: +(0.18 + rand() * 0.2).toFixed(3) });
    });
  }
  const soil = {
    source: om.ok ? "Open-Meteo soil model" : "estimated",
    layers: soilLayers,
    ambee: ambee.ok ? ambee.data?.data?.[0] : null,
    ambee_live: ambee.ok,
    live: om.ok,
  };

  // Air quality — prefer Open-Meteo, then OpenWeather
  const aqc = aq.ok ? aq.data.current : null;
  const owAQI = owAir.ok ? owAir.data.list?.[0] : null;
  const air = {
    source: aq.ok ? "Open-Meteo AQ" : (owAir.ok ? "OpenWeather AQ" : "estimated"),
    aqi: aqc?.us_aqi ?? (owAQI ? owAQI.main.aqi * 50 : Math.round(20 + rand() * 80)),
    pm25: aqc?.pm2_5 ?? owAQI?.components?.pm2_5 ?? +(5 + rand() * 25).toFixed(1),
    pm10: aqc?.pm10 ?? owAQI?.components?.pm10 ?? +(10 + rand() * 35).toFixed(1),
    co: aqc?.carbon_monoxide ?? owAQI?.components?.co ?? +(100 + rand() * 300).toFixed(0),
    no2: aqc?.nitrogen_dioxide ?? owAQI?.components?.no2 ?? +(5 + rand() * 30).toFixed(1),
    o3: aqc?.ozone ?? owAQI?.components?.o3 ?? +(20 + rand() * 60).toFixed(1),
    nh3: aqc?.ammonia ?? owAQI?.components?.nh3 ?? +(2 + rand() * 12).toFixed(1),
    ch4: aqc?.methane ?? null,
    live: aq.ok || owAir.ok,
  };

  // Daily forecast
  let forecast = [];
  if (om.ok && om.data.daily) {
    const d = om.data.daily;
    forecast = d.time.slice(0, 7).map((t, i) => ({
      date: t, max: d.temperature_2m_max[i], min: d.temperature_2m_min[i],
      precip: d.precipitation_sum[i], code: d.weather_code[i],
    }));
  } else {
    forecast = Array.from({ length: 7 }, (_, i) => ({
      date: new Date(Date.now() + i * 864e5).toISOString().slice(0, 10),
      max: +(14 + rand() * 10).toFixed(0), min: +(4 + rand() * 8).toFixed(0),
      precip: +(rand() * 6).toFixed(1), code: [0, 1, 2, 3, 61][Math.floor(rand() * 5)],
    }));
  }

  // Pollen (Ambee, with fallback)
  const pd = pollen.ok ? pollen.data?.data?.[0] : null;
  const pollenData = {
    live: pollen.ok,
    grass: pd?.Risk?.grass_pollen ?? ["Low", "Moderate", "High"][Math.floor(rand() * 3)],
    tree: pd?.Risk?.tree_pollen ?? ["Low", "Moderate", "High"][Math.floor(rand() * 3)],
    weed: pd?.Risk?.weed_pollen ?? ["Low", "Moderate"][Math.floor(rand() * 2)],
    grassCount: pd?.Count?.grass_pollen ?? Math.round(rand() * 80),
    treeCount: pd?.Count?.tree_pollen ?? Math.round(rand() * 120),
  };
  // Fire risk (Ambee, with fallback)
  const fd = fire.ok ? (fire.data?.data?.[0] || null) : null;
  const fireData = {
    live: fire.ok,
    count: fire.ok ? (fire.data?.data?.length || 0) : Math.floor(rand() * 3),
    nearest: fd?.distance != null ? `${fd.distance.toFixed(0)} km` : `${Math.round(20 + rand() * 200)} km`,
    risk: (om.ok && om.data.daily) ? (om.data.daily.temperature_2m_max[0] > 28 && weather.humidity < 35 ? "Elevated" : "Low") : (rand() > 0.7 ? "Elevated" : "Low"),
  };

  return { weather, soil, air, forecast, pollen: pollenData, fire: fireData, _raw: { om, aq, ow, owAir, ambee, pollen, fire } };
}

// ---------- WMO weather code → label ----------
function wmoLabel(code) {
  if (code == null) return "—";
  if (code === 0) return "Clear";
  if (code <= 2) return "Partly cloudy";
  if (code === 3) return "Overcast";
  if (code <= 48) return "Fog";
  if (code <= 57) return "Drizzle";
  if (code <= 67) return "Rain";
  if (code <= 77) return "Snow";
  if (code <= 82) return "Showers";
  if (code <= 99) return "Thunderstorm";
  return "—";
}

// ---------- Color ramps ----------
function lerp(a, b, t) { return a + (b - a) * t; }
function rampColor(stops, t) {
  t = Math.max(0, Math.min(1, t));
  const seg = t * (stops.length - 1);
  const i = Math.min(stops.length - 2, Math.floor(seg));
  const f = seg - i;
  const [r1, g1, b1] = stops[i], [r2, g2, b2] = stops[i + 1];
  return [Math.round(lerp(r1, r2, f)), Math.round(lerp(g1, g2, f)), Math.round(lerp(b1, b2, f))];
}
// Classic NDVI / yield ramp: red(low) → orange → yellow → green → teal → blue(high)
const RAMP_NDVI = [[165, 0, 38], [215, 48, 39], [244, 109, 67], [253, 174, 97], [254, 224, 139], [217, 239, 139], [166, 217, 106], [102, 189, 99], [26, 152, 80], [0, 104, 110], [37, 90, 190]];
// Moisture: tan(dry) → green → teal → deep blue(wet)
const RAMP_MOISTURE = [[120, 90, 50], [180, 150, 90], [200, 200, 120], [120, 190, 140], [60, 170, 180], [30, 110, 200], [20, 60, 160]];
// Temperature: blue(cold) → cyan → green → yellow → red(hot)
const RAMP_TEMP = [[40, 80, 200], [40, 160, 200], [100, 200, 120], [240, 220, 90], [240, 140, 50], [200, 40, 40]];
// Diverging pH: red(acidic) → white(neutral) → blue(alkaline)
const RAMP_PH = [[200, 60, 40], [240, 170, 90], [240, 240, 220], [120, 190, 200], [40, 90, 190]];

// ---------- Field metrics for the heatmap ----------
const FIELD_METRICS = {
  ndvi:     { label: "Crop health (NDVI)", short: "NDVI",     unit: "",      min: 0.15, max: 0.92, ramp: RAMP_NDVI,     decimals: 2, desc: "Vegetation vigor — derived from sensor canopy + satellite NDVI fusion." },
  moisture: { label: "Soil moisture",      short: "Moisture", unit: "% VWC", min: 18,   max: 58,   ramp: RAMP_MOISTURE, decimals: 0, desc: "Volumetric water content from NitroSense/EcoNitro soil probes." },
  no3:      { label: "Nitrate (NO₃⁻)",     short: "Nitrate",  unit: "ppm",   min: 4,    max: 42,   ramp: RAMP_NDVI,     decimals: 0, desc: "Root-zone nitrate — NitroSense electrochemical ISE." },
  soil_temp:{ label: "Soil temperature",   short: "Soil °C",  unit: "°C",    min: 7,    max: 24,   ramp: RAMP_TEMP,     decimals: 1, desc: "Root-zone temperature at probe depth." },
  ph:       { label: "Soil pH",            short: "pH",       unit: "pH",    min: 5.3,  max: 7.6,  ramp: RAMP_PH,       decimals: 1, desc: "Soil acidity/alkalinity — affects nutrient availability." },
};

// Assign a stable value to a sensor for a metric
function sensorMetricValue(sensor, metricKey) {
  const m = FIELD_METRICS[metricKey];
  const seed = sensor.id.charCodeAt(2) * 31 + metricKey.charCodeAt(0) * 7 + (sensor.eui?.charCodeAt(0) || 0);
  const r = mulberry32(seed)();
  // Bias by farm health-ish: use lat fractional for spatial structure
  const spatial = (Math.sin(sensor.lat * 80) + Math.cos(sensor.lng * 80)) * 0.5 * 0.5 + 0.5;
  const t = 0.25 * r + 0.75 * spatial;
  return +(m.min + t * (m.max - m.min)).toFixed(m.decimals);
}

// ---------- IDW interpolation → canvas dataURL ----------
function buildHeatmapCanvas(points, bounds, metricKey, gridN = 70, power = 2.4) {
  // points: [{lat,lng,value}], bounds: {minLat,maxLat,minLng,maxLng}
  const m = FIELD_METRICS[metricKey];
  const canvas = document.createElement("canvas");
  canvas.width = gridN; canvas.height = gridN;
  const ctx = canvas.getContext("2d");
  const img = ctx.createImageData(gridN, gridN);
  const { minLat, maxLat, minLng, maxLng } = bounds;
  for (let gy = 0; gy < gridN; gy++) {
    for (let gx = 0; gx < gridN; gx++) {
      // map grid to lat/lng (gy=0 is top = maxLat)
      const lat = maxLat - (gy / (gridN - 1)) * (maxLat - minLat);
      const lng = minLng + (gx / (gridN - 1)) * (maxLng - minLng);
      let num = 0, den = 0, exact = null;
      for (const p of points) {
        const dLat = (lat - p.lat) * 111;
        const dLng = (lng - p.lng) * 111 * Math.cos(lat * Math.PI / 180);
        const d2 = dLat * dLat + dLng * dLng;
        if (d2 < 1e-7) { exact = p.value; break; }
        const w = 1 / Math.pow(d2, power / 2);
        num += w * p.value; den += w;
      }
      const val = exact != null ? exact : num / den;
      const t = (val - m.min) / (m.max - m.min);
      const [r, g, b] = rampColor(m.ramp, t);
      const idx = (gy * gridN + gx) * 4;
      img.data[idx] = r; img.data[idx + 1] = g; img.data[idx + 2] = b; img.data[idx + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
  // Upscale with smoothing for the blobby look
  const out = document.createElement("canvas");
  out.width = 420; out.height = 420;
  const octx = out.getContext("2d");
  octx.imageSmoothingEnabled = true;
  octx.imageSmoothingQuality = "high";
  octx.drawImage(canvas, 0, 0, out.width, out.height);
  return out.toDataURL("image/png");
}

// ---------- Canadian Space Agency (CSA) open data — CKAN API ----------
// https://donnees-data.asc-csa.gc.ca/  (Earth observation / RADARSAT / soil moisture)
const CSA_FALLBACK = [
  { title: "RADARSAT Constellation Mission (RCM) — C-band SAR", org: "Canadian Space Agency", note: "C-band synthetic aperture radar; soil moisture, crop monitoring & flood mapping, 4-day revisit.", tag: "SAR" },
  { title: "RADARSAT-2 imagery", org: "Canadian Space Agency", note: "High-resolution SAR for agricultural land and surface change.", tag: "SAR" },
  { title: "Soil Moisture Active Passive (SMAP) products", org: "CSA / NASA", note: "L-band radiometer soil-moisture retrievals for root-zone modelling.", tag: "Soil" },
  { title: "Annual Crop Inventory", org: "AAFC / CSA EO", note: "Satellite-derived national crop-type classification, 30 m.", tag: "Crop" },
  { title: "Aurora & space weather (AuroraMAX)", org: "Canadian Space Agency", note: "Geomagnetic activity relevant to GNSS/RTK precision-ag accuracy.", tag: "GNSS" },
];
async function getCSAData(query = "soil moisture", rows = 5) {
  const base = "https://donnees-data.asc-csa.gc.ca";
  const url = `${base}/api/3/action/package_search?q=${encodeURIComponent(query)}&rows=${rows}`;
  const r = await fetchJSON(url, {}, 8000);
  if (r.ok && r.data?.result?.results?.length) {
    return {
      live: true, count: r.data.result.count,
      datasets: r.data.result.results.map((d) => ({
        title: d.title, org: d.organization?.title || "Canadian Space Agency",
        note: (d.notes || "").replace(/<[^>]+>/g, "").slice(0, 160),
        tag: (d.tags?.[0]?.display_name || "EO"),
        url: `${base}/dataset/${d.name}`,
        resources: d.num_resources || (d.resources?.length || 0),
      })),
    };
  }
  return { live: false, count: CSA_FALLBACK.length, datasets: CSA_FALLBACK };
}

function rgbCss([r, g, b]) { return `rgb(${r},${g},${b})`; }

// ---------- Field boundaries as GeoJSON (parcels tiled over the sensor extent) ----------
function farmFieldGeoJSON(farm, sensors, cols = 3, rows = 3) {
  const lats = sensors.map((s) => s.lat), lngs = sensors.map((s) => s.lng);
  const pad = 0.0015;
  const minLat = Math.min(...lats) - pad, maxLat = Math.max(...lats) + pad;
  const minLng = Math.min(...lngs) - pad, maxLng = Math.max(...lngs) + pad;
  const dLat = (maxLat - minLat) / rows, dLng = (maxLng - minLng) / cols;
  const rand = mulberry32((farm.id.charCodeAt(2) || 7) * 13);
  const m = FIELD_METRICS.ndvi;
  const feats = [];
  let n = 0;
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      n++;
      const gap = 0.14;
      const x0 = minLng + c * dLng + dLng * gap * rand();
      const x1 = minLng + (c + 1) * dLng - dLng * gap * rand();
      const y0 = minLat + r * dLat + dLat * gap * rand();
      const y1 = minLat + (r + 1) * dLat - dLat * gap * rand();
      const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
      // NDVI = inverse-distance avg of nearby sensors
      let num = 0, den = 0;
      sensors.forEach((s) => {
        const d = Math.hypot(s.lat - cy, s.lng - cx) + 1e-4;
        const w = 1 / (d * d);
        num += w * sensorMetricValue(s, "ndvi"); den += w;
      });
      const ndvi = +(num / den).toFixed(2);
      const area_ha = +(((y1 - y0) * 111000) * ((x1 - x0) * 111000 * Math.cos(cy * Math.PI / 180)) / 10000).toFixed(1);
      feats.push({
        type: "Feature",
        properties: { name: `Field ${n}`, ndvi, area_ha, crop: farm.crop,
          health: ndvi >= 0.65 ? "Excellent" : ndvi >= 0.45 ? "Good" : ndvi >= 0.3 ? "Moderate stress" : "Stressed" },
        geometry: { type: "Polygon", coordinates: [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]] },
      });
    }
  }
  // outer boundary feature
  const boundary = {
    type: "Feature",
    properties: { name: farm.name, boundary: true, area_ha: +(farm.acres * 0.404686).toFixed(0) },
    geometry: { type: "Polygon", coordinates: [[[minLng, minLat], [maxLng, minLat], [maxLng, maxLat], [minLng, maxLat], [minLng, minLat]]] },
  };
  return { type: "FeatureCollection", features: [boundary, ...feats] };
}

Object.assign(window, {
  LIVE_CONFIG, GIBS_LAYERS, FIELD_METRICS, RAMP_NDVI,
  getLiveBundle, getOpenMeteo, getAmbeeSoil, wmoLabel, getCSAData,
  rampColor, rgbCss, buildHeatmapCanvas, sensorMetricValue, farmFieldGeoJSON,
});
