FlorIA - Plant Identifier

Street View plant ID with PlantNet: paste/drag image, top results + iNat/GBIF/POWO/Wiki links, history gallery, auto-open, local iNat check, and full settings (language, organ, thresholds, iNat radius, privacy, enhance, debug). Requires a PlantNet API key.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         FlorIA - Plant Identifier
// @namespace    https://greasyfork.org/en/users/1518176-math56
// @version      1.1
// @description  Street View plant ID with PlantNet: paste/drag image, top results + iNat/GBIF/POWO/Wiki links, history gallery, auto-open, local iNat check, and full settings (language, organ, thresholds, iNat radius, privacy, enhance, debug). Requires a PlantNet API key.
// @author       Math56 + AI (Perplexity/Codex)
// @icon         https://static.wixstatic.com/media/774bbe_f3ddb022c16c4884948409a3a56a590e~mv2.png
// @include      *://maps.google.com/*
// @include      *://*.google.*/maps/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      my-api.plantnet.org
// @connect      api.inaturalist.org
// @license      MIT
// ==/UserScript==


(function () {
'use strict';

/* =============================
   CONFIG
   ============================= */
const PLANTNET_API_KEY = "PASTE_YOUR_KEY_HERE"

/*
   🔑 How to get your PlantNet API key:

   1. Create an account at https://my.plantnet.org/ if you don't have one yet.
   2. Open your account/API page and copy your API key.
   3. Paste it between the quotes above, replacing "".

   ⚠️ Notes:
   - Keep your API key private, never publish it.
   - Free plans include a limited number of identifications per month.
   - If you leave the key empty, the script will show a popup reminding you to add it.
*/

const ENDPOINT = 'https://my-api.plantnet.org/v2/identify/all';
const ICON_URL = 'https://static.wixstatic.com/media/774bbe_f3ddb022c16c4884948409a3a56a590e~mv2.png';

/* =============================
   SETTINGS (condensed storage)
   ============================= */
const ST = {
  language:        ['en',          'plantnet_language'],     // "en" | "fr" | "en,fr"
  nameFormat:      ['both',        'floria_nameFormat'],    // 'scientific' | 'common' | 'both'
  organ:           ['leaf',        'plantnet_organ'],        // PlantNet organ: leaf | flower | fruit | bark | habit
  noReject:        [true,          'plantnet_noReject'],     // PlantNet no-reject
  includeRelatedImages: [false,    'plantnet_includeRelatedImages'], // PlantNet include-related-images
  topResults:      ['5',           'plantnet_topResults'],   // '3' | '5' | '10' | 'all'
  minScore:        [2,             'plantnet_minScore'],     // % (filter low-confidence results)
  minConf:         [20,            'floria_minConf'],      // %
  autoOpen:        [0,             'floria_autoOpen'],     // 0 disables
  inatRadiusKm:    [10,            'floria_inatRadiusKm'], // km
  inatMapTab:      [true,          'floria_inatMapTab'],   // open iNat map tab by default
  privacyNoCoords: [false,         'plantnet_privacyNoCoords'],
  debug:           [false,         'plantnet_debug'],       // log debug info
  dynamicGap:      [10,            'floria_dynamicGap'],   // %
  highCertain:     [80,            'floria_highCertain'],  // %
  enhanceLocal:    [true,          'floria_enhanceLocal'], // mild sharpen
  history:         ['[]',          'floria_history']       // array, capped 50
};
const get = k => {
  const [d, key] = ST[k]; const v = GM_getValue(key, null);
  if (v === null) return d;
  if (typeof d === 'number') return +v;
  if (typeof d === 'boolean') return !!v;
  return v;
};
const set = (k, v) => GM_setValue(ST[k][1], typeof ST[k][0] === 'boolean' ? !!v : v);
const getHistory = () => {
  try { return JSON.parse(GM_getValue(ST.history[1], '[]')) || []; } catch { return []; }
};
const saveHistory = arr => GM_setValue(ST.history[1], JSON.stringify(arr.slice(0, 50)));

/* =============================
   SMALL HELPERS
   ============================= */
const el = (tag, attrs = {}, ...kids) => {
  const e = document.createElement(tag);
  Object.entries(attrs).forEach(([k, v]) => (k in e ? e[k] = v : e.setAttribute(k, v)));
  for (const k of kids) e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k);
  return e;
};
const css = (e, o) => Object.assign(e.style, o);
const $ = sel => document.querySelector(sel);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

async function retryFetch(url, opts = {}, retries = 2, backoffMs = 500) {
  let lastErr;
  for (let i = 0; i <= retries; i++) {
    try {
      const res = await fetch(url, opts);
      if (!res.ok && (res.status === 429 || res.status >= 500)) {
        throw new Error(`HTTP ${res.status}`);
      }
      return res;
    } catch (err) {
      lastErr = err;
      if (i === retries) throw lastErr;
      await sleep(backoffMs * (i + 1));
    }
  }
  throw lastErr;
}

async function gmRequestWithRetry(options, retries = 2, backoffMs = 500) {
  let lastErr;
  for (let i = 0; i <= retries; i++) {
    try {
      const resp = await new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          ...options,
          onload: r => resolve({
            ok: r.status >= 200 && r.status < 300,
            status: r.status,
            responseText: r.responseText,
            response: r.response
          }),
          onerror: err => {
            const e = new Error('Network error');
            e.details = { url: options.url, error: err };
            reject(e);
          }
        });
      });
      if (!resp.ok && (resp.status === 429 || resp.status >= 500) && i < retries) {
        await sleep(backoffMs * (i + 1));
        continue;
      }
      return resp;
    } catch (err) {
      lastErr = err;
      if (i === retries) throw lastErr;
      await sleep(backoffMs * (i + 1));
    }
  }
  throw lastErr;
}

