// 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