import React, {
useMemo,
useState,
useEffect,
Suspense,
} from "react";
import { Canvas } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import * as THREE from "three";
import { Leva } from "leva";
// --- REAL DATA IMPORT ---
import shoes from "../../../backend/shoes.json";
import MiniMap from "../MiniMap";
import { DEFAULT_CONFIG, CONFIG } from "./gridConfig";
import { rigState, calculateGridDimensions, EMPTY_COLORS, matchesFilter } from "./gridState";
import { useGridConfig } from "./useGridConfig";
import { Rig } from "./Rig";
import { GridCanvas } from "./GridCanvas";
import { UnifiedControlBar } from "../GridUI";
import Header from "../Header";
import { TopologyBackground } from "../TopologyBackground";
import "../HoloCardMaterial"; // Registers with R3F
// --- PRELOAD ALL TEXTURES ---
// This ensures all shoe images are cached before switching collections
shoes.forEach((shoe) => {
useTexture.preload(shoe.image_url);
});
// --- MAIN EXPORT ---
export default function ShoeGrid() {
const [zoomTarget, setZoomTarget] = useState(null);
const [initialZoom] = useState(DEFAULT_CONFIG.zoomOut);
const [currentZoom, setCurrentZoom] = useState(
rigState.zoom
);
const controls = useGridConfig();
// Track zoom state for UI components
useEffect(() => {
const interval = setInterval(() => {
setCurrentZoom(rigState.zoom);
}, 50); // Update every 50ms
return () => clearInterval(interval);
}, []);
// Track active selection state
const [hasActiveSelection, setHasActiveSelection] =
useState(false);
useEffect(() => {
const interval = setInterval(() => {
setHasActiveSelection(rigState.activeId !== null);
}, 16); // Update every frame (60fps) for smoother updates
return () => clearInterval(interval);
}, []);
const isZoomedIn = currentZoom <= CONFIG.zoomIn + 0.5;
// Responsive zoom for mobile viewports
useEffect(() => {
const updateResponsiveZoom = () => {
const width = window.innerWidth;
let newZoomOut;
if (width < 480) {
newZoomOut = 48; // Phone
} else if (width < 768) {
newZoomOut = 38; // Tablet portrait
} else {
newZoomOut = DEFAULT_CONFIG.zoomOut; // Desktop default (31)
}
CONFIG.zoomOut = newZoomOut;
// Only update current zoom if we're in zoomed-out state
if (rigState.zoom > CONFIG.zoomIn + 2) {
rigState.zoom = newZoomOut;
setCurrentZoom(newZoomOut);
}
};
updateResponsiveZoom();
window.addEventListener("resize", updateResponsiveZoom);
return () => window.removeEventListener("resize", updateResponsiveZoom);
}, []);
// Filter state for Nike collection
const [nikeFilter, setNikeFilter] = useState("all"); // 'all' | 'jordan' | 'dunk'
const [colorFilter, setColorFilter] = useState(EMPTY_COLORS); // [] = all, ['blue','green'] = blue OR green
// Collections - Nike (all, unfiltered), New Balance, Under $150
const collectionsData = useMemo(() => {
// All Nike shoes (filtering happens in GridCanvas)
const nike = shoes.filter((s) => s.brand === "Nike");
// New Balance shoes - take half and double to make 30+ items
const newBalanceFull = shoes.filter(
(s) => s.brand === "New Balance"
);
const newBalanceHalf = newBalanceFull.slice(0, Math.ceil(newBalanceFull.length / 2));
const newBalance = [
...newBalanceHalf,
...newBalanceHalf.map((s, i) => ({
...s,
product_url: `${s.product_url}-dup-${i}`,
})),
];
// Under $150 (all brands)
const budget = shoes.filter((s) => {
const price = parseInt(
s.price?.replace(/[$,]/g, "") || "999"
);
return price < 150;
});
return [nike, newBalance, budget];
}, []);
// --- Grid Stack State ---
// Instead of one list of items, we keep a stack of "Rendered Layers".
// This allows us to have one layer exiting and one layer entering simultaneously.
// Initial grid uses Nike collection (index 0)
const [gridLayers, setGridLayers] = useState(() => [
{
id: "init",
items: shoes.filter((s) => s.brand === "Nike"),
mode: "enter", // 'enter' | 'exit'
startTime: 0,
},
]);
const [activeCollectionIdx, setActiveCollectionIdx] =
useState(0);
const handleCollectionSwitch = (index) => {
if (index === activeCollectionIdx) return;
const now = Date.now();
setGridLayers((prev) => {
// 1. Mark existing 'enter' layers as 'exit'
const exitingLayers = prev.map((layer) =>
layer.mode === "enter"
? { ...layer, mode: "exit", startTime: now }
: layer
);
// 2. Add new 'enter' layer
const newLayer = {
id: `grid-${index}-${now}`, // Unique ID for key
items: collectionsData[index],
mode: "enter",
startTime: now,
};
return [...exitingLayers, newLayer];
});
setActiveCollectionIdx(index);
// Clear Nike filters when leaving Nike collection
setNikeFilter("all");
setColorFilter(EMPTY_COLORS);
rigState.target.set(0, 2, 0);
rigState.activeId = null;
// 3. Cleanup old layers after transition time
setTimeout(() => {
setGridLayers((prev) =>
prev.filter((layer) => layer.mode === "enter")
);
}, CONFIG.cleanupTimeout);
};
// Handle filter change (for Nike collection) - just update filter state
// The grid will animate items in place
const handleFilterChange = (filter) => {
if (filter === nikeFilter) return;
setNikeFilter(filter);
rigState.activeId = null;
};
// Handle color filter change (accepts array of colors)
const handleColorFilterChange = (colors) => {
setColorFilter(colors.length > 0 ? colors : EMPTY_COLORS);
rigState.activeId = null;
};
useEffect(() => {
if (zoomTarget === "OUT") {
rigState.zoom = CONFIG.zoomOut;
setCurrentZoom(CONFIG.zoomOut);
rigState.target.set(0, 2, 0);
} else if (typeof zoomTarget === "number") {
rigState.zoom = zoomTarget;
setCurrentZoom(zoomTarget);
}
setZoomTarget(null);
}, [zoomTarget]);
// Determine active grid dimensions for the Rig
// We use the dimensions of the LAST layer (the incoming one)
const activeLayer = gridLayers[gridLayers.length - 1];
// Calculate filtered item count for Nike collection
const filteredItemCount = useMemo(() => {
if (activeCollectionIdx !== 0)
return activeLayer.items.length;
return activeLayer.items.filter((item) =>
matchesFilter(item, nikeFilter, colorFilter)
).length;
}, [activeLayer.items, activeCollectionIdx, nikeFilter, colorFilter]);
const activeDims = calculateGridDimensions(
filteredItemCount
);
return (
);
}