function getFallbackTerms(primaryTerm, secondaryTerm) {
  const terms = [];
  const pushUnique = term => {
    const t = (term || '').trim();
    if (!t || terms.includes(t)) return;
    terms.push(t);
  };
  const addShortened = term => {
    const words = (term || '').trim().split(/\s+/).filter(Boolean);
    for (let i = words.length - 1; i > 0 && terms.length < 4; i--) {
      pushUnique(words.slice(0, i).join(' '));
    }
  };

  pushUnique(primaryTerm);
  if (secondaryTerm && secondaryTerm.trim() !== (primaryTerm || '').trim()) {
    pushUnique(secondaryTerm);
  }
  addShortened(secondaryTerm);
  if (terms.length < 4) addShortened(primaryTerm);

  return terms.slice(0, 4);
}

function extractLatLng() {
  const m = location.href.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
  return m ? { lat: +m[1], lng: +m[2] } : null;
}
function formatNetworkError(err) {
  if (!err) return 'Unknown error';
  const status = err?.details?.status;
  const statusText = err?.details?.statusText;
  const base = err.message || String(err);
  if (status) return `${base} (${status}${statusText ? ` ${statusText}` : ''})`;
  return base;
}
async function toDataURLFromBlobOrFile(file) {
  return new Promise((resolve, reject) => {
    const fr = new FileReader();
    fr.onload = () => resolve(fr.result);
    fr.onerror = reject;
    fr.readAsDataURL(file);
  });
}
// PlantNet expects a binary file; convert base64 data URLs to a Blob.
async function dataURLtoBlob(dataUrl) {
  return new Promise(resolve => {
    fetch(dataUrl)
      .then(res => res.blob())
      .then(blob => resolve(blob));
  });
}
async function makeThumb(dataUrl, maxW = 320) {
  return new Promise(res => {
    const img = new Image();
    img.onload = () => {
      const scale = Math.min(1, maxW / img.width);
      const w = Math.round(img.width * scale), h = Math.round(img.height * scale);
      const c = document.createElement('canvas'); c.width = w; c.height = h;
      c.getContext('2d').drawImage(img, 0, 0, w, h);
      res(c.toDataURL('image/jpeg', 0.85));
    };
    img.crossOrigin = 'anonymous';
    img.src = dataUrl;
  });
}
// mild local contrast/saturation + sharpen
async function enhanceDataUrl(dataUrl) {
  if (!get('enhanceLocal')) return dataUrl;
  return new Promise(res => {
    const img = new Image();
    img.onload = () => {
      const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
      const ctx = c.getContext('2d');
      const t = document.createElement('canvas'); t.width = img.width; t.height = img.height;
      const tx = t.getContext('2d'); tx.filter = 'contrast(110%) saturate(110%)'; tx.drawImage(img, 0, 0);
      ctx.drawImage(t, 0, 0);
      const id = ctx.getImageData(0, 0, c.width, c.height), out = ctx.createImageData(c.width, c.height);
      const k = [0,-1,0,-1,5,-1,0,-1,0], src = id.data, dst = out.data, w = id.width, h = id.height;
      for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) {
        for (let ch = 0; ch < 3; ch++) {
          let sum = 0, idx = 0;
          for (let ky = -1; ky <= 1; ky++) for (let kx = -1; kx <= 1; kx++)
            sum += src[((y + ky) * w + (x + kx)) * 4 + ch] * k[idx++];
          dst[(y * w + x) * 4 + ch] = Math.max(0, Math.min(255, sum));
        }
        dst[(y * w + x) * 4 + 3] = src[(y * w + x) * 4 + 3];
      }
      ctx.putImageData(out, 0, 0);
      res(c.toDataURL('image/jpeg', 0.9));
    };
    img.crossOrigin = 'anonymous';
    img.src = dataUrl;
  });
}

/* =============================
   PLANTNET & iNAT
   ============================= */
