From ba5ad1e46b7609c3aa99a7c0fe6c07b965146ac4 Mon Sep 17 00:00:00 2001 From: Dalton Dinderman Date: Thu, 4 Jun 2026 12:44:50 -0500 Subject: [PATCH] Add infinite canvas artwork explorer with pan/zoom, clusters, and detail panel - Install react-zoom-pan-pinch for pan/zoom functionality - Add InfiniteCanvas component with zoom controls (+/-/reset) - Add API functions for AIC aggregations and cluster artwork fetching - Add cluster layout engine for positioning clusters on canvas - Replace flat card grid with positioned clusters grouped by classification - Implement lazy-loading of artwork images as clusters enter viewport - Add slide-in detail panel for artwork metadata - Add grouping selector dropdown (classification/department/style) - Add search-to-viewport animation that zooms to matching clusters Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 17 +- package.json | 3 +- src/App.css | 391 +++++++++++++++++++++++++++- src/App.jsx | 388 +++++++++++++++++++++------ src/api/fetchAggregations.js | 47 ++++ src/api/fetchClusterArtworks.js | 67 +++++ src/components/Cluster.jsx | 131 ++++++++++ src/components/DetailPanel.jsx | 134 ++++++++++ src/components/GroupingSelector.jsx | 47 ++++ src/components/InfiniteCanvas.jsx | 223 ++++++++++++++++ src/components/SearchBar.jsx | 2 +- src/utils/clusterLayout.js | 166 ++++++++++++ 12 files changed, 1537 insertions(+), 79 deletions(-) create mode 100644 src/api/fetchAggregations.js create mode 100644 src/api/fetchClusterArtworks.js create mode 100644 src/components/Cluster.jsx create mode 100644 src/components/DetailPanel.jsx create mode 100644 src/components/GroupingSelector.jsx create mode 100644 src/components/InfiniteCanvas.jsx create mode 100644 src/utils/clusterLayout.js 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 ? ( + {artwork.title} + ) : ( +
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 ? ( + {artwork.title} + ) : ( +
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 ( + + +
+ +
+ {children} +
+
+
+
+ ); +}); + +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;