diff --git a/package-lock.json b/package-lock.json
index 74d14a5..65f67aa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,8 @@
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router-dom": "^6.15.0"
+ "react-router-dom": "^6.15.0",
+ "react-zoom-pan-pinch": "^4.0.3"
},
"devDependencies": {
"@types/react": "^18.2.15",
@@ -3176,6 +3177,20 @@
"react-dom": ">=16.8"
}
},
+ "node_modules/react-zoom-pan-pinch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.3.tgz",
+ "integrity": "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8",
+ "npm": ">=5"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.3.tgz",
diff --git a/package.json b/package.json
index 37ead22..9ea8a97 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,8 @@
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router-dom": "^6.15.0"
+ "react-router-dom": "^6.15.0",
+ "react-zoom-pan-pinch": "^4.0.3"
},
"devDependencies": {
"@types/react": "^18.2.15",
diff --git a/src/App.css b/src/App.css
index 2ed39b8..f619926 100644
--- a/src/App.css
+++ b/src/App.css
@@ -137,7 +137,394 @@ Something is overriding the .artcard a:hover */
font-size: 1.35rem;
}
-.art-card a:hover {
+.art-card a:hover {
color: #d60036;
- transition-duration: 1s;
+ transition-duration: 1s;
+}
+
+/* INFINITE CANVAS CSS */
+.canvas-controls {
+ position: fixed;
+ top: 120px;
+ right: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: 1000;
+ background: rgba(255, 255, 255, 0.95);
+ padding: 10px;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.canvas-controls button {
+ width: 40px;
+ height: 40px;
+ border: 1px solid #d6003540;
+ border-radius: 4px;
+ background: white;
+ color: #333;
+ font-size: 1.2rem;
+ font-family: 'Lato', sans-serif;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.canvas-controls button:hover {
+ background: #d60036;
+ color: white;
+ border-color: #d60036;
+}
+
+.canvas-controls button:active {
+ transform: scale(0.95);
+}
+
+.canvas-content {
+ min-width: 100%;
+ min-height: 100%;
+ padding: 20px;
+ cursor: grab;
+}
+
+.canvas-content:active {
+ cursor: grabbing;
+}
+
+/* CLUSTER CSS */
+.cluster {
+ background: rgba(255, 255, 255, 0.95);
+ border: 1px solid #d6003530;
+ border-radius: 8px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+ overflow: hidden;
+ transition: box-shadow 0.2s ease;
+}
+
+.cluster:hover {
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
+}
+
+.cluster-label {
+ padding: 12px 16px;
+ background: linear-gradient(to bottom, #fafafa, #f5f5f5);
+ border-bottom: 1px solid #eee;
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.cluster-title {
+ font-family: 'Topaz', serif;
+ font-size: 1.1rem;
+ font-weight: 400;
+ color: #333;
+ margin: 0;
+ text-transform: capitalize;
+}
+
+.cluster-count {
+ font-family: 'Lato', sans-serif;
+ font-size: 0.75rem;
+ color: #999;
+ font-weight: 400;
+}
+
+.cluster-content {
+ position: relative;
+ width: 100%;
+ height: calc(100% - 50px);
+}
+
+.cluster-loading,
+.cluster-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: #999;
+ font-family: 'Lato', sans-serif;
+ font-size: 0.85rem;
+}
+
+.cluster-loading {
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 1; }
+}
+
+/* Artwork thumbnail within cluster */
+.cluster-artwork {
+ cursor: pointer;
+ transition: transform 0.15s ease, z-index 0s;
+}
+
+.cluster-artwork:hover {
+ transform: scale(1.05);
+ z-index: 10;
+}
+
+.cluster-artwork-thumbnail {
+ background: white;
+ border-radius: 4px;
+ overflow: hidden;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+}
+
+.cluster-artwork-thumbnail img {
+ width: 100%;
+ height: 90px;
+ object-fit: cover;
+ display: block;
+ background: #f5f5f5;
+}
+
+.thumbnail-title {
+ font-family: 'Lato', sans-serif;
+ font-size: 0.65rem;
+ color: #666;
+ padding: 4px 6px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+}
+
+.thumbnail-placeholder {
+ width: 100%;
+ height: 90px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f0f0f0;
+ color: #999;
+ font-family: 'Lato', sans-serif;
+ font-size: 0.7rem;
+}
+
+/* Canvas loading state */
+.canvas-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 400px;
+ color: #666;
+ font-family: 'Lato', sans-serif;
+ font-size: 1rem;
+}
+
+/* DETAIL PANEL CSS */
+.detail-panel-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 2000;
+ display: flex;
+ justify-content: flex-end;
+ animation: fadeIn 0.2s ease-out;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.detail-panel {
+ position: relative;
+ width: 450px;
+ max-width: 90vw;
+ height: 100%;
+ background: white;
+ box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15);
+ overflow-y: auto;
+ animation: slideIn 0.3s ease-out;
+}
+
+@keyframes slideIn {
+ from { transform: translateX(100%); }
+ to { transform: translateX(0); }
+}
+
+.detail-panel-close {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 36px;
+ height: 36px;
+ border: none;
+ background: rgba(255, 255, 255, 0.9);
+ border-radius: 50%;
+ font-size: 1.5rem;
+ line-height: 1;
+ color: #666;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.detail-panel-close:hover {
+ background: #d60036;
+ color: white;
+}
+
+.detail-panel-image {
+ width: 100%;
+ background: #f5f5f5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 300px;
+}
+
+.detail-panel-image img {
+ width: 100%;
+ height: auto;
+ display: block;
+ max-height: 60vh;
+ object-fit: contain;
+}
+
+.detail-panel-no-image {
+ width: 100%;
+ height: 300px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #999;
+ font-family: 'Lato', sans-serif;
+ font-size: 1rem;
+}
+
+.detail-panel-metadata {
+ padding: 24px;
+}
+
+.detail-panel-title {
+ font-family: 'Topaz', serif;
+ font-size: 1.5rem;
+ font-weight: 400;
+ color: #333;
+ margin: 0 0 12px 0;
+ line-height: 1.3;
+}
+
+.detail-panel-artist {
+ font-family: 'Lato', sans-serif;
+ font-size: 1rem;
+ font-weight: 400;
+ color: #333;
+ margin: 0 0 8px 0;
+}
+
+.detail-panel-date {
+ font-family: 'Lato', sans-serif;
+ font-size: 0.9rem;
+ color: #666;
+ margin: 0 0 8px 0;
+}
+
+.detail-panel-medium {
+ font-family: 'Lato', sans-serif;
+ font-size: 0.85rem;
+ color: #767676;
+ margin: 0 0 20px 0;
+ line-height: 1.5;
+}
+
+.detail-panel-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-family: 'Lato', sans-serif;
+ font-size: 0.9rem;
+ font-weight: 400;
+ color: #d60036;
+ text-decoration: none;
+ padding: 10px 16px;
+ border: 1px solid #d60036;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+ margin-top: 8px;
+}
+
+.detail-panel-link:hover {
+ background: #d60036;
+ color: white;
+}
+
+.detail-panel-link-arrow {
+ font-size: 1.1rem;
+ transition: transform 0.2s ease;
+}
+
+.detail-panel-link:hover .detail-panel-link-arrow {
+ transform: translateX(4px);
+}
+
+/* SEARCH CONTROLS CSS */
+.search-controls {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+
+/* GROUPING SELECTOR CSS */
+.grouping-selector {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.grouping-selector-label {
+ font-family: 'Lato', sans-serif;
+ font-size: 0.9rem;
+ color: #666;
+ font-weight: 400;
+}
+
+.grouping-selector-dropdown {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ background: white;
+ border: 1px solid #d6003540;
+ border-radius: 4px;
+ padding: 8px 32px 8px 12px;
+ font-family: 'Lato', sans-serif;
+ font-size: 0.9rem;
+ color: #333;
+ cursor: pointer;
+ outline: none;
+ transition: all 0.2s ease;
+ /* Custom dropdown arrow */
+ background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ background-size: 16px;
+}
+
+.grouping-selector-dropdown:hover {
+ border-color: #d60036;
+}
+
+.grouping-selector-dropdown:focus {
+ border-color: #d60036;
+ box-shadow: 0 0 0 2px rgba(214, 0, 54, 0.1);
+}
+
+.grouping-selector-dropdown option {
+ padding: 8px;
+ font-family: 'Lato', sans-serif;
}
\ No newline at end of file
diff --git a/src/App.jsx b/src/App.jsx
index 7403c08..2d8a9a8 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,79 +1,319 @@
import "./App.css"
-import { useState, useEffect } from "react";
-import axios from "axios";
-import ArtCard from "./components/ArtCard";
-import SearchBar from "./components/SearchBar"
-
-// AIC API DOCS
-// https://api.artic.edu/docs/
-
-export default function App() {
-
- // API Data State
- const [data, setData] = useState([]);
+import { useState, useEffect, useCallback, useRef } from "react";
+import SearchBar from "./components/SearchBar";
+import GroupingSelector from "./components/GroupingSelector";
+import InfiniteCanvas from "./components/InfiniteCanvas";
+import Cluster from "./components/Cluster";
+import DetailPanel from "./components/DetailPanel";
+import { fetchAggregations } from "./api/fetchAggregations";
+import { fetchClusterArtworks } from "./api/fetchClusterArtworks";
+import { calculateClusterLayout, calculateCanvasDimensions } from "./utils/clusterLayout";
+
+// AIC API DOCS
+// https://api.artic.edu/docs/
+
+export default function App() {
+
// SEARCH Term State
- const [searchTerm, setSearchTerm] = useState("");
-
-
- useEffect(() => {
- // DO NOT CHANGE THE ORDER OF THE URL SCHEME. REQUEST WILL FAIL.
- // 1
- const API_BASE_URL = "https://api.artic.edu/api/v1/artworks";
- // 2
- const searchPrefix = "/search?q=";
- // 3 (captured by controlled form)
-
- // 3.5
- // const searchTerm = "miro";
- // Temp usage, commented out after search form was working
-
- // 4 & 5
- const limit = "&limit=20"; // 20 results in the search
- // TODO: Will be able to paginate. Make pageNum a var
- // Pass that in a handleClick function and use a CSS component
- const paginate = "?page=1"; // 1 shows the first page of results
- // 6
- // The stumper on fields was the "&". Solved by looking at "query" and "limit" params
- const fields =
- "&fields=id,title,artist_title,artist_display,date_display,medium_display,image_id,next_url,pagination.page,pagination.totalPages"
- // 7
- const publicDomain = "&query[term][is_public_domain]=true"
-
- axios
- .get(`${API_BASE_URL}${searchPrefix}${searchTerm}${fields}${publicDomain}${limit}${paginate}`)
- .then((response) => setData(response.data.data));
- }, [searchTerm]);
-
-
- if (data) {
- return (
-
-
-
-
-
-
From the Collection
-
-
- {data.map((artwork) => {
- return (
-
- )
- })}
-
-
- );
-
- } else {
- return Loading...
;
- }
+ const [searchTerm, setSearchTerm] = useState("");
+
+ // Canvas cluster state
+ const [groupByField, setGroupByField] = useState("classification_title");
+ const [clusterMeta, setClusterMeta] = useState([]);
+
+ // Phase 3: Cluster artwork data and loading state
+ const [loadedClusters, setLoadedClusters] = useState(new Set());
+ const [loadingClusters, setLoadingClusters] = useState(new Set());
+ const [artworkData, setArtworkData] = useState({});
+
+ // Viewport state for lazy loading
+ const [viewport, setViewport] = useState(null);
+
+ // Phase 4: Selected artwork for detail panel
+ const [selectedArtwork, setSelectedArtwork] = useState(null);
+
+ // Ref to track in-flight requests and avoid duplicates
+ const pendingRequests = useRef(new Set());
+
+ // Ref for InfiniteCanvas to enable programmatic navigation
+ const canvasRef = useRef(null);
+
+ // Calculate canvas dimensions based on cluster layout
+ const canvasDimensions = calculateCanvasDimensions(clusterMeta);
+
+ // Debug log for cluster data loading
+ useEffect(() => {
+ if (clusterMeta.length > 0) {
+ console.log("Cluster layout ready:", clusterMeta.length, "clusters");
+ console.log("Canvas dimensions:", canvasDimensions);
+ }
+ }, [clusterMeta, canvasDimensions]);
+
+ // Fetch aggregations for cluster structure on mount and when grouping changes
+ useEffect(() => {
+ fetchAggregations(groupByField)
+ .then((buckets) => {
+ // Calculate layout positions for clusters
+ const layoutData = calculateClusterLayout(buckets);
+ setClusterMeta(layoutData);
+ // Reset artwork data when grouping changes
+ setLoadedClusters(new Set());
+ setLoadingClusters(new Set());
+ setArtworkData({});
+ pendingRequests.current = new Set();
+ })
+ .catch((error) => {
+ console.error("Failed to fetch aggregations:", error);
+ setClusterMeta([]);
+ });
+ }, [groupByField]);
+
+ /**
+ * Check if a cluster intersects with the current viewport
+ */
+ const isClusterVisible = useCallback((cluster, vp) => {
+ if (!vp) return false;
+
+ // Add padding to viewport for preloading nearby clusters
+ const padding = 200;
+ const vpLeft = vp.x - padding;
+ const vpTop = vp.y - padding;
+ const vpRight = vp.x + vp.width + padding;
+ const vpBottom = vp.y + vp.height + padding;
+
+ // Cluster bounds
+ const clusterLeft = cluster.x;
+ const clusterTop = cluster.y;
+ const clusterRight = cluster.x + cluster.width;
+ const clusterBottom = cluster.y + cluster.height;
+
+ // Check for intersection
+ return !(
+ clusterRight < vpLeft ||
+ clusterLeft > vpRight ||
+ clusterBottom < vpTop ||
+ clusterTop > vpBottom
+ );
+ }, []);
+
+ /**
+ * Fetch artworks for a single cluster
+ */
+ const loadClusterArtworks = useCallback(async (clusterKey) => {
+ // Skip if already loaded, loading, or request pending
+ if (
+ loadedClusters.has(clusterKey) ||
+ loadingClusters.has(clusterKey) ||
+ pendingRequests.current.has(clusterKey)
+ ) {
+ return;
+ }
+
+ // Mark as pending and loading
+ pendingRequests.current.add(clusterKey);
+ setLoadingClusters(prev => new Set([...prev, clusterKey]));
+
+ try {
+ const artworks = await fetchClusterArtworks(groupByField, clusterKey, 20);
+
+ // Update artwork data
+ setArtworkData(prev => ({
+ ...prev,
+ [clusterKey]: artworks,
+ }));
+
+ // Mark as loaded
+ setLoadedClusters(prev => new Set([...prev, clusterKey]));
+ } catch (error) {
+ console.error(`Failed to load artworks for cluster "${clusterKey}":`, error);
+ } finally {
+ // Remove from loading and pending
+ setLoadingClusters(prev => {
+ const next = new Set(prev);
+ next.delete(clusterKey);
+ return next;
+ });
+ pendingRequests.current.delete(clusterKey);
+ }
+ }, [groupByField, loadedClusters, loadingClusters]);
+
+ /**
+ * Handle viewport changes - load visible clusters
+ */
+ useEffect(() => {
+ if (!viewport || clusterMeta.length === 0) return;
+
+ // Find clusters that intersect with viewport
+ const visibleClusters = clusterMeta.filter(cluster =>
+ isClusterVisible(cluster, viewport)
+ );
+
+ // Load artworks for visible clusters that haven't been loaded
+ visibleClusters.forEach(cluster => {
+ if (!loadedClusters.has(cluster.key) && !pendingRequests.current.has(cluster.key)) {
+ loadClusterArtworks(cluster.key);
+ }
+ });
+ }, [viewport, clusterMeta, isClusterVisible, loadedClusters, loadClusterArtworks]);
+
+ /**
+ * Handle transform changes from InfiniteCanvas
+ */
+ const handleTransformChange = useCallback((bounds) => {
+ setViewport(bounds);
+ }, []);
+
+ /**
+ * Handle artwork click - opens detail panel
+ */
+ const handleArtworkClick = useCallback((artwork) => {
+ console.log("Artwork clicked:", artwork.title, artwork);
+ setSelectedArtwork(artwork);
+ }, []);
+
+ /**
+ * Handle closing the detail panel
+ */
+ const handleCloseDetailPanel = useCallback(() => {
+ setSelectedArtwork(null);
+ }, []);
+
+ /**
+ * Handle grouping field change - refetch aggregations and reset layout
+ */
+ const handleGroupByChange = useCallback((newGroupByField) => {
+ console.log("Grouping changed to:", newGroupByField);
+ setGroupByField(newGroupByField);
+ // Reset viewport to initial state when grouping changes
+ if (canvasRef.current) {
+ canvasRef.current.resetView();
+ }
+ }, []);
+
+ /**
+ * Find clusters that match the search term
+ * Searches in cluster keys and loaded artwork data
+ */
+ const findMatchingClusters = useCallback((term) => {
+ if (!term || term.trim() === "") return [];
+
+ const lowerTerm = term.toLowerCase().trim();
+ const matchingClusters = [];
+
+ clusterMeta.forEach((cluster) => {
+ // Check if cluster key matches
+ const keyMatches = cluster.key.toLowerCase().includes(lowerTerm);
+
+ // Check if any artwork in this cluster matches
+ const clusterArtworks = artworkData[cluster.key] || [];
+ const artworkMatches = clusterArtworks.some(
+ (artwork) =>
+ artwork.title?.toLowerCase().includes(lowerTerm) ||
+ artwork.artist_title?.toLowerCase().includes(lowerTerm) ||
+ artwork.artist_display?.toLowerCase().includes(lowerTerm) ||
+ artwork.medium_display?.toLowerCase().includes(lowerTerm)
+ );
+
+ if (keyMatches || artworkMatches) {
+ matchingClusters.push(cluster);
+ }
+ });
+
+ return matchingClusters;
+ }, [clusterMeta, artworkData]);
+
+ /**
+ * Calculate bounding box that encompasses all matching clusters
+ */
+ const calculateClusterBounds = useCallback((clusters) => {
+ if (!clusters || clusters.length === 0) return null;
+
+ const minX = Math.min(...clusters.map((c) => c.x));
+ const minY = Math.min(...clusters.map((c) => c.y));
+ const maxX = Math.max(...clusters.map((c) => c.x + c.width));
+ const maxY = Math.max(...clusters.map((c) => c.y + c.height));
+
+ return {
+ x: minX,
+ y: minY,
+ width: maxX - minX,
+ height: maxY - minY,
+ };
+ }, []);
+
+ /**
+ * Effect to handle search term changes - animate viewport to matching clusters
+ */
+ useEffect(() => {
+ // Debounce search to avoid too many viewport animations
+ const timeoutId = setTimeout(() => {
+ if (!searchTerm || searchTerm.trim() === "") {
+ // Clear search - reset viewport to initial state
+ if (canvasRef.current) {
+ canvasRef.current.resetView();
+ }
+ return;
+ }
+
+ const matchingClusters = findMatchingClusters(searchTerm);
+ console.log("Search matches:", matchingClusters.length, "clusters for term:", searchTerm);
+
+ if (matchingClusters.length > 0 && canvasRef.current) {
+ const bounds = calculateClusterBounds(matchingClusters);
+ if (bounds) {
+ console.log("Animating viewport to bounds:", bounds);
+ canvasRef.current.zoomToBounds(bounds, 100);
+ }
+ }
+ }, 500); // 500ms debounce
+
+ return () => clearTimeout(timeoutId);
+ }, [searchTerm, findMatchingClusters, calculateClusterBounds]);
+
+ return (
+
+
+
+
+
+
+
Explore the Collection
+
+
+ {clusterMeta.length > 0 ? (
+ clusterMeta.map((cluster) => (
+
+ ))
+ ) : (
+
+ Loading collection clusters...
+
+ )}
+
+
+ {/* Detail panel for selected artwork */}
+
+
+ );
}
{/* CLEAR SEARCH: MOVED TO SEARCHBAR COMPONENT */}
diff --git a/src/api/fetchAggregations.js b/src/api/fetchAggregations.js
new file mode 100644
index 0000000..23bf62e
--- /dev/null
+++ b/src/api/fetchAggregations.js
@@ -0,0 +1,47 @@
+import axios from "axios";
+
+const API_BASE_URL = "https://api.artic.edu/api/v1/artworks/search";
+
+/**
+ * Fetch aggregation counts for a given grouping field from the AIC API.
+ * Uses Elasticsearch aggregations to get bucket counts without fetching artwork records.
+ *
+ * @param {string} groupByField - The field to group by (e.g., "classification_title", "department_title", "style_title")
+ * @returns {Promise>} Array of buckets with key and count
+ */
+export async function fetchAggregations(groupByField = "classification_title") {
+ // Build the aggregation query URL
+ // - limit=0 means we only want aggregation data, no artwork records
+ // - is_public_domain=true filters to artworks with usable images
+ // - .keyword suffix is required for text field aggregations in Elasticsearch
+ const params = new URLSearchParams({
+ limit: "0",
+ "query[term][is_public_domain]": "true",
+ [`aggs[by_${groupByField}][terms][field]`]: `${groupByField}.keyword`,
+ [`aggs[by_${groupByField}][terms][size]`]: "50", // Get top 50 clusters
+ });
+
+ const url = `${API_BASE_URL}?${params.toString()}`;
+
+ try {
+ const response = await axios.get(url);
+ const aggregations = response.data.aggregations;
+
+ if (!aggregations || !aggregations[`by_${groupByField}`]) {
+ console.warn(`No aggregations found for field: ${groupByField}`);
+ return [];
+ }
+
+ const buckets = aggregations[`by_${groupByField}`].buckets;
+
+ // Log for debugging during development
+ console.log(`Aggregations for ${groupByField}:`, buckets);
+
+ return buckets;
+ } catch (error) {
+ console.error("Error fetching aggregations:", error);
+ throw error;
+ }
+}
+
+export default fetchAggregations;
diff --git a/src/api/fetchClusterArtworks.js b/src/api/fetchClusterArtworks.js
new file mode 100644
index 0000000..aafef08
--- /dev/null
+++ b/src/api/fetchClusterArtworks.js
@@ -0,0 +1,67 @@
+import axios from "axios";
+
+const API_BASE_URL = "https://api.artic.edu/api/v1/artworks/search";
+
+/**
+ * Fetch artworks for a specific cluster (group value) from the AIC API.
+ *
+ * @param {string} groupByField - The field to filter by (e.g., "classification_title")
+ * @param {string} groupValue - The value to match (e.g., "Painting")
+ * @param {number} limit - Maximum number of artworks to fetch (default: 20)
+ * @returns {Promise} Array of artwork objects with image_id, title, artist_title, etc.
+ */
+export async function fetchClusterArtworks(
+ groupByField = "classification_title",
+ groupValue,
+ limit = 20
+) {
+ // Build the search query URL
+ // - Filter by the specific group value using term query
+ // - Require public domain for usable images
+ // - Request only the fields we need
+ const fields = [
+ "id",
+ "title",
+ "artist_title",
+ "artist_display",
+ "date_display",
+ "medium_display",
+ "image_id",
+ "classification_title",
+ "department_title",
+ "style_title",
+ ].join(",");
+
+ const params = new URLSearchParams({
+ fields: fields,
+ limit: limit.toString(),
+ "query[bool][must][0][term][is_public_domain]": "true",
+ [`query[bool][must][1][term][${groupByField}.keyword]`]: groupValue,
+ });
+
+ const url = `${API_BASE_URL}?${params.toString()}`;
+
+ try {
+ const response = await axios.get(url);
+ const artworks = response.data.data || [];
+
+ // Filter out artworks without images
+ const artworksWithImages = artworks.filter(
+ (artwork) => artwork.image_id && artwork.image_id !== null
+ );
+
+ console.log(
+ `Fetched ${artworksWithImages.length} artworks for ${groupByField}="${groupValue}"`
+ );
+
+ return artworksWithImages;
+ } catch (error) {
+ console.error(
+ `Error fetching artworks for ${groupByField}="${groupValue}":`,
+ error
+ );
+ throw error;
+ }
+}
+
+export default fetchClusterArtworks;
diff --git a/src/components/Cluster.jsx b/src/components/Cluster.jsx
new file mode 100644
index 0000000..e2859f0
--- /dev/null
+++ b/src/components/Cluster.jsx
@@ -0,0 +1,131 @@
+import PropTypes from "prop-types";
+import { calculateArtworkPositions, LAYOUT_CONSTANTS } from "../utils/clusterLayout";
+
+/**
+ * Cluster - Renders a cluster label and its artwork grid
+ * Each cluster is positioned absolutely on the canvas based on its layout coordinates
+ */
+function Cluster({ cluster, artworks, isLoading, onArtworkClick }) {
+ // Calculate positions for artworks within this cluster
+ const artworkPositions = calculateArtworkPositions(
+ artworks.length,
+ cluster.cols || Math.ceil(Math.sqrt(artworks.length))
+ );
+
+ return (
+
+ {/* Cluster label */}
+
+
{cluster.key}
+
+ {cluster.doc_count.toLocaleString()} artworks
+
+
+
+ {/* Cluster content area */}
+
+ {isLoading ? (
+
+ Loading artworks...
+
+ ) : artworks.length > 0 ? (
+ artworks.map((artwork, index) => {
+ const position = artworkPositions[index] || { localX: 0, localY: 0 };
+ return (
+
onArtworkClick && onArtworkClick(artwork)}
+ >
+
+
+ );
+ })
+ ) : (
+
+ Zoom in to load artworks
+
+ )}
+
+
+ );
+}
+
+/**
+ * ClusterArtworkThumbnail - Simplified artwork display for cluster grid
+ * Shows smaller thumbnails than the full ArtCard
+ */
+function ClusterArtworkThumbnail({ artwork }) {
+ const iiifUrl = "https://www.artic.edu/iiif/2/";
+ const iiifSize = "/full/200,/0/default.jpg"; // Smaller size for thumbnails
+
+ return (
+
+ {artwork.image_id ? (
+

+ ) : (
+
No Image
+ )}
+
+ {artwork.title}
+
+
+ );
+}
+
+ClusterArtworkThumbnail.propTypes = {
+ artwork: PropTypes.shape({
+ id: PropTypes.number,
+ title: PropTypes.string,
+ image_id: PropTypes.string,
+ }).isRequired,
+};
+
+Cluster.propTypes = {
+ cluster: PropTypes.shape({
+ key: PropTypes.string.isRequired,
+ doc_count: PropTypes.number.isRequired,
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ cols: PropTypes.number,
+ rows: PropTypes.number,
+ }).isRequired,
+ artworks: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.number,
+ title: PropTypes.string,
+ image_id: PropTypes.string,
+ })
+ ),
+ isLoading: PropTypes.bool,
+ onArtworkClick: PropTypes.func,
+};
+
+Cluster.defaultProps = {
+ artworks: [],
+ isLoading: false,
+ onArtworkClick: null,
+};
+
+export default Cluster;
diff --git a/src/components/DetailPanel.jsx b/src/components/DetailPanel.jsx
new file mode 100644
index 0000000..4a0309c
--- /dev/null
+++ b/src/components/DetailPanel.jsx
@@ -0,0 +1,134 @@
+import PropTypes from "prop-types";
+import { useEffect, useCallback } from "react";
+
+/**
+ * DetailPanel - Slide-in panel that shows larger image and metadata
+ * when an artwork is clicked. Maintains exploration context without
+ * leaving the canvas.
+ */
+function DetailPanel({ artwork, onClose }) {
+ // IIIF image URLs
+ const iiifUrl = "https://www.artic.edu/iiif/2/";
+ const iiifSizeLarge = "/full/843,/0/default.jpg";
+
+ // AIC website URL for linking to full artwork page
+ const aicUrl = "https://www.artic.edu/artworks/";
+
+ /**
+ * Handle click outside the panel to close it
+ */
+ const handleBackdropClick = useCallback(
+ (e) => {
+ // Only close if clicking the backdrop itself, not the panel content
+ if (e.target.classList.contains("detail-panel-backdrop")) {
+ onClose();
+ }
+ },
+ [onClose]
+ );
+
+ /**
+ * Handle escape key to close panel
+ */
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [onClose]);
+
+ // Don't render if no artwork is selected
+ if (!artwork) return null;
+
+ return (
+
+
+ {/* Close button */}
+
+
+ {/* Large artwork image */}
+
+ {artwork.image_id ? (
+

+ ) : (
+
No Image Available
+ )}
+
+
+ {/* Artwork metadata */}
+
+
+ {artwork.title}
+
+
+ {/* Artist */}
+ {artwork.artist_title && (
+
{artwork.artist_title}
+ )}
+
+ {/* Date */}
+ {artwork.date_display && (
+
{artwork.date_display}
+ )}
+
+ {/* Medium */}
+ {artwork.medium_display && (
+
{artwork.medium_display}
+ )}
+
+ {/* Link to AIC website */}
+
+ View on Art Institute of Chicago
+ →
+
+
+
+
+ );
+}
+
+DetailPanel.propTypes = {
+ artwork: PropTypes.shape({
+ id: PropTypes.number,
+ title: PropTypes.string,
+ artist_title: PropTypes.string,
+ artist_display: PropTypes.string,
+ date_display: PropTypes.string,
+ medium_display: PropTypes.string,
+ image_id: PropTypes.string,
+ classification_title: PropTypes.string,
+ department_title: PropTypes.string,
+ style_title: PropTypes.string,
+ }),
+ onClose: PropTypes.func.isRequired,
+};
+
+DetailPanel.defaultProps = {
+ artwork: null,
+};
+
+export default DetailPanel;
diff --git a/src/components/GroupingSelector.jsx b/src/components/GroupingSelector.jsx
new file mode 100644
index 0000000..3c8c248
--- /dev/null
+++ b/src/components/GroupingSelector.jsx
@@ -0,0 +1,47 @@
+import PropTypes from "prop-types";
+
+/**
+ * GroupingSelector - Dropdown to switch the grouping dimension for canvas clusters
+ *
+ * @param {object} props
+ * @param {string} props.value - Current selected grouping field
+ * @param {function} props.onChange - Callback when selection changes
+ */
+function GroupingSelector({ value, onChange }) {
+ const groupingOptions = [
+ { value: "classification_title", label: "Classification" },
+ { value: "department_title", label: "Department" },
+ { value: "style_title", label: "Style" },
+ ];
+
+ const handleChange = (event) => {
+ onChange(event.target.value);
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+GroupingSelector.propTypes = {
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+export default GroupingSelector;
diff --git a/src/components/InfiniteCanvas.jsx b/src/components/InfiniteCanvas.jsx
new file mode 100644
index 0000000..4e730a5
--- /dev/null
+++ b/src/components/InfiniteCanvas.jsx
@@ -0,0 +1,223 @@
+import { TransformWrapper, TransformComponent, useControls } from "react-zoom-pan-pinch";
+import PropTypes from "prop-types";
+import { useCallback, useRef, useEffect, useImperativeHandle, forwardRef } from "react";
+
+/**
+ * CanvasControls - Zoom in/out/reset buttons for the infinite canvas
+ * Must be rendered inside TransformWrapper to access useControls hook
+ */
+function CanvasControls() {
+ const { zoomIn, zoomOut, resetTransform } = useControls();
+
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * Calculate viewport bounds from transform state
+ * @param {object} state - Transform state from react-zoom-pan-pinch
+ * @param {number} wrapperWidth - Width of the wrapper container
+ * @param {number} wrapperHeight - Height of the wrapper container
+ * @returns {object} Viewport bounds { x, y, width, height, scale }
+ */
+function calculateViewportBounds(state, wrapperWidth, wrapperHeight) {
+ const { scale, positionX, positionY } = state;
+
+ // The viewport shows content that is offset by -positionX/Y and scaled
+ // To get content coordinates, we need to invert the transform
+ const viewportX = -positionX / scale;
+ const viewportY = -positionY / scale;
+ const viewportWidth = wrapperWidth / scale;
+ const viewportHeight = wrapperHeight / scale;
+
+ return {
+ x: viewportX,
+ y: viewportY,
+ width: viewportWidth,
+ height: viewportHeight,
+ scale: scale,
+ };
+}
+
+/**
+ * InfiniteCanvas - Wraps content in a pannable/zoomable container
+ * Uses react-zoom-pan-pinch for CSS transform-based pan/zoom
+ *
+ * @param {object} props
+ * @param {React.ReactNode} props.children - Content to render inside the canvas
+ * @param {function} props.onTransformChange - Callback fired when viewport changes with bounds info
+ * @param {object} props.canvasDimensions - Optional canvas dimensions { width, height }
+ * @param {React.Ref} ref - Ref to expose canvas control methods
+ */
+const InfiniteCanvas = forwardRef(function InfiniteCanvas(
+ { children, onTransformChange, canvasDimensions },
+ ref
+) {
+ const wrapperRef = useRef(null);
+ const transformRef = useRef(null);
+
+ // Handle transform changes (pan/zoom)
+ const handleTransformChange = useCallback((refObj) => {
+ if (!onTransformChange || !wrapperRef.current) return;
+
+ const wrapperRect = wrapperRef.current.getBoundingClientRect();
+ const bounds = calculateViewportBounds(
+ refObj.state,
+ wrapperRect.width,
+ wrapperRect.height
+ );
+
+ onTransformChange(bounds);
+ }, [onTransformChange]);
+
+ // Trigger initial viewport calculation after mount
+ useEffect(() => {
+ if (onTransformChange && wrapperRef.current) {
+ const wrapperRect = wrapperRef.current.getBoundingClientRect();
+ const initialBounds = calculateViewportBounds(
+ { scale: 1, positionX: 0, positionY: 0 },
+ wrapperRect.width,
+ wrapperRect.height
+ );
+ onTransformChange(initialBounds);
+ }
+ }, [onTransformChange]);
+
+ // Expose canvas control methods via ref
+ useImperativeHandle(ref, () => ({
+ /**
+ * Animate viewport to center on and fit a bounding box
+ * @param {object} bounds - { x, y, width, height } in canvas coordinates
+ * @param {number} padding - Additional padding around the bounds (default: 50)
+ */
+ zoomToBounds: (bounds, padding = 50) => {
+ if (!transformRef.current || !wrapperRef.current) return;
+
+ const wrapperRect = wrapperRef.current.getBoundingClientRect();
+ const { setTransform } = transformRef.current;
+
+ // Calculate the scale needed to fit the bounds in the viewport
+ const paddedWidth = bounds.width + padding * 2;
+ const paddedHeight = bounds.height + padding * 2;
+
+ const scaleX = wrapperRect.width / paddedWidth;
+ const scaleY = wrapperRect.height / paddedHeight;
+ const scale = Math.min(scaleX, scaleY, 2); // Cap at 2x zoom
+
+ // Calculate the center of the bounds
+ const centerX = bounds.x + bounds.width / 2;
+ const centerY = bounds.y + bounds.height / 2;
+
+ // Calculate position to center the bounds in viewport
+ const positionX = wrapperRect.width / 2 - centerX * scale;
+ const positionY = wrapperRect.height / 2 - centerY * scale;
+
+ // Animate to the new transform
+ setTransform(positionX, positionY, scale, 500, "easeOut");
+ },
+
+ /**
+ * Reset viewport to initial state (centered, scale 0.5)
+ */
+ resetView: () => {
+ if (!transformRef.current) return;
+ transformRef.current.resetTransform(500, "easeOut");
+ },
+
+ /**
+ * Get the current transform state
+ */
+ getTransform: () => {
+ if (!transformRef.current) return null;
+ return transformRef.current.state;
+ },
+ }), []);
+
+ const canvasWidth = canvasDimensions?.width || 4000;
+ const canvasHeight = canvasDimensions?.height || 3000;
+
+ return (
+
+
+
+
+ );
+});
+
+InfiniteCanvas.propTypes = {
+ children: PropTypes.node.isRequired,
+ onTransformChange: PropTypes.func,
+ canvasDimensions: PropTypes.shape({
+ width: PropTypes.number,
+ height: PropTypes.number,
+ }),
+};
+
+InfiniteCanvas.defaultProps = {
+ onTransformChange: null,
+ canvasDimensions: null,
+};
+
+export default InfiniteCanvas;
diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx
index 3e42986..315678e 100644
--- a/src/components/SearchBar.jsx
+++ b/src/components/SearchBar.jsx
@@ -1,5 +1,5 @@
/* eslint-disable react/prop-types */
-export default function Search ({searchTerm, artSearch, setData}) {
+export default function Search ({searchTerm, artSearch}) {
// update setData as typing happens
const handleChange = (evt) => {
diff --git a/src/utils/clusterLayout.js b/src/utils/clusterLayout.js
new file mode 100644
index 0000000..f80a39d
--- /dev/null
+++ b/src/utils/clusterLayout.js
@@ -0,0 +1,166 @@
+/**
+ * Grid layout calculator for positioning clusters on the canvas.
+ * Arranges clusters in a grid pattern based on their counts and available canvas width.
+ */
+
+// Layout constants
+const CLUSTER_PADDING = 40; // Padding between clusters
+const MIN_CLUSTER_WIDTH = 300; // Minimum cluster width
+const MAX_CLUSTER_WIDTH = 600; // Maximum cluster width
+const ARTWORK_SIZE = 120; // Size of artwork thumbnail
+const ARTWORK_GAP = 10; // Gap between artworks in a cluster
+const LABEL_HEIGHT = 50; // Height reserved for cluster label
+
+/**
+ * Calculate grid layout positions for clusters based on their bucket data.
+ *
+ * @param {Array<{key: string, doc_count: number}>} buckets - Aggregation buckets from API
+ * @param {number} canvasWidth - Available canvas width (default: 4000)
+ * @returns {Array<{key: string, doc_count: number, x: number, y: number, width: number, height: number}>}
+ */
+export function calculateClusterLayout(buckets, canvasWidth = 4000) {
+ if (!buckets || buckets.length === 0) {
+ return [];
+ }
+
+ // Calculate cluster dimensions based on doc_count
+ const layoutClusters = buckets.map((bucket) => {
+ // Calculate how many artworks we'll show (cap at 20 for performance)
+ const displayCount = Math.min(bucket.doc_count, 20);
+
+ // Calculate grid dimensions for artworks within cluster
+ // Aim for roughly square clusters
+ const cols = Math.ceil(Math.sqrt(displayCount));
+ const rows = Math.ceil(displayCount / cols);
+
+ // Calculate cluster dimensions
+ const contentWidth = cols * (ARTWORK_SIZE + ARTWORK_GAP) - ARTWORK_GAP;
+ const contentHeight = rows * (ARTWORK_SIZE + ARTWORK_GAP) - ARTWORK_GAP;
+
+ // Clamp width to min/max
+ const width = Math.max(
+ MIN_CLUSTER_WIDTH,
+ Math.min(MAX_CLUSTER_WIDTH, contentWidth + CLUSTER_PADDING * 2)
+ );
+ const height = contentHeight + LABEL_HEIGHT + CLUSTER_PADDING * 2;
+
+ return {
+ key: bucket.key,
+ doc_count: bucket.doc_count,
+ width,
+ height,
+ cols,
+ rows,
+ displayCount,
+ };
+ });
+
+ // Calculate grid positions
+ // Determine how many columns of clusters fit in the canvas
+ const avgClusterWidth =
+ layoutClusters.reduce((sum, c) => sum + c.width, 0) / layoutClusters.length;
+ const gridCols = Math.max(
+ 1,
+ Math.floor((canvasWidth - CLUSTER_PADDING) / (avgClusterWidth + CLUSTER_PADDING))
+ );
+
+ // Position clusters in a grid
+ let currentX = CLUSTER_PADDING;
+ let currentY = CLUSTER_PADDING;
+ let rowMaxHeight = 0;
+ let colIndex = 0;
+
+ const positionedClusters = layoutClusters.map((cluster) => {
+ // Check if we need to wrap to next row
+ if (colIndex >= gridCols) {
+ currentX = CLUSTER_PADDING;
+ currentY += rowMaxHeight + CLUSTER_PADDING;
+ rowMaxHeight = 0;
+ colIndex = 0;
+ }
+
+ const positioned = {
+ ...cluster,
+ x: currentX,
+ y: currentY,
+ };
+
+ // Update position for next cluster
+ currentX += cluster.width + CLUSTER_PADDING;
+ rowMaxHeight = Math.max(rowMaxHeight, cluster.height);
+ colIndex++;
+
+ return positioned;
+ });
+
+ // Log layout info for debugging
+ console.log("Cluster layout calculated:", {
+ totalClusters: positionedClusters.length,
+ gridColumns: gridCols,
+ canvasWidth,
+ positions: positionedClusters.map((c) => ({
+ key: c.key,
+ x: c.x,
+ y: c.y,
+ width: c.width,
+ height: c.height,
+ })),
+ });
+
+ return positionedClusters;
+}
+
+/**
+ * Calculate the total canvas dimensions needed for all clusters.
+ *
+ * @param {Array<{x: number, y: number, width: number, height: number}>} clusters - Positioned clusters
+ * @returns {{width: number, height: number}} Canvas dimensions
+ */
+export function calculateCanvasDimensions(clusters) {
+ if (!clusters || clusters.length === 0) {
+ return { width: 4000, height: 3000 };
+ }
+
+ const maxX = Math.max(...clusters.map((c) => c.x + c.width));
+ const maxY = Math.max(...clusters.map((c) => c.y + c.height));
+
+ return {
+ width: maxX + CLUSTER_PADDING,
+ height: maxY + CLUSTER_PADDING,
+ };
+}
+
+/**
+ * Calculate positions for artworks within a cluster.
+ *
+ * @param {number} artworkCount - Number of artworks in the cluster
+ * @param {number} cols - Number of columns in the cluster grid
+ * @returns {Array<{localX: number, localY: number}>} Local positions within cluster
+ */
+export function calculateArtworkPositions(artworkCount, cols) {
+ const positions = [];
+
+ for (let i = 0; i < artworkCount; i++) {
+ const col = i % cols;
+ const row = Math.floor(i / cols);
+
+ positions.push({
+ localX: CLUSTER_PADDING + col * (ARTWORK_SIZE + ARTWORK_GAP),
+ localY: LABEL_HEIGHT + CLUSTER_PADDING + row * (ARTWORK_SIZE + ARTWORK_GAP),
+ });
+ }
+
+ return positions;
+}
+
+// Export constants for use in components
+export const LAYOUT_CONSTANTS = {
+ CLUSTER_PADDING,
+ MIN_CLUSTER_WIDTH,
+ MAX_CLUSTER_WIDTH,
+ ARTWORK_SIZE,
+ ARTWORK_GAP,
+ LABEL_HEIGHT,
+};
+
+export default calculateClusterLayout;