function buildResultLinks(result) {
  const sci = result?.sci || '';
  const sciEncoded = encodeURIComponent(sci);
  const wikiTitle = encodeURIComponent(sci.replace(/ /g, '_'));
  const lang = (get('language') || 'en').split(',')[0].trim() || 'en';
  const mapTab = get('inatMapTab') ? '#map-tab' : '';
  const wikiFallback = `https://${lang}.wikipedia.org/wiki/${wikiTitle}`;
  const wikiEn = `https://en.wikipedia.org/wiki/${wikiTitle}`;
  const gbifId = result?.gbif != null ? String(result.gbif) : null;
  const powoId = result?.powo != null ? String(result.powo) : null;
  const inatId = result?.inatId ?? result?.inat ?? result?.inat_id ?? null;
  const inatUrl = result?.inatUrl || null;
  const inatSlug = encodeURIComponent(sci.replace(/ /g, '_'));
  return {
    gbif: gbifId ? `https://www.gbif.org/species/${encodeURIComponent(gbifId)}` : null,
    inat: inatUrl || (inatId
      ? `https://www.inaturalist.org/taxa/${encodeURIComponent(String(inatId))}-${inatSlug}${mapTab}`
      : `https://www.inaturalist.org/taxa/search?q=${sciEncoded}`),
    powo: powoId
      ? `https://powo.science.kew.org/taxon/${encodeURIComponent(powoId)}`
      : `https://powo.science.kew.org/results?q=${sciEncoded}`,
    tela: `https://www.tela-botanica.org/?post_type=taxon&tb_nom=${sciEncoded}`,
    wiki: result?.inatWikipedia || wikiFallback || wikiEn
  };
}
async function identifyPlant(dataUrl) {
  if (!ensureApiKey()) return;
  const langPref = (get('language') || 'en').split(',')[0].trim() || 'en';

  const noReject = get('noReject') ? 'true' : 'false';
  const includeRelatedImages = get('includeRelatedImages') ? 'true' : 'false';

  // PlantNet uses multipart/form-data + api-key query param (Plant.id used JSON + header).
  const url = `${ENDPOINT}?api-key=${encodeURIComponent(PLANTNET_API_KEY)}&lang=${encodeURIComponent(langPref)}&no-reject=${encodeURIComponent(noReject)}&include-related-images=${encodeURIComponent(includeRelatedImages)}`;
  // Convert dataUrl to Blob
  const blob = await dataURLtoBlob(dataUrl);

  // Create FormData
  const formData = new FormData();
  formData.append('images', blob, 'image.jpg');
  // PlantNet expects an array; repeating the same key builds that array.
  formData.append('organs', get('organ') || 'leaf');

  if (get('debug')) {
    console.log('PlantNet request:', {
      url: url.replace(PLANTNET_API_KEY, '[HIDDEN]'),
      organ: get('organ') || 'leaf',
      noReject,
      includeRelatedImages
    });
  }

  // Request (use GM_xmlhttpRequest to bypass userscript CORS)
  const response = await gmRequestWithRetry({
    method: 'POST',
    url,
    data: formData,
    headers: {}
  });

  if (get('debug')) {
    console.log('DEBUG: response keys:', Object.keys(response || {}));
    console.log('DEBUG: responseText length:', response?.responseText?.length ?? 0);
  }

  if (!response.ok) {
    const text = response.responseText || '';
    if (get('debug') && text) console.error('PlantNet error response:', text);
    const e = new Error(`HTTP ${response.status}${text ? `: ${text}` : ''}`);
    e.details = { status: response.status, url, responseText: text };
    throw e;
  }

  let r = null;
  try {
    r = JSON.parse(response.responseText || '{}');
  } catch {
    r = null;
  }
  const statusEl = document.getElementById('floria-status');
  const s = Array.isArray(r?.results) ? r.results : [];
  if (get('debug')) {
    const sample = s.slice(0, 3).map(v =>
      v?.species?.scientificName || v?.species?.scientificNameWithoutAuthor || 'Unknown'
    );
    console.log('PlantNet response summary:', {
      results: s.length,
      bestMatch: r?.bestMatch || '',
      sample,
      resultsExists: !!r?.results,
      resultsLength: Array.isArray(r?.results) ? r.results.length : 'not array',
      resultsIsArray: Array.isArray(r?.results)
    });
  }
  if (!s.length) {
    if (statusEl) statusEl.textContent = 'No species found.';
    return [];
  }

  const topResults = get('topResults') || '5';
  const maxResults = topResults === 'all' ? null : parseInt(topResults, 10);
  const useLimit = Number.isFinite(maxResults) && maxResults > 0;
  const minScore = Math.max(0, Number(get('minScore')) || 0);
  const limited = useLimit ? s.slice(0, maxResults) : s;

  const results = limited.map(v => {
    const species = v.species || {};
    const commonNames = species.commonNames;
    let commonName = '';
    if (Array.isArray(commonNames)) {
      const langHit = commonNames.find(n => n && typeof n === 'object' && (n.lang === langPref || n.language === langPref));
      if (langHit) commonName = langHit.name || langHit.commonName || '';
      if (!commonName) {
        const stringHit = commonNames.find(n => typeof n === 'string' && n.trim());
        if (stringHit) commonName = stringHit;
      }
      if (!commonName) {
        const objHit = commonNames.find(n => n && typeof n === 'object' && (n.name || n.commonName));
        if (objHit) commonName = objHit.name || objHit.commonName || '';
      }
    } else if (commonNames && typeof commonNames === 'object') {
      const langList = commonNames[langPref];
      if (Array.isArray(langList) && langList.length) commonName = langList[0];
      if (!commonName) {
        const any = Object.values(commonNames).find(v => Array.isArray(v) && v.length);
        if (any) commonName = any[0];
      }
    } else if (typeof commonNames === 'string') {
      commonName = commonNames;
    }
    if (!commonName) commonName = species.commonName || species.common_name || '';
    return {
      sci: species.scientificName || species.scientificNameWithoutAuthor || 'Unknown',
      prob: Math.round((v.score || 0) * 100),
      com: commonName,
      gbif: v.gbif && v.gbif.id != null ? v.gbif.id : null,
      powo: v.powo && v.powo.id != null ? v.powo.id : null,
      inatId: null,
      inatUrl: null,
      inatWikipedia: null,
      inatMatch: null,
      inat: null
    };
  }).filter(r => r.prob >= minScore);
  if (!results.length) {
    if (statusEl) statusEl.textContent = 'No species above confidence threshold.';
    return [];
  }

  const topSetting = get('topResults') || '5';
  let maxLookups = topSetting === 'all' ? 10 : parseInt(topSetting, 10);
  if (!Number.isFinite(maxLookups) || maxLookups <= 0) maxLookups = 5;
  const topResultsToLookup = results.slice(0, maxLookups);

  const inatPromises = topResultsToLookup.map(async (result, index) => {
    const primaryTerm = (result.com || result.common || result.commonName || '').trim();
    const scientificTerm = (result.scientificName || result.sci || '').trim();
    const searchTerm = primaryTerm || scientificTerm || '';
    const terms = getFallbackTerms(searchTerm, scientificTerm);
    let bestMatch = null;
    let usedTerm = searchTerm;
    try {
      for (let i = 0; i < terms.length; i++) {
        const term = terms[i];
        const res = await retryFetch(
          `https://api.inaturalist.org/v1/taxa/autocomplete?q=${encodeURIComponent(term)}&per_page=1`
        );
        const data = await res.json();
        const total = Number(data?.total_results ?? 0);
        if (get('debug')) {
          console.log(`iNat #${index} try ${i + 1}: "${term}" -> ${total}`);
        }
        if (total > 0) {
          bestMatch = Array.isArray(data?.results) ? data.results[0] : null;
          if (bestMatch) {
            bestMatch.usedTerm = term;
            usedTerm = term;
            if (get('debug')) {
              const wiki = bestMatch.wikipedia_url ? ` + Wikipedia: ${bestMatch.wikipedia_url}` : '';
              console.log(`iNat #${index} match: ID ${bestMatch.id} (${bestMatch.name})${wiki}`);
            }
          }
          break;
        }
      }
      return { index, match: bestMatch, searchTerm: usedTerm };
    } catch (e) {
      if (get('debug')) console.warn(`iNat lookup #${index} failed:`, searchTerm, e);
      return { index, match: null, searchTerm };
    }
  });

  const inatResponses = await Promise.all(inatPromises);
  if (get('debug')) {
    const sample = inatResponses
      .filter(r => r.match)
      .slice(0, 5)
      .map(r => ({ index: r.index, id: r.match?.id, name: r.match?.name, q: r.searchTerm }));
    console.log('iNat autocomplete:', { count: inatResponses.length, sample });
  }

  inatResponses.forEach(({ index, match, searchTerm }) => {
    if (index >= results.length) return;
    results[index].inatMatch = searchTerm || null;
    if (!match) return;
    results[index].inatId = match.id;
    results[index].inatWikipedia = match.wikipedia_url || null;
    const mapTab = get('inatMapTab') ? '#map-tab' : '';
    results[index].inatUrl = `https://www.inaturalist.org/taxa/${match.id}-${encodeURIComponent(match.name.replace(/ /g, '_'))}${mapTab}`;
    results[index].inat = match.id;
  });
  if (get('debug')) console.log('FlorIA mapped results:', results);
  if (statusEl) statusEl.textContent = 'Done.';
  return results;
}
async function inatLocalCount(id) {
  try {
    if (!id || get('privacyNoCoords')) return 0;
    const c = extractLatLng(); if (!c) return 0;
    const r = get('inatRadiusKm');
    const u = `https://api.inaturalist.org/v1/observations?taxon_id=${id}&lat=${c.lat}&lng=${c.lng}&radius=${r}&per_page=1&verifiable=true`;
    const j = await fetch(u).then(x => x.json());
    return j?.total_results ?? 0;
  } catch { return 0; }
}

