// Florio Analytics — Labeling tab (ported from js/pages/labeling.js). // Human-in-the-loop supervised data collection for the avatar k-NN pipeline. // Shows one houseplant photo at a time, user picks the closest-matching avatar // from a grid, the label is written to `avatar_labels` in Firestore. A Cloud // Function (onLabelCreated) embeds the photo via Replicate CLIP and fills the // `embedding` field. The 3D cluster viz is a PCA projection of those embeddings. // // Rebuilt in the new design kit, but ALL behaviour is preserved: // • avatar pick grid with live Rive previews + per-avatar count badges // • queue loading (Unsplash queue vs user-scan queue tabs) // • label submit (batch write), skip ("no_match") + not-houseplant ("rejected") // • upload from computer, progress strip, per-avatar balance bars // • Unsplash seeding (key prompted + cached in localStorage) // • purge non-Unsplash pending items // • re-embed (test 1 / re-embed all) via the reEmbedLabels Cloud Function // • 3D cluster visualization (Three.js + OrbitControls, custom PCA) (function () { const { useState, useEffect, useRef, useCallback } = React; const e = React.createElement; // ─── Constants ───────────────────────────────────────────────────────────── // All 19 avatar ids. Append new avatars here to have them show up in the grid. const AVATAR_ORDER = [ "monstera", "fern", "peace", "snake", "cactus", "palm", "bushy", "hanging", "orchid", "aloevera", "succulent", "bonsai", "anthurium", "prayer", "dracaena", "ficus", "banana", "clover", "coin", ]; const DISPLAY_NAMES = { monstera: "Monstera", palm: "Palm", bushy: "Bushy", peace: "Peace Lily", cactus: "Cactus", succulent: "Succulent", aloevera: "Aloe Vera", snake: "Snake Plant", fern: "Fern", hanging: "Hanging", orchid: "Orchid", anthurium: "Anthurium", bonsai: "Bonsai", banana: "Banana", dracaena: "Dracaena", ficus: "Ficus", prayer: "Prayer Plant", clover: "Clover", coin: "Coin Plant", }; // Seed queries per category — Unsplash search terms for INDOOR houseplant photos. const SEED_CATEGORY_QUERIES = { monstera: ["monstera indoor plant", "monstera deliciosa indoor plant", "monstera adansonii indoor plant", "monstera potted indoor plant"], fern: ["fern indoor plant", "boston fern indoor plant", "birds nest fern indoor plant", "maidenhair fern indoor plant"], peace: ["peace lily indoor plant", "spathiphyllum indoor plant", "peace lily potted indoor plant"], snake: ["snake plant indoor plant", "sansevieria indoor plant", "snake plant potted indoor plant"], cactus: ["cactus indoor plant", "barrel cactus indoor plant", "cactus potted indoor plant", "euphorbia trigona indoor plant"], succulent: ["succulent indoor plant", "echeveria indoor plant", "haworthia indoor plant", "jade plant indoor plant"], aloevera: ["aloe vera indoor plant", "aloe vera potted indoor plant"], palm: ["parlor palm indoor plant", "areca palm indoor plant", "kentia palm indoor plant", "ponytail palm indoor plant"], orchid: ["orchid indoor plant", "orchid phalaenopsis indoor plant", "orchid potted indoor plant"], hanging: ["pothos indoor plant", "string of pearls indoor plant", "string of hearts indoor plant", "hoya indoor plant", "tradescantia zebrina indoor plant", "ivy indoor plant"], bushy: ["philodendron indoor plant", "philodendron brasil indoor plant", "syngonium indoor plant", "spider plant indoor plant", "alocasia indoor plant", "bird of paradise indoor plant"], anthurium: ["anthurium indoor plant", "anthurium potted indoor plant", "anthurium houseplant indoor plant"], bonsai: ["bonsai indoor plant", "bonsai tree indoor plant", "bonsai potted indoor plant"], banana: ["banana indoor plant", "banana plant potted indoor plant"], dracaena: ["dracaena indoor plant", "dracaena marginata indoor plant", "cordyline indoor plant"], ficus: ["fiddle leaf fig indoor plant", "rubber plant ficus indoor plant", "ficus benjamina indoor plant"], prayer: ["calathea indoor plant", "calathea orbifolia indoor plant", "maranta prayer plant indoor plant", "stromanthe indoor plant", "fittonia indoor plant"], clover: ["oxalis indoor plant", "oxalis triangularis indoor plant"], coin: ["pilea indoor plant", "pilea peperomioides indoor plant", "peperomia indoor plant"], indoor: ["indoor plant", "houseplant on shelf", "potted plant living room", "indoor plant collection"], }; const SEED_OPTIONS = [ { group: "Houseplant categories", items: [ ["monstera", "Monstera"], ["fern", "Ferns"], ["peace", "Peace Lily"], ["snake", "Snake Plant"], ["cactus", "Cactus"], ["succulent", "Succulents"], ["aloevera", "Aloe Vera"], ["palm", "Palms"], ["orchid", "Orchid"], ["hanging", "Hanging / Trailing"], ["bushy", "Bushy / Foliage"], ["anthurium", "Anthurium"], ["bonsai", "Bonsai"], ["banana", "Banana Plant"], ["dracaena", "Dracaena"], ["ficus", "Ficus"], ["prayer", "Prayer Plant"], ["clover", "Clover / Oxalis"], ["coin", "Coin Plant / Pilea"], ]}, { group: "Broad", items: [["indoor", "Indoor plants"], ["all", "All houseplants"]] }, { group: "Other", items: [["custom", "Custom search…"]] }, ]; const PHOTOS_PER_QUERY = 30; const FUNCTIONS_BASE = "https://us-central1-florio-a6f48.cloudfunctions.net"; const UNSPLASH_KEY_STORAGE = "florio_unsplash_access_key"; const AVATAR_COLORS = { monstera: "#2d9a4e", fern: "#5bb87a", peace: "#a8b5c8", snake: "#d4b83e", cactus: "#c0392b", palm: "#2980b9", bushy: "#8e44ad", hanging: "#16a085", orchid: "#c2185b", aloevera: "#7cb342", succulent: "#e67e22", bonsai: "#6d4c41", anthurium: "#e53935", prayer: "#00838f", dracaena: "#546e7a", ficus: "#388e3c", banana: "#f9a825", clover: "#00897b", coin: "#9e9d24", }; // ─── Rive previews (lazy import, cached at module scope) ───────────────────── let RiveLib = null; async function ensureRiveLib() { if (RiveLib) return RiveLib; try { const mod = await import("https://esm.sh/@rive-app/canvas@2.17.3"); const Rive = mod.Rive || (mod.default && mod.default.Rive); const Layout = mod.Layout || (mod.default && mod.default.Layout); const Fit = mod.Fit || (mod.default && mod.default.Fit); const Alignment = mod.Alignment || (mod.default && mod.default.Alignment); RiveLib = { Rive, Layout, Fit, Alignment }; } catch (err) { console.error("[Rive] runtime import FAILED:", err); RiveLib = { Rive: null }; } return RiveLib; } // Mounts a Rive canvas (or a letter placeholder) for one avatar. function RivePreview({ id }) { const slotRef = useRef(null); useEffect(() => { let cancelled = false; let riveInstance = null; const name = DISPLAY_NAMES[id] || id; const placeholder = () => { if (slotRef.current) { slotRef.current.innerHTML = '
' + (name[0] || "?") + "
"; } }; (async () => { const lib = await ensureRiveLib(); if (cancelled || !slotRef.current) return; if (!lib.Rive) { placeholder(); return; } const canvas = document.createElement("canvas"); canvas.width = 160; canvas.height = 160; canvas.style.width = "100%"; canvas.style.height = "100%"; slotRef.current.innerHTML = ""; slotRef.current.appendChild(canvas); const src = "../rive/" + id + "/" + id + "_4.riv"; try { riveInstance = new lib.Rive({ src, canvas, autoplay: true, stateMachines: "State Machine 1", layout: new lib.Layout({ fit: lib.Fit.Contain, alignment: lib.Alignment.Center }), onLoadError: () => placeholder(), }); } catch (err) { console.error("[Rive] constructor threw for", id, err); placeholder(); } })(); return () => { cancelled = true; try { if (riveInstance) riveInstance.cleanup(); } catch (err) { /* noop */ } }; }, [id]); return e("div", { ref: slotRef, style: { width: 64, height: 64, display: "grid", placeItems: "center" } }); } // ─── Unsplash helpers ──────────────────────────────────────────────────────── function getUnsplashKey() { let key = localStorage.getItem(UNSPLASH_KEY_STORAGE); if (!key) { key = prompt( "Unsplash Access Key required.\n\n" + "1. Sign up at https://unsplash.com/developers\n" + "2. Create an app, copy the Access Key (starts with a long alphanumeric string)\n" + "3. Paste it below — it stays in your browser only.", ); if (key) { localStorage.setItem(UNSPLASH_KEY_STORAGE, key.trim()); return key.trim(); } return null; } return key; } async function searchUnsplash(query, perPage, accessKey) { const url = "https://api.unsplash.com/search/photos?query=" + encodeURIComponent(query) + "&per_page=" + perPage + "&orientation=portrait&content_filter=high"; const res = await fetch(url, { headers: { Authorization: "Client-ID " + accessKey, "Accept-Version": "v1" }, }); if (!res.ok) { const body = await res.text(); throw new Error("Unsplash " + res.status + ": " + body.slice(0, 120)); } const json = await res.json(); return (json.results || []).map((p) => ({ id: p.id, photoUrl: p.urls.regular, photographer: (p.user && p.user.name) || null, photographerUrl: (p.user && p.user.links && p.user.links.html) || null, })); } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } // ─── PCA via dual method (N×N gram matrix, since N << 768) ─────────────────── function pcaProject3D(embeddings) { const N = embeddings.length; const D = embeddings[0].length; const mean = new Float64Array(D); for (const emb of embeddings) for (let d = 0; d < D; d++) mean[d] += emb[d]; for (let d = 0; d < D; d++) mean[d] /= N; const centered = embeddings.map((emb) => { const c = new Float64Array(D); for (let d = 0; d < D; d++) c[d] = emb[d] - mean[d]; return c; }); const K = []; for (let i = 0; i < N; i++) K[i] = new Float64Array(N); for (let i = 0; i < N; i++) { for (let j = i; j < N; j++) { let dot = 0; for (let d = 0; d < D; d++) dot += centered[i][d] * centered[j][d]; K[i][j] = dot; K[j][i] = dot; } } const { vectors, values } = topEigenvectors(K, 3); const result = []; for (let i = 0; i < N; i++) { result.push({ x: Math.sqrt(Math.abs(values[0])) * vectors[0][i], y: Math.sqrt(Math.abs(values[1])) * vectors[1][i], z: Math.sqrt(Math.abs(values[2])) * vectors[2][i], }); } return result; } function topEigenvectors(K, count) { const N = K.length; const vectors = []; const values = []; const mat = K.map((row) => Float64Array.from(row)); for (let c = 0; c < count; c++) { let v = new Float64Array(N); for (let i = 0; i < N; i++) v[i] = Math.random() - 0.5; for (let iter = 0; iter < 300; iter++) { const next = new Float64Array(N); for (let i = 0; i < N; i++) { let s = 0; for (let j = 0; j < N; j++) s += mat[i][j] * v[j]; next[i] = s; } let norm = 0; for (let i = 0; i < N; i++) norm += next[i] * next[i]; norm = Math.sqrt(norm); if (norm < 1e-10) break; for (let i = 0; i < N; i++) next[i] /= norm; v = next; } let lambda = 0; for (let i = 0; i < N; i++) { let s = 0; for (let j = 0; j < N; j++) s += mat[i][j] * v[j]; lambda += v[i] * s; } vectors.push(v); values.push(lambda); for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) mat[i][j] -= lambda * v[i] * v[j]; } return { vectors, values }; } // ─── Three.js 3D scatter ────────────────────────────────────────────────────── let threeLib = null; async function loadThree() { if (threeLib) return threeLib; const [THREE, ctrl] = await Promise.all([ import("https://esm.sh/three@0.164.1"), import("https://esm.sh/three@0.164.1/examples/jsm/controls/OrbitControls.js"), ]); threeLib = { THREE, OrbitControls: ctrl.OrbitControls }; return threeLib; } function makeGlowTexture(color, size) { const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); const half = size / 2; const grad = ctx.createRadialGradient(half, half, 0, half, half, half); grad.addColorStop(0, color + "ff"); grad.addColorStop(0.15, color + "cc"); grad.addColorStop(0.4, color + "44"); grad.addColorStop(0.7, color + "11"); grad.addColorStop(1, color + "00"); ctx.fillStyle = grad; ctx.fillRect(0, 0, size, size); const core = ctx.createRadialGradient(half, half, 0, half, half, half * 0.2); core.addColorStop(0, "#ffffff"); core.addColorStop(1, color + "00"); ctx.fillStyle = core; ctx.fillRect(0, 0, size, size); return canvas; } // Imperatively builds the Three.js scene into `container`. Returns a cleanup fn. async function renderScatter3D(container, tooltipEl, points, coords) { const { THREE, OrbitControls } = await loadThree(); const W = container.clientWidth || 760; const H = 560; container.style.height = H + "px"; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity; for (const c of coords) { if (c.x < minX) minX = c.x; if (c.x > maxX) maxX = c.x; if (c.y < minY) minY = c.y; if (c.y > maxY) maxY = c.y; if (c.z < minZ) minZ = c.z; if (c.z > maxZ) maxZ = c.z; } const rX = maxX - minX || 1, rY = maxY - minY || 1, rZ = maxZ - minZ || 1; const SCALE = 4; const norm = coords.map((c) => ({ x: ((c.x - minX) / rX - 0.5) * SCALE, y: ((c.y - minY) / rY - 0.5) * SCALE, z: ((c.z - minZ) / rZ - 0.5) * SCALE, })); const scene = new THREE.Scene(); scene.background = new THREE.Color("#0a0e1a"); scene.fog = new THREE.FogExp2(0x0a0e1a, 0.04); const camera = new THREE.PerspectiveCamera(50, W / H, 0.1, 100); camera.position.set(SCALE * 0.9, SCALE * 0.7, SCALE * 1.1); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setSize(W, H); renderer.setPixelRatio(window.devicePixelRatio); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; container.prepend(renderer.domElement); renderer.domElement.style.borderRadius = "var(--radius-lg, 8px)"; const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.minDistance = 1; controls.maxDistance = 20; controls.target.set(0, 0, 0); controls.autoRotate = true; controls.autoRotateSpeed = 0.4; const boxGeo = new THREE.BoxGeometry(SCALE, SCALE, SCALE); const boxEdges = new THREE.EdgesGeometry(boxGeo); scene.add(new THREE.LineSegments(boxEdges, new THREE.LineBasicMaterial({ color: 0x1a5fff, opacity: 0.18, transparent: true }))); const gridMat = new THREE.LineBasicMaterial({ color: 0x1a5fff, opacity: 0.07, transparent: true }); const half = SCALE / 2; const divisions = 8; const step = SCALE / divisions; for (let i = 1; i < divisions; i++) { const offset = -half + i * step; scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(offset, -half, -half), new THREE.Vector3(offset, half, -half)]), gridMat)); scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(-half, offset, -half), new THREE.Vector3(half, offset, -half)]), gridMat)); scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(offset, -half, -half), new THREE.Vector3(offset, -half, half)]), gridMat)); scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(-half, -half, offset), new THREE.Vector3(half, -half, offset)]), gridMat)); } const axisLen = SCALE * 0.55; const axisColors = [0x3388ff, 0x33aaff, 0x2266dd]; for (let a = 0; a < 3; a++) { const dir = [[1, 0, 0], [0, 1, 0], [0, 0, 1]][a]; const geo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(-dir[0] * axisLen, -dir[1] * axisLen, -dir[2] * axisLen), new THREE.Vector3(dir[0] * axisLen, dir[1] * axisLen, dir[2] * axisLen), ]); scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color: axisColors[a], opacity: 0.3, transparent: true }))); } const glowTextureCache = {}; function getGlowTexture(hex) { if (!glowTextureCache[hex]) { const tex = new THREE.CanvasTexture(makeGlowTexture(hex, 128)); tex.needsUpdate = true; glowTextureCache[hex] = tex; } return glowTextureCache[hex]; } const sprites = []; for (let i = 0; i < norm.length; i++) { const color = AVATAR_COLORS[points[i].avatar] || "#999999"; const mat = new THREE.SpriteMaterial({ map: getGlowTexture(color), transparent: true, opacity: 0.85, depthWrite: false, blending: THREE.AdditiveBlending }); const sprite = new THREE.Sprite(mat); sprite.position.set(norm[i].x, norm[i].y, norm[i].z); sprite.scale.setScalar(0.28); sprite.userData = { index: i, baseScale: 0.28 }; scene.add(sprite); sprites.push(sprite); } const coreGeo = new THREE.SphereGeometry(0.04, 8, 6); const meshes = []; for (let i = 0; i < norm.length; i++) { const color = AVATAR_COLORS[points[i].avatar] || "#999999"; const mesh = new THREE.Mesh(coreGeo, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.0 })); mesh.position.set(norm[i].x, norm[i].y, norm[i].z); mesh.userData = { index: i }; scene.add(mesh); meshes.push(mesh); } const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); let hoveredIdx = -1; const onMove = (ev) => { const rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObjects(meshes); if (hits.length > 0) { const idx = hits[0].object.userData.index; if (idx !== hoveredIdx) { if (hoveredIdx >= 0) sprites[hoveredIdx].scale.setScalar(sprites[hoveredIdx].userData.baseScale); hoveredIdx = idx; sprites[idx].scale.setScalar(sprites[idx].userData.baseScale * 2); const pt = points[idx]; const name = DISPLAY_NAMES[pt.avatar] || pt.avatar; tooltipEl.innerHTML = '' + '
' + name + "" + '
' + pt.species + "
"; tooltipEl.style.display = "flex"; } const tx = ev.clientX - rect.left + 14; const ty = ev.clientY - rect.top - 16; tooltipEl.style.left = Math.min(tx, rect.width - 250) + "px"; tooltipEl.style.top = Math.max(ty, 8) + "px"; renderer.domElement.style.cursor = "pointer"; } else { if (hoveredIdx >= 0) { sprites[hoveredIdx].scale.setScalar(sprites[hoveredIdx].userData.baseScale); hoveredIdx = -1; } tooltipEl.style.display = "none"; renderer.domElement.style.cursor = "grab"; } }; const onLeave = () => { if (hoveredIdx >= 0) { sprites[hoveredIdx].scale.setScalar(sprites[hoveredIdx].userData.baseScale); hoveredIdx = -1; } tooltipEl.style.display = "none"; }; renderer.domElement.addEventListener("mousemove", onMove); renderer.domElement.addEventListener("mouseleave", onLeave); let animId; const clock = new THREE.Clock(); function animate() { animId = requestAnimationFrame(animate); controls.update(); const t = clock.getElapsedTime(); for (let i = 0; i < sprites.length; i++) { if (i === hoveredIdx) continue; const pulse = 1 + Math.sin(t * 1.5 + i * 0.7) * 0.06; sprites[i].scale.setScalar(sprites[i].userData.baseScale * pulse); } renderer.render(scene, camera); } animate(); // Returns the legend filter applier + cleanup, so the React legend can drive it. return { sprites, meshes, points, cleanup: () => { cancelAnimationFrame(animId); renderer.domElement.removeEventListener("mousemove", onMove); renderer.domElement.removeEventListener("mouseleave", onLeave); try { renderer.dispose(); } catch (err) { /* noop */ } try { controls.dispose(); } catch (err) { /* noop */ } }, }; } // ─── Shared style helpers (match the other ported tabs) ────────────────────── const cardStyle = { background: "var(--surface-card)", border: "1px solid var(--border-subtle)", borderRadius: "var(--radius-2xl)", boxShadow: "var(--shadow-card)", padding: 22, }; const secondaryBtn = (extra) => Object.assign({ padding: "8px 14px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-default)", background: "var(--surface-card)", color: "var(--text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)", fontWeight: 600, fontSize: 13, whiteSpace: "nowrap", }, extra || {}); function countTier(n) { return n < 20 ? "low" : n < 50 ? "ok" : "good"; } const TIER_COLOR = { low: "#E0584F", ok: "#E0A23F", good: "#3FA45B" }; // ─── Main component ────────────────────────────────────────────────────────── function Labeling() { const PA = window.PA; const ChartCard = PA.ChartCard; const [ready, setReady] = useState(false); // Source tab: "unsplash" (default queue) or "user_scan" (user_scan items). const [activeSource, setActiveSource] = useState("unsplash"); const activeSourceRef = useRef("unsplash"); activeSourceRef.current = activeSource; const [currentItem, setCurrentItem] = useState(null); const currentItemRef = useRef(null); currentItemRef.current = currentItem; const [photoError, setPhotoError] = useState(false); const [photoLoading, setPhotoLoading] = useState(true); const [counts, setCounts] = useState({ labeledCount: 0, queueTotal: 0, userScanLabeledCount: 0, userScanQueueTotal: 0, pendingUserScans: 0, perAvatar: {}, }); const countsRef = useRef(counts); countsRef.current = counts; const [progressMeta, setProgressMeta] = useState("Loading queue…"); const [flashId, setFlashId] = useState(null); // Seed UI const [seedOpen, setSeedOpen] = useState(false); const [customQuery, setCustomQuery] = useState(""); const [seedBusy, setSeedBusy] = useState(false); // Button transient labels const [purgeLabel, setPurgeLabel] = useState("Purge non-Unsplash"); const [purgeBusy, setPurgeBusy] = useState(false); const [embedOneLabel, setEmbedOneLabel] = useState("Test embed 1"); const [embedAllLabel, setEmbedAllLabel] = useState("Re-embed all"); const [embedBusy, setEmbedBusy] = useState(false); const uploadRef = useRef(null); // ── Firestore helpers (lazy — window.FlorioDB available after FlorioReady) ── const FB = () => window.FlorioDB; // ── refreshProgress ── const refreshProgress = useCallback(async () => { const { db, collection, getDocs } = FB(); try { const [labelsSnap, queueSnap] = await Promise.all([ getDocs(collection(db, "avatar_labels")), getDocs(collection(db, "labeling_queue")), ]); let labeledCount = 0, userScanLabeledCount = 0; const perAvatar = {}; labelsSnap.forEach((d) => { const data = d.data(); if (data.skipped || data.notHouseplant) return; if (data.source === "user_scan" || data.source === "watering_photo") userScanLabeledCount += 1; else labeledCount += 1; if (data.chosenAvatar) perAvatar[data.chosenAvatar] = (perAvatar[data.chosenAvatar] || 0) + 1; }); let pendingQueue = 0, pendingUserScans = 0; queueSnap.forEach((d) => { const data = d.data(); if (data.status !== "pending") return; if (data.userScan === true) pendingUserScans += 1; else pendingQueue += 1; }); setCounts({ labeledCount, userScanLabeledCount, queueTotal: labeledCount + pendingQueue, userScanQueueTotal: userScanLabeledCount + pendingUserScans, pendingUserScans, perAvatar, }); } catch (err) { console.error("[Labeling] refreshProgress failed:", err); setProgressMeta("Failed: " + err.message); } }, []); // ── loadNext ── const loadNext = useCallback(async () => { const { db, collection, query, where, limit, getDocs } = FB(); const isUserScan = activeSourceRef.current === "user_scan"; setPhotoError(false); setPhotoLoading(true); try { const filters = [where("status", "==", "pending")]; if (isUserScan) filters.push(where("userScan", "==", true)); const q = query(collection(db, "labeling_queue"), ...filters, limit(1)); const snap = await getDocs(q); const match = snap.docs[0] || null; if (!match) { setCurrentItem(null); setPhotoLoading(false); return; } setCurrentItem(Object.assign({ id: match.id }, match.data(), { _source: activeSourceRef.current })); } catch (err) { console.error("[Labeling] loadNext failed:", err); setCurrentItem({ _error: err.message }); setPhotoLoading(false); } }, []); // ── submitLabel ── const submitLabel = useCallback(async (avatarId) => { const item = currentItemRef.current; if (!item || !item.id) return; const { db, collection, doc, writeBatch, serverTimestamp } = FB(); const user = window.FlorioDB.auth.currentUser; if (!user) { console.warn("[Labeling] no authed user"); return; } try { const batch = writeBatch(db); const labelRef = doc(collection(db, "avatar_labels")); const photoUrl = item.photoDataUrl || item.photoUrl; batch.set(labelRef, { labelId: labelRef.id, photoUrl, chosenAvatar: avatarId, labelerId: user.uid, species: item.species || null, source: item.source || "manual", sourceQueueItemId: item.id, skipped: false, notHouseplant: false, embedding: null, createdAt: serverTimestamp(), }); const queueRef = doc(db, "labeling_queue", item.id); batch.set(queueRef, { status: "labeled", labeledAt: serverTimestamp() }, { merge: true }); await batch.commit(); const isUserScan = activeSourceRef.current === "user_scan"; const prev = countsRef.current; setCounts(Object.assign({}, prev, { labeledCount: prev.labeledCount + (isUserScan ? 0 : 1), userScanLabeledCount: prev.userScanLabeledCount + (isUserScan ? 1 : 0), perAvatar: Object.assign({}, prev.perAvatar, { [avatarId]: (prev.perAvatar[avatarId] || 0) + 1 }), })); setFlashId(avatarId); setTimeout(() => setFlashId((f) => (f === avatarId ? null : f)), 350); await loadNext(); } catch (err) { console.error("[Labeling] submitLabel failed:", err); alert("Save failed: " + err.message); } }, [loadNext]); // ── skipItem ── const skipItem = useCallback(async (reason) => { const item = currentItemRef.current; if (!item || !item.id) return; const { db, doc, setDoc, serverTimestamp } = FB(); try { const status = reason === "not_houseplant" ? "rejected" : "no_match"; const queueRef = doc(db, "labeling_queue", item.id); await setDoc(queueRef, { status, resolvedAt: serverTimestamp() }, { merge: true }); await loadNext(); } catch (err) { console.error("[Labeling] skip failed:", err); } }, [loadNext]); // ── handleUpload ── const handleUpload = useCallback(async (ev) => { const files = Array.from(ev.target.files || []); if (!files.length) return; const { db, collection, doc, setDoc, serverTimestamp } = FB(); const origMeta = progressMeta; setProgressMeta("Uploading " + files.length + " photo" + (files.length > 1 ? "s" : "") + "…"); let added = 0; for (const file of files) { try { const dataUrl = await readFileAsDataUrl(file); const ref = doc(collection(db, "labeling_queue")); await setDoc(ref, { queueId: ref.id, photoDataUrl: dataUrl, species: file.name.replace(/\.[^.]+$/, "").replace(/[-_]/g, " "), source: "manual_upload", status: "pending", createdAt: serverTimestamp(), }); added += 1; setProgressMeta("Uploading… " + added + "/" + files.length); } catch (err) { console.error("[Labeling] upload failed for", file.name, err); } } setProgressMeta(added > 0 ? "Uploaded " + added + " photo" + (added > 1 ? "s" : "") : "Upload failed"); ev.target.value = ""; await refreshProgress(); if (!currentItemRef.current || !currentItemRef.current.id) await loadNext(); setTimeout(() => setProgressMeta(origMeta), 5000); }, [progressMeta, refreshProgress, loadNext]); // ── runSeed (Unsplash) ── const runSeed = useCallback(async (category, customQueries) => { const key = getUnsplashKey(); if (!key) return; setSeedBusy(true); const { db, collection, doc, setDoc, getDocs, serverTimestamp } = FB(); const queries = customQueries || (category === "all" ? Object.values(SEED_CATEGORY_QUERIES).flat() : SEED_CATEGORY_QUERIES[category] || [category + " houseplant indoor"]); const origMeta = progressMeta; setProgressMeta("Seeding…"); try { const existingIds = new Set(); const [queueSnap, labelsSnap] = await Promise.all([ getDocs(collection(db, "labeling_queue")), getDocs(collection(db, "avatar_labels")), ]); queueSnap.forEach((d) => { const id = d.data().unsplashId; if (id) existingIds.add(id); }); labelsSnap.forEach((d) => { const id = d.data().unsplashId; if (id) existingIds.add(id); }); let added = 0, duplicates = 0, queryFailed = 0, rateLimited = false; for (let qi = 0; qi < queries.length; qi++) { const q = queries[qi]; let photos; try { photos = await searchUnsplash(q, PHOTOS_PER_QUERY, key); } catch (err) { queryFailed += 1; if (String(err.message).includes("429") || String(err.message).includes("Rate Limit")) { rateLimited = true; break; } console.warn('[Labeling] search failed for "' + q + '":', err); continue; } for (const photo of photos) { if (existingIds.has(photo.id)) { duplicates += 1; continue; } try { const ref = doc(collection(db, "labeling_queue")); await setDoc(ref, { queueId: ref.id, photoUrl: photo.photoUrl, species: q, source: "unsplash", unsplashId: photo.id, photographer: photo.photographer, photographerUrl: photo.photographerUrl, status: "pending", createdAt: serverTimestamp(), }); existingIds.add(photo.id); added += 1; } catch (err) { console.warn("[Labeling] write failed for", photo.photoUrl, err); } } setProgressMeta("Seeding… " + added + " added (" + (qi + 1) + "/" + queries.length + ")"); } let result; if (added > 0) { result = "Seeded " + added + " photos"; if (duplicates) result += " · " + duplicates + " duplicates skipped"; if (rateLimited) result += " · rate-limited"; } else if (rateLimited) result = "Rate limited — try again in an hour"; else if (duplicates > 0) result = "All " + duplicates + " photos already exist"; else result = "No photos returned (" + queryFailed + " failed)"; setProgressMeta(result); await refreshProgress(); if (!currentItemRef.current || !currentItemRef.current.id) await loadNext(); } catch (err) { console.error("[Labeling] seed run failed:", err); if (String(err.message).includes("401") || String(err.message).includes("Unauthorized")) { localStorage.removeItem(UNSPLASH_KEY_STORAGE); setProgressMeta("Bad Unsplash key — try seeding again"); } else { setProgressMeta("Seed failed"); } } finally { setSeedBusy(false); setTimeout(() => setProgressMeta(origMeta), 8000); } }, [progressMeta, refreshProgress, loadNext]); const onSeedSelect = useCallback((val) => { setSeedOpen(false); if (val === "custom") { setSeedOpen("custom"); return; } if (val) runSeed(val); }, [runSeed]); const submitCustomSeed = useCallback(() => { const q = customQuery.trim(); if (!q) return; runSeed("custom", [q + " indoor plant"]); setCustomQuery(""); setSeedOpen(false); }, [customQuery, runSeed]); // ── purgeNonUnsplash ── const purgeNonUnsplash = useCallback(async () => { const { db, collection, query, where, getDocs, writeBatch } = FB(); setPurgeBusy(true); setPurgeLabel("Scanning…"); let snap; try { snap = await getDocs(query(collection(db, "labeling_queue"), where("status", "==", "pending"))); } catch (err) { console.error("[Labeling] purge scan failed:", err); setPurgeLabel("Scan failed"); setTimeout(() => { setPurgeBusy(false); setPurgeLabel("Purge non-Unsplash"); }, 4000); return; } const toDelete = []; snap.forEach((d) => { if (d.data().source !== "unsplash") toDelete.push(d.ref); }); if (toDelete.length === 0) { setPurgeLabel("Nothing to purge"); setTimeout(() => { setPurgeBusy(false); setPurgeLabel("Purge non-Unsplash"); }, 4000); return; } if (!confirm("Delete " + toDelete.length + " non-Unsplash pending item(s)?")) { setPurgeBusy(false); setPurgeLabel("Purge non-Unsplash"); return; } setPurgeLabel("Deleting " + toDelete.length + "…"); let deleted = 0; for (let i = 0; i < toDelete.length; i += 500) { const batch = writeBatch(db); toDelete.slice(i, i + 500).forEach((ref) => batch.delete(ref)); await batch.commit(); deleted += Math.min(500, toDelete.length - i); setPurgeLabel("Deleted " + deleted + " / " + toDelete.length + "…"); } setPurgeLabel("Purged " + deleted + " items"); await refreshProgress(); if (!currentItemRef.current || !currentItemRef.current.id) await loadNext(); setTimeout(() => { setPurgeBusy(false); setPurgeLabel("Purge non-Unsplash"); }, 5000); }, [refreshProgress, loadNext]); // ── runReEmbed ── const runReEmbed = useCallback(async (mode) => { const isOne = mode === "one"; const baseLabel = isOne ? "Test embed 1" : "Re-embed all"; const setLabel = isOne ? setEmbedOneLabel : setEmbedAllLabel; const user = window.FlorioDB.auth.currentUser; if (!user) { alert("Not signed in"); return; } if (!isOne && !confirm("Re-embed all labeled photos via CLIP? Skips Wikipedia URLs. May take a few minutes.")) return; setEmbedBusy(true); try { const token = await user.getIdToken(); if (isOne) { setLabel("Embedding 1…"); const res = await fetch(FUNCTIONS_BASE + "/reEmbedLabels?mode=one", { headers: { Authorization: "Bearer " + token } }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "HTTP " + res.status); setLabel(data.ok + " ok, " + data.failed + " fail, " + data.skipped + " skip"); if (data.ok > 0) refreshClusterRef.current && refreshClusterRef.current(); } else { let totalOk = 0, totalFail = 0, totalSkip = 0; while (true) { setLabel("Re-embedding… " + totalOk + " done"); const res = await fetch(FUNCTIONS_BASE + "/reEmbedLabels?mode=one", { headers: { Authorization: "Bearer " + token } }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "HTTP " + res.status); totalOk += data.ok; totalFail += data.failed; totalSkip += data.skipped; if (data.ok === 0 && data.failed === 0) break; } setLabel("Done: " + totalOk + " ok, " + totalFail + " fail, " + totalSkip + " skip"); if (totalOk > 0) refreshClusterRef.current && refreshClusterRef.current(); } } catch (err) { console.error("[Labeling] re-embed failed:", err); setLabel("Failed: " + err.message); } finally { setTimeout(() => { setEmbedBusy(false); setLabel(baseLabel); }, 8000); } }, []); // ── Cluster viz ── const clusterCanvasRef = useRef(null); const clusterTooltipRef = useRef(null); const scene3DRef = useRef(null); const refreshClusterRef = useRef(null); const [clusterMeta, setClusterMeta] = useState(""); const [clusterMsg, setClusterMsg] = useState("Loading embeddings…"); const [clusterBusy, setClusterBusy] = useState(false); const [clusterLegend, setClusterLegend] = useState([]); // [{id,color,name}] const [clusterFilter, setClusterFilter] = useState(null); const renderClusterViz = useCallback(async () => { const { db, collection, getDocs } = FB(); setClusterBusy(true); // tear down any previous scene if (scene3DRef.current) { try { scene3DRef.current.cleanup(); } catch (err) { /* noop */ } scene3DRef.current = null; } if (clusterCanvasRef.current) { // remove any leftover the renderer prepended const old = clusterCanvasRef.current.querySelector("canvas"); if (old) old.remove(); } setClusterLegend([]); setClusterFilter(null); try { const snap = await getDocs(collection(db, "avatar_labels")); const withEmbedding = []; let pendingCount = 0; snap.forEach((d) => { const data = d.data(); if (data.skipped || data.notHouseplant) return; if (data.embeddingVersion !== "v2") { pendingCount++; return; } const emb = data.embedding; if (Array.isArray(emb) && emb.length > 2) { withEmbedding.push({ embedding: emb, avatar: data.chosenAvatar, species: data.species || "unknown", photoUrl: data.photoUrl }); } else { pendingCount++; } }); if (withEmbedding.length < 3) { setClusterMeta(""); setClusterMsg("Need at least 3 labels with embeddings. " + withEmbedding.length + " have embeddings" + (pendingCount > 0 ? ", " + pendingCount + " awaiting embedding" : "") + ". Make sure the Replicate API token is configured and the onLabelCreated function is deployed."); return; } setClusterMsg(null); setClusterMeta(withEmbedding.length + " plotted" + (pendingCount > 0 ? " · " + pendingCount + " pending" : "")); const coords = pcaProject3D(withEmbedding.map((p) => p.embedding)); // build the scene after the message clears (next tick so the canvas div exists) await new Promise((r) => setTimeout(r, 0)); if (!clusterCanvasRef.current) return; const handle = await renderScatter3D(clusterCanvasRef.current, clusterTooltipRef.current, withEmbedding, coords); scene3DRef.current = handle; const seen = new Set(withEmbedding.map((p) => p.avatar)); setClusterLegend(AVATAR_ORDER.filter((id) => seen.has(id)).map((id) => ({ id, color: AVATAR_COLORS[id] || "#999", name: DISPLAY_NAMES[id] || id }))); } catch (err) { console.error("[Cluster] render failed:", err); setClusterMsg("Failed to load: " + err.message); } finally { setClusterBusy(false); } }, []); refreshClusterRef.current = renderClusterViz; // Apply legend click-to-filter to the live Three.js sprites/meshes. const applyClusterFilter = useCallback((avatarId) => { setClusterFilter((cur) => { const next = cur === avatarId ? null : avatarId; const handle = scene3DRef.current; if (handle) { for (let i = 0; i < handle.sprites.length; i++) { const match = !next || handle.points[i].avatar === next; handle.sprites[i].visible = match; handle.meshes[i].visible = match; } } return next; }); }, []); // ── Boot: wait for FlorioReady, then load everything ── useEffect(() => { let alive = true; (async () => { await window.FlorioReady; if (!alive) return; setReady(true); await refreshProgress(); await loadNext(); renderClusterViz(); })(); return () => { alive = false; if (scene3DRef.current) { try { scene3DRef.current.cleanup(); } catch (err) { /* noop */ } } }; }, []); // eslint-disable-line // Reload the queue whenever the source tab changes (after initial boot). useEffect(() => { if (!ready) return; loadNext(); }, [activeSource]); // eslint-disable-line // ─── Derived progress display ─────────────────────────────────────────────── const isUserScan = activeSource === "user_scan"; let progressCount, progressFillPct, derivedMeta; if (isUserScan) { progressCount = counts.userScanLabeledCount + " / " + counts.userScanQueueTotal + " labeled"; progressFillPct = counts.userScanQueueTotal > 0 ? Math.min(100, (counts.userScanLabeledCount / counts.userScanQueueTotal) * 100) : 0; const pending = counts.userScanQueueTotal - counts.userScanLabeledCount; derivedMeta = pending > 0 ? pending + " user scans waiting" : "All user scans labeled"; } else { const targetForBeta = 1000; progressCount = counts.labeledCount + " / " + counts.queueTotal + " labeled"; progressFillPct = Math.min(100, (counts.labeledCount / targetForBeta) * 100); if (counts.labeledCount >= targetForBeta) derivedMeta = "Beta target reached"; else if (counts.labeledCount >= 500) derivedMeta = "Usable quality · " + (targetForBeta - counts.labeledCount) + " more to beta"; else if (counts.labeledCount >= 200) derivedMeta = "k-NN starts working · target " + targetForBeta; else derivedMeta = "Warming up · first ML threshold at 200"; } // progressMeta holds transient messages (uploading/seeding); otherwise show the derived status. const showMeta = (progressMeta && progressMeta !== "Loading queue…") ? progressMeta : derivedMeta; const maxPerAvatar = Math.max(1, ...Object.values(counts.perAvatar)); // ─── Photo panel content ────────────────────────────────────────────────── const imgSrc = currentItem && (currentItem.photoDataUrl || currentItem.photoUrl); let sourceLine = null; if (currentItem && currentItem.id) { if (currentItem.source === "unsplash" && currentItem.photographer) { sourceLine = e("span", null, e("span", null, currentItem.species || "unknown"), " · photo by ", currentItem.photographerUrl ? e("a", { href: currentItem.photographerUrl + "?utm_source=florio_labeling&utm_medium=referral", target: "_blank", rel: "noopener", style: { color: "var(--app-accent)" } }, currentItem.photographer) : currentItem.photographer, " on ", e("a", { href: "https://unsplash.com/?utm_source=florio_labeling&utm_medium=referral", target: "_blank", rel: "noopener", style: { color: "var(--app-accent)" } }, "Unsplash")); } else if (currentItem.source === "user_scan" || currentItem.source === "watering_photo") { const label = currentItem.source === "watering_photo" ? "watering photo" : "user scan"; sourceLine = e("span", null, e("span", null, currentItem.species || "unknown"), " · " + label, currentItem.characterTypeId ? e("span", null, " · AI picked: ", e("strong", null, DISPLAY_NAMES[currentItem.characterTypeId] || currentItem.characterTypeId)) : null); } else { sourceLine = (currentItem.species || "unknown") + " · source: " + (currentItem.source || "manual"); } } const hasItem = !!(currentItem && currentItem.id); function EmptyPhoto({ title, hint }) { return e("div", { style: { minHeight: 360, display: "grid", placeItems: "center", textAlign: "center", padding: 24, color: "var(--text-muted)" } }, e("div", null, e("p", { style: { fontFamily: "var(--font-sans)", fontSize: 15, fontWeight: 600, color: "var(--text-secondary)", margin: 0 } }, title), hint ? e("p", { style: { fontFamily: "var(--font-sans)", fontSize: 12, opacity: 0.7, marginTop: 6 } }, hint) : null)); } let photoContent; if (currentItem && currentItem._error) { photoContent = e(EmptyPhoto, { title: "Failed to load queue.", hint: currentItem._error }); } else if (!hasItem) { photoContent = e(EmptyPhoto, { title: "Queue empty.", hint: isUserScan ? "No user scan photos yet. They appear here when users scan a plant in the app." : "All pending photos have been labeled. Seed more to continue.", }); } else if (photoError) { photoContent = e(EmptyPhoto, { title: "Image failed to load.", hint: "Skip this one." }); } else { photoContent = e("img", { src: imgSrc, alt: currentItem.species || "plant", onLoad: () => setPhotoLoading(false), onError: () => { setPhotoError(true); setPhotoLoading(false); }, style: { width: "100%", maxHeight: 460, objectFit: "contain", borderRadius: "var(--radius-lg)", display: "block", background: "var(--surface-sunken)" }, }); } // ─── Render ────────────────────────────────────────────────────────────── const eyebrow = (t) => e("div", { style: { fontFamily: "var(--font-mono-display)", fontSize: 10, letterSpacing: ".14em", textTransform: "uppercase", color: "var(--text-muted)", marginBottom: 6 } }, t); return e("div", { style: { display: "flex", flexDirection: "column", gap: 22 } }, // ── Header actions row ── e("div", { style: { display: "flex", flexWrap: "wrap", gap: 10, alignItems: "center", justifyContent: "flex-end" } }, e("div", { style: { marginRight: "auto" } }, eyebrow("Avatar k-NN training data"), e("div", { style: { fontFamily: "var(--font-sans)", fontSize: 13, color: "var(--text-muted)", maxWidth: 520 } }, "Label photos with the closest-matching avatar to build the training set."), ), e("a", { href: "../gaps.html", style: secondaryBtn({ textDecoration: "none", display: "inline-flex", alignItems: "center" }) }, "View avatar gaps"), // Seed group (Unsplash only) !isUserScan && e("div", { style: { position: "relative" } }, e("button", { onClick: () => setSeedOpen((o) => (o ? false : "menu")), disabled: seedBusy, style: secondaryBtn({ opacity: seedBusy ? 0.6 : 1 }) }, seedBusy ? "Seeding…" : "Seed plants…"), seedOpen === "menu" && e("div", { style: { position: "absolute", top: 42, right: 0, zIndex: 50, background: "var(--surface-card)", border: "1px solid var(--border-subtle)", borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-card)", padding: 8, width: 240, maxHeight: 400, overflowY: "auto" } }, SEED_OPTIONS.map((grp) => e("div", { key: grp.group }, e("div", { style: { fontFamily: "var(--font-mono-display)", fontSize: 9, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--text-muted)", padding: "8px 8px 4px" } }, grp.group), grp.items.map(([val, lab]) => e("button", { key: val, onClick: () => onSeedSelect(val), style: { display: "block", width: "100%", textAlign: "left", padding: "7px 10px", borderRadius: "var(--radius-md)", border: "none", background: "transparent", cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 13, color: "var(--text-primary)" }, onMouseEnter: (ev) => { ev.target.style.background = "var(--surface-sunken)"; }, onMouseLeave: (ev) => { ev.target.style.background = "transparent"; }, }, lab)), )), ), seedOpen === "custom" && e("div", { style: { position: "absolute", top: 42, right: 0, zIndex: 50, background: "var(--surface-card)", border: "1px solid var(--border-subtle)", borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-card)", padding: 12, width: 280, display: "flex", gap: 8 } }, e("input", { type: "text", value: customQuery, autoFocus: true, placeholder: "Custom search…", onChange: (ev) => setCustomQuery(ev.target.value), onKeyDown: (ev) => { if (ev.key === "Enter") submitCustomSeed(); }, style: { flex: 1, height: 38, borderRadius: "var(--radius-md)", border: "1px solid var(--border-default)", padding: "0 10px", fontFamily: "var(--font-sans)", fontSize: 13, color: "var(--text-primary)", background: "var(--surface-card)", outline: "none" }, }), e("button", { onClick: submitCustomSeed, style: secondaryBtn({}) }, "Seed"), ), ), e("label", { htmlFor: "labeling-upload-input", style: secondaryBtn({ display: "inline-flex", alignItems: "center" }) }, "Upload photos"), e("input", { id: "labeling-upload-input", ref: uploadRef, type: "file", accept: "image/*", multiple: true, hidden: true, onChange: handleUpload }), !isUserScan && e("button", { onClick: purgeNonUnsplash, disabled: purgeBusy, style: secondaryBtn({ borderColor: "var(--negative, #E0584F)", color: "var(--negative, #E0584F)", opacity: purgeBusy ? 0.6 : 1 }) }, purgeLabel), e("button", { onClick: () => runReEmbed("one"), disabled: embedBusy, style: secondaryBtn({ opacity: embedBusy ? 0.6 : 1 }) }, embedOneLabel), e("button", { onClick: () => runReEmbed("all"), disabled: embedBusy, style: secondaryBtn({ opacity: embedBusy ? 0.6 : 1 }) }, embedAllLabel), ), // ── Source tabs ── e("div", { style: { display: "inline-flex", background: "var(--surface-sunken)", borderRadius: 999, padding: 3, gap: 2, alignSelf: "flex-start" } }, [["unsplash", "Unsplash photos", null], ["user_scan", "User scans", counts.pendingUserScans]].map(([src, lab, badge]) => e("button", { key: src, onClick: () => setActiveSource(src), style: { border: "none", cursor: "pointer", padding: "7px 15px", borderRadius: 999, display: "inline-flex", alignItems: "center", gap: 7, fontFamily: "var(--font-ui-alt, var(--font-sans))", fontWeight: 600, fontSize: 12.5, background: activeSource === src ? "var(--surface-card)" : "transparent", color: activeSource === src ? "var(--text-primary)" : "var(--text-muted)", boxShadow: activeSource === src ? "var(--shadow-sm)" : "none", }, }, lab, badge != null ? e("span", { style: { background: "var(--app-accent)", color: "#fff", borderRadius: 999, fontSize: 10, fontWeight: 700, padding: "1px 7px", fontFamily: "var(--font-mono)" } }, badge) : null)), ), // ── Progress strip ── e("div", { style: Object.assign({}, cardStyle, { padding: 18, display: "flex", flexDirection: "column", gap: 12 }) }, e("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, flexWrap: "wrap" } }, e("span", { style: { fontFamily: "var(--font-sans)", fontWeight: 700, fontSize: 16, color: "var(--text-primary)" } }, progressCount), e("span", { style: { fontFamily: "var(--font-sans)", fontSize: 12.5, color: "var(--text-muted)" } }, showMeta), ), e("div", { style: { height: 10, background: "var(--surface-sunken)", borderRadius: 99, overflow: "hidden" } }, e("div", { style: { width: progressFillPct + "%", height: "100%", borderRadius: 99, background: "var(--app-accent)", transition: "width var(--dur-fast, .2s)" } }), ), // Per-avatar balance bars e("div", { style: { display: "flex", alignItems: "flex-end", gap: 4, height: 70, paddingTop: 6 } }, AVATAR_ORDER.map((id) => { const n = counts.perAvatar[id] || 0; const pct = (n / maxPerAvatar) * 100; const tier = countTier(n); const name = DISPLAY_NAMES[id] || id; return e("div", { key: id, title: name + ": " + n, style: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, minWidth: 0 } }, e("div", { style: { width: "100%", height: 40, display: "flex", alignItems: "flex-end" } }, e("div", { style: { width: "100%", height: Math.max(6, pct) + "%", borderRadius: 4, background: TIER_COLOR[tier] } })), e("span", { style: { fontFamily: "var(--font-mono)", fontSize: 8.5, color: "var(--text-muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: "100%" } }, name.split(" ")[0])); }), ), ), // ── Main labeling area ── e("div", { style: { display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1.2fr)", gap: 22, alignItems: "start" } }, // Left: current photo e("div", { style: Object.assign({}, cardStyle, { display: "flex", flexDirection: "column", gap: 14 }) }, e("div", null, photoContent), e("div", { style: { display: "flex", gap: 10, flexWrap: "wrap" } }, e("button", { onClick: () => skipItem("no_match"), disabled: !hasItem, style: secondaryBtn({ opacity: hasItem ? 1 : 0.5, cursor: hasItem ? "pointer" : "default" }) }, "No close matching avatars"), e("button", { onClick: () => skipItem("not_houseplant"), disabled: !hasItem, style: secondaryBtn({ opacity: hasItem ? 1 : 0.5, cursor: hasItem ? "pointer" : "default" }) }, "Not a houseplant"), ), sourceLine ? e("p", { style: { fontFamily: "var(--font-sans)", fontSize: 12.5, color: "var(--text-muted)", margin: 0 } }, sourceLine) : null, ), // Right: avatar pick grid e("div", { style: cardStyle }, e("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 16, gap: 10, flexWrap: "wrap" } }, e("span", { style: { fontFamily: "var(--font-sans)", fontWeight: 700, fontSize: 17, color: "var(--text-primary)" } }, "Pick the closest-matching avatar"), e("span", { style: { fontFamily: "var(--font-sans)", fontSize: 12, color: "var(--text-muted)" } }, "Click a card to label the photo"), ), e("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))", gap: 10 } }, AVATAR_ORDER.map((id) => { const name = DISPLAY_NAMES[id] || id; const n = counts.perAvatar[id] || 0; const tier = countTier(n); const flashing = flashId === id; return e("button", { key: id, onClick: () => submitLabel(id), style: { position: "relative", display: "flex", flexDirection: "column", alignItems: "center", gap: 6, padding: "12px 8px 10px", borderRadius: "var(--radius-xl)", border: "1px solid " + (flashing ? "var(--app-accent)" : "var(--border-subtle)"), background: flashing ? "color-mix(in srgb, var(--app-accent) 14%, var(--surface-card))" : "var(--surface-card)", cursor: hasItem ? "pointer" : "pointer", transition: "border-color var(--dur-fast,.2s), background var(--dur-fast,.2s), transform var(--dur-fast,.2s)", transform: flashing ? "scale(0.96)" : "scale(1)", }, onMouseEnter: (ev) => { if (!flashing) ev.currentTarget.style.borderColor = "var(--border-default)"; ev.currentTarget.style.background = "var(--surface-sunken)"; }, onMouseLeave: (ev) => { if (!flashing) { ev.currentTarget.style.borderColor = "var(--border-subtle)"; ev.currentTarget.style.background = "var(--surface-card)"; } }, }, e(RivePreview, { id }), e("span", { style: { fontFamily: "var(--font-sans)", fontSize: 12, fontWeight: 600, color: "var(--text-secondary)", textAlign: "center", lineHeight: 1.1 } }, name), e("span", { style: { position: "absolute", top: 8, right: 8, minWidth: 18, textAlign: "center", padding: "1px 5px", borderRadius: 99, fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 700, color: "#fff", background: TIER_COLOR[tier] } }, n), ); }), ), ), ), // ── Cluster viz ── e(ChartCard, { title: "Label clusters (embedding space)", eyebrow: "PCA of CLIP embeddings", action: e("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, e("span", { style: { fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--text-muted)" } }, clusterMeta), e("button", { onClick: renderClusterViz, disabled: clusterBusy, style: secondaryBtn({ padding: "5px 12px", fontSize: 12, opacity: clusterBusy ? 0.6 : 1 }) }, clusterBusy ? "Loading…" : "Refresh"), ), }, clusterMsg ? e("div", { style: { minHeight: 180, display: "grid", placeItems: "center", textAlign: "center", padding: 22 } }, e("p", { style: { fontFamily: "var(--font-sans)", fontSize: 13, color: "var(--text-muted)", maxWidth: 520, margin: 0 } }, clusterMsg)) : e("div", { style: { display: "flex", gap: 16, flexWrap: "wrap" } }, e("div", { ref: clusterCanvasRef, style: { flex: 1, minWidth: 320, position: "relative", borderRadius: "var(--radius-lg)", overflow: "hidden", background: "#0a0e1a" } }, e("div", { ref: clusterTooltipRef, style: { display: "none", position: "absolute", zIndex: 10, pointerEvents: "none", gap: 8, alignItems: "center", background: "rgba(10,14,26,0.92)", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 8, padding: 8, boxShadow: "0 6px 18px rgba(0,0,0,0.4)" } })), e("div", { style: { width: 180, display: "flex", flexDirection: "column", gap: 8, paddingTop: 4 } }, clusterLegend.map((it) => e("span", { key: it.id, onClick: () => applyClusterFilter(it.id), style: { display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 12.5, color: "var(--text-secondary)", opacity: !clusterFilter || clusterFilter === it.id ? 1 : 0.25, transition: "opacity var(--dur-fast,.2s)", }, }, e("span", { style: { width: 10, height: 10, borderRadius: 3, background: it.color, boxShadow: "0 0 6px " + it.color, flexShrink: 0 } }), it.name)), ), ), ), ); } // Register under the Florio-owned namespace (see Shell.jsx NAV_TOOLS → navId 'labeling'). (window.FlorioTabs = window.FlorioTabs || {}).labeling = Labeling; })();