function ensureApiKey() {
  if (!PLANTNET_API_KEY || PLANTNET_API_KEY === "PASTE_YOUR_KEY_HERE") {
    // Popup propre (ou alert minimaliste si tu veux + court)
    const wrap = document.createElement('div');
    wrap.style.cssText = `
      position:fixed;inset:0;z-index:20000;background:rgba(0,0,0,.45);
      display:flex;align-items:center;justify-content:center;
    `;
    const card = document.createElement('div');
    card.style.cssText = `
      width:420px;background:#fff;border-radius:12px;padding:16px;
      box-shadow:0 20px 50px rgba(0,0,0,.35);font:14px/1.4 system-ui;
    `;
    card.innerHTML = `
      <div style="font-weight:700;font-size:16px;margin-bottom:8px;">PlantNet API key required</div>
      <div style="color:#334155;margin-bottom:12px;">
        This userscript needs a PlantNet API key.
        <ol style="margin:6px 0 10px 20px;padding:0;font-size:13px;">
          <li>Create an account at <a href="https://my.plantnet.org/" target="_blank">my.plantnet.org</a></li>
          <li>Open your account/API page and copy your key</li>
          <li>Paste it into <code>PLANTNET_API_KEY</code>.</li>
        </ol>
      </div>
      <div style="display:flex;justify-content:flex-end;gap:8px;">
        <a href="https://my.plantnet.org/" target="_blank"
           style="padding:8px 12px;background:#0ea5e9;color:#fff;text-decoration:none;border-radius:8px;">
          Get API key
        </a>
        <button id="floria-key-close" style="padding:8px 12px;background:#0f172a;color:#fff;border:none;border-radius:8px;cursor:pointer;">
          OK
        </button>
      </div>
    `;
    wrap.appendChild(card);
    document.body.appendChild(wrap);
    wrap.querySelector('#floria-key-close').addEventListener('click', () => document.body.removeChild(wrap));
    return false;
  }
  return true;
}

/* =============================
   UI  (with logo + styles)
   ============================= */
function createUI() {
  if (document.getElementById('floria-toggle')) return;

  // Floating button with logo
  const toggle = el('button', { id: 'floria-toggle', title: 'Open FlorIA' },
    el('img', { src: ICON_URL, alt: 'FlorIA', width: 22, height: 22 })
  );
  css(toggle, {
    position: 'fixed', right: '16px', bottom: '16px', zIndex: 10000,
    background: '#0f172a', color: '#fff', border: 'none', borderRadius: '999px',
    padding: '8px 10px', display: 'flex', alignItems: 'center', gap: '8px',
    boxShadow: '0 10px 30px rgba(0,0,0,.25)', cursor: 'pointer'
  });
  document.body.appendChild(toggle);

  // Panel
  const panel = el('div', { id: 'floria-panel' });
  css(panel, {
    position: 'fixed', right: '16px', bottom: '64px', zIndex: 10000,
    width: '440px', background: '#f7fff7', border: '1px solid #cfe3cf',
    borderRadius: '12px', padding: '12px',
    boxShadow: '0 20px 40px rgba(0,0,0,.3)', display: 'none',
    font: '13px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif'
  });

  // Header with logo + title + settings
  const header = el('div', { className: 'floria-head' },
    el('div', { className: 'left' },
      el('img', { src: ICON_URL, alt: 'logo', width: 20, height: 20 }),
      el('span', { textContent: ' FlorIA - Plant identification', className: 'title' })
    ),
    el('div', { className: 'right' },
      el('button', { id: 'floria-settings', title: 'Settings', textContent: '⚙️' }),
      el('button', { id: 'floria-close', title: 'Close', textContent: 'X' })
    )
  );
  css(header, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' });
  css(header.querySelector('.left'), { display: 'flex', alignItems: 'center', gap: '8px', fontWeight: '700' });
  css(header.querySelector('.right'), { display: 'flex', alignItems: 'center', gap: '6px' });
  const btnSettings = header.querySelector('#floria-settings');
  css(btnSettings, { background: '#e2e8f0', border: 'none', borderRadius: '8px', padding: '6px 8px', cursor: 'pointer' });
  const btnClose = header.querySelector('#floria-close');
  css(btnClose, { background: '#e2e8f0', border: 'none', borderRadius: '8px', padding: '6px 8px', cursor: 'pointer' });

  // Dropzone
  const drop = el('div', { id: 'floria-drop' },
    el('div', { innerHTML: '<b>Paste (Ctrl+V)</b> or drop a file, or choose below.' }),
    el('input', { id: 'floria-file', type: 'file', accept: 'image/*' }),
    el('img', { id: 'floria-preview', style: 'display:none;max-height:260px;object-fit:contain;border-radius:8px;border:1px solid #e5e7eb;background:#fff;margin-top:6px;width:100%;' })
  );
  css(drop, { border: '2px dashed #94a3b8', borderRadius: '10px', padding: '10px', textAlign: 'center', background: '#fff', marginBottom: '8px' });

  // Actions
  const rowActions = el('div', { className: 'row-actions' },
    el('button', { id: 'floria-identify', textContent: 'Identify' }),
    el('button', { id: 'floria-openall', textContent: 'Open all', disabled: true })
  );
  css(rowActions, { display: 'flex', gap: '8px', marginBottom: '8px' });
  const btnIdentify = rowActions.querySelector('#floria-identify');
  const btnOpenAll  = rowActions.querySelector('#floria-openall');
  css(btnIdentify, { flex: 1, padding: '10px', background: '#16a34a', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' });
  css(btnOpenAll,  { flex: 1, padding: '10px', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', display: 'none' });

  // Results
  const results = el('div', { id: 'floria-results' });
  css(results, { display: 'none', background: '#fff', border: '1px solid #e5e7eb', borderRadius: '8px', padding: '8px', maxHeight: '260px', overflow: 'auto', marginBottom: '8px' });

  // History thumbnails
  const historyBox = el('div', { id: 'floria-history' },
    el('div', { textContent: 'History', style: 'font-weight:600;margin-bottom:6px;' }),
    el('div', { id: 'floria-history-list' })
  );
  css(historyBox, { background: '#f8fafc', border: '1px dashed #cbd5e1', borderRadius: '8px', padding: '8px', maxHeight: '120px', overflow: 'auto', marginBottom: '8px' });
  const historyList = historyBox.querySelector('#floria-history-list');
  css(historyList, { display: 'flex', gap: '6px', flexWrap: 'wrap' });

  // Status
  const status = el('div', { id: 'floria-status', textContent: 'Ready.' });
  css(status, { fontSize: '12px', color: '#333' });

  const poweredBy = el('div', { id: 'floria-powered-by-plantnet' },
    el('div', {},
      'The image-based plant species identification service is based on the Pl@ntNet recognition API,',
      ' regularly updated and accessible through the site ',
      el('a', { href: 'https://my.plantnet.org/', target: '_blank', rel: 'noopener noreferrer', textContent: 'https://my.plantnet.org/' }),
      '.'
    ),
    el('a', { href: 'https://my.plantnet.org/', target: '_blank', rel: 'noopener noreferrer', style: 'display:inline-block;margin-top:4px;' },
      el('img', { src: 'https://my.plantnet.org/images/powered-by-plantnet-light.png', alt: 'Powered by Pl@ntNet', style: 'height:24px;' })
    )
  );
  css(poweredBy, { marginTop: '6px', fontSize: '11px', opacity: '0.8' });

  panel.append(header, drop, rowActions, results, historyBox, status, poweredBy);
  document.body.appendChild(panel);

  let lastDataUrl = null;
  let urlsAbove = [];
  let currentResults = [];
  let history = getHistory();

  function hideOpenAll() { btnOpenAll.disabled = true; btnOpenAll.style.display = 'none'; urlsAbove = []; }
  function showOpenAllIfEligible() {
    if (urlsAbove.length >= 2) { btnOpenAll.disabled = false; btnOpenAll.style.display = 'inline-block'; }
    else hideOpenAll();
  }
  function renderHistory() {
    historyList.innerHTML = '';
    history.forEach((h, i) => {
      const card = el('div', { className: 'hist-card', title: h.top || '' });
      css(card, { border: '1px solid #e5e7eb', borderRadius: '6px', padding: '3px', cursor: 'pointer', background: '#fff' });
      const img = el('img', { src: h.thumb, width: 80, height: 50 });
      css(img, { objectFit: 'cover', borderRadius: '4px', display: 'block' });
      card.appendChild(img);
      card.addEventListener('click', () => {
        // load minimal – just show results saved (fast)
        lastDataUrl = null; // no re-identify; just display saved lines
        results.style.display = 'block';
        results.innerHTML = '';
        urlsAbove = [];
        (h.results || []).forEach((r, i2) => {
          const links = buildResultLinks(r);
          const row = buildResultRow(r, i2, links);
          results.appendChild(row);
          if (r.pct >= get('minConf')) urlsAbove.push(links.inat);
        });
        showOpenAllIfEligible();
        status.textContent = 'Loaded from history.';
      });
      historyList.appendChild(card);
    });
  }

  function nameTitle(sci, com) {
    const fmt = get('nameFormat');
    if (fmt === 'scientific') return sci;
    if (fmt === 'common') return com || sci;
    return com ? `${com} (${sci})` : sci;
  }
  function linkBar(result, links) {
    const linkSet = links || buildResultLinks(result);
    const wrap = el('div', {});
    css(wrap, { fontSize: '12px', color: '#475569' });
    const aINat = el('a', { href: linkSet.inat, target: '_blank', textContent: 'iNat' });
    const aGBIF = linkSet.gbif ? el('a', { href: linkSet.gbif, target: '_blank', textContent: 'GBIF' }) : null;
    const aPOWO = el('a', { href: linkSet.powo, target: '_blank', textContent: 'POWO' });
    const aTela = el('a', { href: linkSet.tela, target: '_blank', textContent: 'Tela' });
    const aWiki = el('a', { href: linkSet.wiki, target: '_blank', textContent: 'Wiki' });
    [aINat, aGBIF, aPOWO, aTela, aWiki].filter(Boolean).forEach((a, idx) => {
      if (idx) wrap.append(' · ');
      css(a, { textDecoration: 'none', color: '#0369a1' });
      wrap.appendChild(a);
    });
    return wrap;
  }
  function badge(text, bg, fg) {
    const b = el('span', { textContent: text });
    css(b, { marginLeft: '6px', background: bg, color: fg, padding: '2px 6px', borderRadius: '999px', fontSize: '11px' });
    return b;
  }
  function buildResultRow(r, idx, links) {
    const row = el('div', {});
    css(row, { display: 'flex', alignItems: 'center', justifyContent: 'space-between',
               borderBottom: '1px solid #eef2f7', padding: '6px 0', opacity: r.low ? .55 : 1 });

    const linkSet = links || buildResultLinks(r);
    const left = el('div', { style: 'flex:1;min-width:0;' });
    const title = el('div', { innerHTML: `${idx + 1}. ${r.title}` });
    css(title, { fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' });
    const sub = el('div', {}); css(sub, { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' });
    sub.append(el('span', { innerHTML: `${r.pct}%${r.low ? ' · <span style="color:#ef4444">low confidence</span>' : ''}` }));
    sub.append(linkBar(r, linkSet));
    if (r.localCount > 0) sub.append(badge('local', '#10b981', '#fff'));
    left.append(title, sub);

    const go = el('a', { href: linkSet.inat, target: '_blank', textContent: 'iNaturalist' });
    css(go, { textDecoration: 'none', background: '#0ea5e9', color: '#fff', padding: '6px 10px', borderRadius: '8px', flexShrink: 0 });

    row.append(left, go);
    return row;
  }

  async function setPreviewFromDataUrl(dataUrl) {
    lastDataUrl = dataUrl;
    const thumb = await makeThumb(dataUrl);
    const img = panel.querySelector('#floria-preview');
    img.src = thumb; img.style.display = 'block';
    results.style.display = 'none'; results.innerHTML = '';
    hideOpenAll();
    status.textContent = 'Preview ready. Click “Identify”.';
  }

  // Coller (Ctrl+V) DANS LA DROPZONE UNIQUEMENT
  drop.addEventListener('paste', async (e) => {
    const items = e.clipboardData?.items || [];
    for (const it of items) {
      if (it.kind === 'file' && it.type.startsWith('image/')) {
        const blob = it.getAsFile();
        const dataUrl = await toDataURLFromBlobOrFile(blob);
        await setPreviewFromDataUrl(dataUrl);
        e.preventDefault();
        return;
      }
    }
    status.textContent = 'No image in clipboard.';
  });
  // Drag & drop
  ;['dragenter','dragover'].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); e.stopPropagation(); drop.style.background = '#f1f5f9'; }));
  ;['dragleave','drop'].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); e.stopPropagation(); drop.style.background = '#fff'; }));
  drop.addEventListener('drop', async (e) => {
    const f = e.dataTransfer?.files?.[0]; if (!f || !f.type.startsWith('image/')) return;
    const dataUrl = await toDataURLFromBlobOrFile(f);
    await setPreviewFromDataUrl(dataUrl);
  });
  // File chooser
  const fileInput = drop.querySelector('#floria-file');
  fileInput.addEventListener('change', async () => {
    const f = fileInput.files?.[0]; if (!f || !f.type.startsWith('image/')) return;
    const dataUrl = await toDataURLFromBlobOrFile(f);
    await setPreviewFromDataUrl(dataUrl);
  });

  // Identify
  btnIdentify.addEventListener('click', async () => {
    if (!lastDataUrl) { status.textContent = 'No image yet.'; return; }
    if (!ensureApiKey()) return;

    status.textContent = 'Preparing image…';
    const send = await enhanceDataUrl(lastDataUrl);

    status.textContent = `Identifying… (lang: ${get('language')})`;
    results.style.display = 'none'; results.innerHTML = ''; hideOpenAll();

    try {
      const candidates = await identifyPlant(send);
      if (!candidates.length) {
        if (!/^No species/i.test(status.textContent || '')) status.textContent = 'No species candidate returned.';
        return;
      }

      const topResults = get('topResults') || '5';
      const maxResults = topResults === 'all' ? null : parseInt(topResults, 10);
      const useLimit = Number.isFinite(maxResults) && maxResults > 0;
      const sorted = candidates.sort((a, b) => (b.prob || 0) - (a.prob || 0));
      const shown = useLimit ? sorted.slice(0, maxResults) : sorted;
      const top1 = Math.round(shown[0].prob || 0);
      const top2 = Math.round(shown[1]?.prob || 0);
      let effMin = get('minConf');
      if (top1 - top2 < get('dynamicGap')) effMin = Math.min(100, effMin + 10);
      if (top1 >= get('highCertain'))      effMin = Math.max(5,   effMin - 10);

      const links = shown.map(c => buildResultLinks(c));
      const urls = links.map(link => link.inat);
      const locals = await Promise.all(shown.map(c => inatLocalCount(c.inatId || c.inat)));

      // Auto-open
      const auto = get('autoOpen');
      if (auto > 0 && top1 >= auto) window.open(urls[0], '_blank');

      // Render
      currentResults = [];
      results.style.display = 'block'; results.innerHTML = '';
      urlsAbove = [];
      shown.forEach((c, i) => {
        const pct = Math.round(c.prob || 0);
        const low = pct < effMin;
        const title = nameTitle(c.sci, c.com);
        const linkSet = links[i];
        const url = linkSet.inat;
        const r = {
          title,
          pct,
          url,
          localCount: locals[i] || 0,
          sci: c.sci,
          com: c.com,
          inatId: c.inatId || c.inat,
          inatUrl: c.inatUrl || c.inatMatch?.url || null,
          inatWikipedia: c.inatWikipedia || null,
          inatMatch: c.inatMatch || null,
          gbif: c.gbif,
          powo: c.powo,
          low
        };
        results.appendChild(buildResultRow(r, i, linkSet));
        currentResults.push(r);
        if (pct >= effMin) urlsAbove.push(url);
      });
      showOpenAllIfEligible();

      // Save history (thumb only, not full image)
      const thumb = panel.querySelector('#floria-preview').src;
      const topTitle = currentResults[0]?.title || '';
      history.unshift({ ts: Date.now(), thumb, top: `${topTitle} (${top1}%)`, results: currentResults });
      if (history.length > 50) history = history.slice(0, 50);
      saveHistory(history);
      renderHistory();

      status.textContent = 'Done.';
    } catch (e) {
      if (get('debug')) {
        console.error('FlorIA network error', e);
        if (e?.details?.responseText) console.debug('FlorIA response text', e.details.responseText);
      }
      const msg = formatNetworkError(e);
      status.textContent = `Identification error: ${msg}${get('debug') ? ' (see console)' : ''}`;
    }
  });

  // Open all in new tabs
  btnOpenAll.addEventListener('click', () => {
    if (!urlsAbove || urlsAbove.length < 2) return;
    urlsAbove.forEach(u => window.open(u, '_blank'));
  });

  // Toggle
  toggle.addEventListener('click', () => {
    panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
    if (panel.style.display === 'block') renderHistory();
  });

  // Close panel
  btnClose.addEventListener('click', () => { panel.style.display = 'none'; });

  // Settings panel
  btnSettings.addEventListener('click', showSettingsPanel);

  // Initial history render
  renderHistory();
}

/* =============================
   SETTINGS PANEL (with sliders)
   ============================= */
function slider(label, min, max, val, oninput) {
  const wrap = el('div', { className: 'slider-wrap' });
  const lab = el('label', { textContent: `${label}: ${val}%` });
  const s = el('input', { type: 'range', min: String(min), max: String(max), value: String(val) });
  css(wrap, { margin: '8px 0' }); css(s, { width: '100%' });
  s.addEventListener('input', () => { lab.textContent = `${label}: ${s.value}%`; oninput(+s.value); });
  wrap.append(lab, s); return wrap;
}
function select(label, values, current, onchange) {
  const w = el('div', {}); css(w, { margin: '6px 0' });
  const lab = el('label', { textContent: label }); css(lab, { display: 'block', marginBottom: '4px' });
  const sel = el('select', {});
  values.forEach(v => sel.append(el('option', { value: v, textContent: v, selected: v === current })));
  css(sel, { width: '100%', padding: '6px', border: '1px solid #cbd5e1', borderRadius: '8px' });
  sel.addEventListener('change', () => onchange(sel.value));
  w.append(lab, sel); return w;
}
function checkbox(label, checked, onchange) {
  const l = el('label', {}); css(l, { display: 'flex', alignItems: 'center', gap: '8px', margin: '6px 0' });
  const c = el('input', { type: 'checkbox', checked }); const t = el('span', { textContent: label });
  c.addEventListener('change', () => onchange(!!c.checked));
  l.append(c, t); return l;
}
function number(label, value, min, step, onchange) {
  const w = el('div', {}); css(w, { margin: '6px 0' });
  const lab = el('label', { textContent: label }); css(lab, { display: 'block', marginBottom: '4px' });
  const inp = el('input', { type: 'number', value: String(value), min: String(min), step: String(step) });
  css(inp, { width: '100%', padding: '6px', border: '1px solid #cbd5e1', borderRadius: '8px' });
  inp.addEventListener('input', () => onchange(Math.max(min, +inp.value || value)));
  w.append(lab, inp); return w;
}

function showSettingsPanel() {
  if ($('#floria-settings-panel')) return;
  const p = el('div', { id: 'floria-settings-panel' });
  css(p, {
    position: 'fixed', right: '16px', bottom: '64px', zIndex: 11000,
    width: '380px', background: '#ffffff', border: '1px solid #cbd5e1', borderRadius: '12px',
    boxShadow: '0 20px 50px rgba(0,0,0,.25)', padding: '14px', font: '13px/1.4 system-ui'
  });

  const head = el('div', { className: 'floria-settings-head' },
    el('div', { className: 'left' },
      el('img', { src: ICON_URL, width: 20, height: 20, style: 'vertical-align:middle;margin-right:6px;' }),
      el('b', { textContent: 'Settings' })
    ),
    el('div', { className: 'right' },
      el('button', { id: 'floria-settings-close', title: 'Close', textContent: 'X' })
    )
  );
  css(head, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' });
  const btnSettingsClose = head.querySelector('#floria-settings-close');
  css(btnSettingsClose, { background: '#e2e8f0', border: 'none', borderRadius: '8px', padding: '6px 8px', cursor: 'pointer' });
  btnSettingsClose.addEventListener('click', () => document.body.removeChild(p));

  // Controls
  let lang = get('language'), fmt = get('nameFormat'), organ = get('organ'),
      noReject = get('noReject'), includeRelatedImages = get('includeRelatedImages'),
      topR = get('topResults'), minScore = get('minScore'),
      minC = get('minConf'), auto = get('autoOpen'),
      gap  = get('dynamicGap'), high = get('highCertain'),
      rad  = get('inatRadiusKm'), mapTab = get('inatMapTab'), priv = get('privacyNoCoords'),
      enh  = get('enhanceLocal'), dbg = get('debug');

  const langSel = select('Language (PlantNet common names & Wikipedia)', ['en', 'fr', 'en,fr'], lang, v => lang = v);
  const fmtSel  = select('Name format', ['scientific', 'common', 'both'], fmt, v => fmt = v);
  const organSel = select('Organ (PlantNet)', ['leaf', 'flower', 'fruit', 'bark', 'habit'], organ, v => organ = v);
  const cNoReject = checkbox('No reject (PlantNet)', noReject, v => noReject = v);
  const cRelated = checkbox('Include related images', includeRelatedImages, v => includeRelatedImages = v);
  const topSel = select('Max results to show', ['3', '5', '10', 'all'], topR || '5', v => topR = v);

  const nScore = number('Min score filter (%)', minScore, 0, 1, v => minScore = v);
  const sMin  = slider('Min confidence (highlight/Open all)', 0, 100, minC, v => minC = v);
  const sAuto = slider('Auto-open top-1 if ≥', 0, 100, auto, v => auto = v);
  const sGap  = slider('Dynamic gap (raise min if top1-top2 &lt;)', 0, 30, gap, v => gap = v);
  const sHigh = slider('High certainty threshold (lowers min by 10 when reached)', 50, 100, high, v => high = v);

  const nRad = number('iNaturalist cross-check radius (km)', rad, 1, 1, v => rad = v);
  const cMapTab = checkbox('iNat: Map tab', mapTab, v => mapTab = v);
  const cPriv = checkbox('Never send coordinates (disables local cross-check)', priv, v => priv = v);
  const cEnh  = checkbox('Enhance locally (contrast/saturation + sharpen)', enh, v => enh = v);
  const cDbg  = checkbox('Debug logs (console)', dbg, v => dbg = v);

  const rowBtn = el('div', {}); css(rowBtn, { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '8px' });
  const save = el('button', { textContent: 'Save' });
  const cancel = el('button', { textContent: 'Cancel' });
  css(save, { padding: '8px 12px', background: '#16a34a', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' });
  css(cancel, { padding: '8px 12px', background: '#0f172a', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' });
  save.onclick = () => {
      set('language', lang); set('nameFormat', fmt); set('organ', organ); set('noReject', noReject);
      set('includeRelatedImages', includeRelatedImages); set('topResults', topR); set('minScore', minScore);
      set('minConf', minC); set('autoOpen', auto);
      set('dynamicGap', gap); set('highCertain', high); set('inatRadiusKm', rad);
      set('inatMapTab', mapTab);
      set('privacyNoCoords', priv); set('enhanceLocal', enh); set('debug', dbg);

      const msg = p.querySelector('#settings-msg') || document.createElement('div');
      msg.id = 'settings-msg';
      msg.style.cssText = "margin-top:8px;color:#16a34a;font-size:12px;";
      msg.textContent = "✅ Settings saved";
      p.appendChild(msg);

      // option: auto-hide after 2s
      setTimeout(() => msg.remove(), 2000);
  };

  cancel.onclick = () => document.body.removeChild(p);

  p.append(head, langSel, fmtSel, organSel, cNoReject, cRelated, topSel, nScore, sMin, sAuto, sGap, sHigh, nRad, cMapTab, cPriv, cEnh, cDbg, rowBtn);
  rowBtn.append(cancel, save);
  document.body.appendChild(p);
}

/* =============================
   MOUNT
   ============================= */
function waitForMaps(cb) {
  const t = setInterval(() => { if (location.href.includes('@')) { clearInterval(t); cb(); } }, 600);
}
let lastUrl = location.href;
new MutationObserver(() => {
  const cur = location.href;
  if (cur !== lastUrl) {
    lastUrl = cur;
    setTimeout(() => { if (cur.includes('@')) createUI(); }, 400);
  }
}).observe(document, { subtree: true, childList: true });

waitForMaps(() => createUI());

})();