Sub-Container Overview Images - Frontend Implementation Requirements
Sub-Container Overview Images - Frontend Implementation Requirements
Last Updated: Mån 7 Jul 2025 11:18:53 CEST
Status: Implementation Planning
Priority: High
Related Use Case: UC-306: Sub-Container Overview Images
Executive Summary
This document defines the frontend implementation requirements for sub-container overview images - a feature that generates and displays visual previews showing the spatial layout of objects contained within containers. This enhancement integrates with the existing spatial relationships system to provide users with immediate visual understanding of container contents before navigation.
Integration with Existing Systems
Spatial Relationships System Integration
Current System Leverage
- Container Detection: Utilizes existing
spatialChildrenrelationships from GraphQL queries - Spatial Predicates: Leverages dual predicate system (
CURRENT_*,NORMAL_*) for layout calculation - Hierarchy Navigation: Integrates seamlessly with existing
HierarchicalSpatialCardcomponents - Parent-Child Relationships: Respects existing containment rules (
IN,CONTAINS,ONrelationships)
Spatial Data Sources
// Existing spatial data from GraphQL queries
interface SpatialDataForOverview {
spatialChildren: ObjectInstance[]; // Objects contained in this container
spatialHierarchy: SpatialRef[]; // Navigation breadcrumb data
positionalRelationships: PositionalRelationship[]; // Spatial predicate relationships
currentPlacement: Placement; // Current location context
normalPlacement: Placement; // Expected location context
}
Spatial Layout Calculation
The overview generation will use existing spatial predicates to determine object positioning:
// Layout calculation using existing spatial predicates
const calculateSpatialLayout = (
spatialChildren: ObjectInstance[],
positionalRelationships: PositionalRelationship[]
): SpatialLayout => {
// Use existing spatial predicates for positioning
const positioning = positionalRelationships.reduce((layout, rel) => {
switch (rel.relationshipType) {
case 'CURRENT_LEFT_OF':
case 'NORMAL_LEFT_OF':
layout.placeLeftOf(rel.object, rel.relatedObject);
break;
case 'CURRENT_RIGHT_OF':
case 'NORMAL_RIGHT_OF':
layout.placeRightOf(rel.object, rel.relatedObject);
break;
case 'CURRENT_ABOVE':
case 'NORMAL_ABOVE':
layout.placeAbove(rel.object, rel.relatedObject);
break;
case 'CURRENT_UNDER':
case 'NORMAL_UNDER':
layout.placeBelow(rel.object, rel.relatedObject);
break;
// Additional spatial predicates...
}
return layout;
}, new SpatialLayout());
return positioning;
};
Image Management System Integration
Leveraging Existing Image Infrastructure
- Storage: Uses existing
object-imagesSupabase bucket with new path structure - CDN Transformations: Leverages existing Supabase image transformation pipeline
- Single Source of Truth: Follows established pattern for image metadata storage
- Access Control: Respects existing RLS policies and organization boundaries
Overview Image Storage Pattern
// Storage path following existing patterns
const overviewImagePath = {
bucket: 'object-images',
path: `overviews/containers/${containerId}/layout-v${version}.png`,
metadata: {
container_id: containerId,
generated_at: timestamp,
version: versionNumber,
object_count: containedObjectCount
}
};
// Integration with existing image management
interface ContainerOverviewImage {
id: string;
container_id: string;
storage_path: string;
public_url: string;
version: number;
generated_at: string;
object_count: number;
file_size: number;
}
CDN Integration for Performance
// Leverage existing image transformation system
const { transformedUrl, loading, error } = useTransformedImage(
overviewImageUrl,
{
width: 336, // 2x retina for 168px display
height: 224, // 2x retina for 112px display
resize: 'contain', // Preserve aspect ratio for layout accuracy
quality: 90,
format: 'webp' // Use modern format for efficiency
}
);
Component Architecture
Enhanced HierarchicalSpatialCard Component
Overview Image Integration
// Enhanced existing component with overview image support
interface HierarchicalSpatialCardProps {
object: HierarchicalSpatialObject;
// ... existing props
// New overview image props
showOverviewImage?: boolean;
overviewImageStyle?: 'thumbnail' | 'detailed' | 'hover';
onOverviewImageError?: (error: Error) => void;
enableOverviewImageGeneration?: boolean;
}
const HierarchicalSpatialCard: React.FC<HierarchicalSpatialCardProps> = ({
object,
showOverviewImage = true,
overviewImageStyle = 'thumbnail',
...props
}) => {
const hasChildren = object.children.length > 0;
const isContainer = hasChildren || object.type === 'Container';
return (
<Card className={getCardClasses()}>
{/* Existing card header */}
<CardHeader>
{/* ... existing header content */}
</CardHeader>
<CardContent>
{/* Main object image or overview image for containers */}
{isContainer && showOverviewImage ? (
<ContainerOverviewDisplay
containerId={object.id}
style={overviewImageStyle}
fallbackToObjectImage={true}
/>
) : (
<ObjectImage
imageUrl={object.mainImageUrl}
objectName={object.name}
/>
)}
{/* ... existing content */}
</CardContent>
</Card>
);
};
New ContainerOverviewDisplay Component
Core Overview Display Component
interface ContainerOverviewDisplayProps {
containerId: string;
style: 'thumbnail' | 'detailed' | 'hover';
fallbackToObjectImage?: boolean;
enableGeneration?: boolean;
onImageClick?: () => void;
onGenerationStart?: () => void;
onGenerationComplete?: (url: string) => void;
}
const ContainerOverviewDisplay: React.FC<ContainerOverviewDisplayProps> = ({
containerId,
style,
fallbackToObjectImage = true,
enableGeneration = true
}) => {
const {
overviewImageUrl,
loading,
error,
generateOverview,
isGenerating
} = useContainerOverview(containerId);
const { transformedUrl, loading: transformLoading } = useTransformedImage(
overviewImageUrl,
getDimensionsForStyle(style)
);
if (loading || transformLoading) {
return <OverviewImageSkeleton style={style} />;
}
if (error || !overviewImageUrl) {
return (
<OverviewImageFallback
containerId={containerId}
style={style}
enableGeneration={enableGeneration}
onGenerateClick={generateOverview}
isGenerating={isGenerating}
fallbackToObjectImage={fallbackToObjectImage}
/>
);
}
return (
<div className="overview-image-container">
<img
src={transformedUrl}
alt={`Container layout overview`}
className={getOverviewImageClasses(style)}
loading="lazy"
/>
{style === 'thumbnail' && (
<OverviewImageBadge containerId={containerId} />
)}
{style === 'hover' && (
<OverviewImageLabels containerId={containerId} />
)}
</div>
);
};
Overview Image Management Hook
useContainerOverview Hook
interface UseContainerOverviewResult {
overviewImageUrl: string | null;
loading: boolean;
error: Error | null;
generateOverview: () => Promise<void>;
refreshOverview: () => Promise<void>;
isGenerating: boolean;
lastGenerated: Date | null;
version: number;
needsRegeneration: boolean;
}
const useContainerOverview = (containerId: string): UseContainerOverviewResult => {
const [isGenerating, setIsGenerating] = useState(false);
// Query for existing overview image
const { data, loading, error, refetch } = useQuery(GET_CONTAINER_OVERVIEW, {
variables: { containerId },
errorPolicy: 'all'
});
// Mutations for overview management
const [generateOverviewMutation] = useMutation(GENERATE_CONTAINER_OVERVIEW);
const [refreshOverviewMutation] = useMutation(REFRESH_CONTAINER_OVERVIEW);
const generateOverview = useCallback(async () => {
setIsGenerating(true);
try {
const result = await generateOverviewMutation({
variables: { containerId }
});
// Update cache with new overview URL
await refetch();
return result.data.generateContainerOverview.overviewImageUrl;
} catch (error) {
console.error('Failed to generate overview image:', error);
throw error;
} finally {
setIsGenerating(false);
}
}, [containerId, generateOverviewMutation, refetch]);
const refreshOverview = useCallback(async () => {
return generateOverview(); // Force regeneration
}, [generateOverview]);
// Check if regeneration is needed based on content changes
const needsRegeneration = useMemo(() => {
if (!data?.containerOverview) return true;
const lastGenerated = new Date(data.containerOverview.generatedAt);
const lastModified = new Date(data.containerOverview.lastContentModified);
return lastModified > lastGenerated;
}, [data]);
return {
overviewImageUrl: data?.containerOverview?.overviewImageUrl || null,
loading,
error,
generateOverview,
refreshOverview,
isGenerating,
lastGenerated: data?.containerOverview?.generatedAt ? new Date(data.containerOverview.generatedAt) : null,
version: data?.containerOverview?.version || 0,
needsRegeneration
};
};
GraphQL Schema Extensions
Query Extensions
extend type ObjectInstance {
# Container overview image information
overviewImage: ContainerOverviewImage
# Check if container needs overview regeneration
needsOverviewRegeneration: Boolean!
# Spatial layout data for overview generation
spatialLayoutData: SpatialLayoutData
}
type ContainerOverviewImage {
id: ID!
containerId: ID!
publicUrl: String!
version: Int!
generatedAt: DateTime!
lastContentModified: DateTime!
objectCount: Int!
fileSize: Int!
# Generation metadata
generationDuration: Float # seconds
generationStatus: OverviewGenerationStatus!
}
type SpatialLayoutData {
containerBounds: BoundingBox!
objectPositions: [ObjectPosition!]!
spatialDensity: Float! # 0.0 to 1.0
averageObjectSize: ObjectSize!
}
type ObjectPosition {
object: ObjectInstance!
position: Position2D!
size: ObjectSize!
zIndex: Int!
relationships: [SpatialRelationshipIndicator!]!
}
type Position2D {
x: Float!
y: Float!
}
type ObjectSize {
width: Float!
height: Float!
}
type BoundingBox {
width: Float!
height: Float!
aspectRatio: Float!
}
type SpatialRelationshipIndicator {
type: SpatialPredicateType!
targetObjectId: ID!
confidence: Float! # 0.0 to 1.0
}
enum OverviewGenerationStatus {
PENDING
GENERATING
COMPLETED
FAILED
STALE
}
enum SpatialPredicateType {
CURRENT_IN
CURRENT_ON
CURRENT_LEFT_OF
CURRENT_RIGHT_OF
CURRENT_ABOVE
CURRENT_UNDER
CURRENT_NEXT_TO
CURRENT_ATTACHED_TO
NORMAL_IN
NORMAL_ON
NORMAL_LEFT_OF
NORMAL_RIGHT_OF
NORMAL_ABOVE
NORMAL_UNDER
NORMAL_NEXT_TO
NORMAL_ATTACHED_TO
}
Query Definitions
# Query for container overview data
query GetContainerOverview($containerId: ID!) {
object(id: $containerId) {
id
name
overviewImage {
id
publicUrl
version
generatedAt
lastContentModified
objectCount
generationStatus
}
spatialChildren {
id
name
mainImageUrl
type
}
needsOverviewRegeneration
}
}
# Query for spatial layout data
query GetSpatialLayoutData($containerId: ID!) {
object(id: $containerId) {
spatialLayoutData {
containerBounds {
width
height
aspectRatio
}
objectPositions {
object {
id
name
mainImageUrl
type
}
position {
x
y
}
size {
width
height
}
zIndex
relationships {
type
targetObjectId
confidence
}
}
spatialDensity
averageObjectSize {
width
height
}
}
}
}
Mutation Extensions
extend type Mutation {
# Generate container overview image
generateContainerOverview(
containerId: ID!
style: OverviewImageStyle = STANDARD
forceRegenerate: Boolean = false
): ContainerOverviewResult!
# Refresh stale overview image
refreshContainerOverview(containerId: ID!): ContainerOverviewResult!
# Delete container overview image
deleteContainerOverview(containerId: ID!): Boolean!
# Batch generate overview images
batchGenerateOverviews(
containerIds: [ID!]!
priority: GenerationPriority = NORMAL
): BatchOverviewResult!
}
type ContainerOverviewResult {
success: Boolean!
overviewImage: ContainerOverviewImage
error: String
generationDuration: Float
}
type BatchOverviewResult {
totalRequested: Int!
successful: Int!
failed: Int!
results: [ContainerOverviewResult!]!
}
enum OverviewImageStyle {
THUMBNAIL
STANDARD
DETAILED
HIGH_RESOLUTION
}
enum GenerationPriority {
LOW
NORMAL
HIGH
URGENT
}
Visual Design Specifications
Overview Image Styles and Dimensions
Thumbnail Style (168×112px)
.overview-image-thumbnail {
width: 168px;
height: 112px;
border-radius: 8px;
object-fit: contain;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
/* Loading state */
&.loading {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* Error state */
&.error {
background: #f8f9fa;
border: 2px dashed #dee2e6;
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
}
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
Detailed Style (320×240px)
.overview-image-detailed {
width: 320px;
height: 240px;
border-radius: 12px;
object-fit: contain;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.1);
/* Hover enhancements */
transition: transform 0.2s ease-in-out;
&:hover {
transform: scale(1.02);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
}
Visual Elements and Overlays
Object Count Badge
const OverviewImageBadge: React.FC<{ containerId: string }> = ({ containerId }) => {
const { data } = useQuery(GET_CONTAINER_OBJECT_COUNT, {
variables: { containerId }
});
const objectCount = data?.object?.spatialChildren?.length || 0;
return (
<div className="overview-badge">
<span className="object-count">{objectCount}</span>
<span className="object-label">items</span>
</div>
);
};
.overview-badge {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
.object-count {
font-weight: 700;
}
.object-label {
opacity: 0.9;
}
}
Spatial Density Indicator
const SpatialDensityIndicator: React.FC<{ density: number }> = ({ density }) => {
const getDensityColor = (density: number) => {
if (density < 0.3) return '#28a745'; // Green - sparse
if (density < 0.7) return '#ffc107'; // Yellow - moderate
return '#dc3545'; // Red - dense
};
const getDensityLabel = (density: number) => {
if (density < 0.3) return 'Sparse';
if (density < 0.7) return 'Moderate';
return 'Dense';
};
return (
<div className="density-indicator">
<div
className="density-bar"
style={{
width: `${density * 100}%`,
backgroundColor: getDensityColor(density)
}}
/>
<span className="density-label">{getDensityLabel(density)}</span>
</div>
);
};
Responsive Design Adaptations
Mobile Optimizations (< 768px)
@media (max-width: 767px) {
.overview-image-thumbnail {
width: 140px;
height: 93px;
}
.overview-image-detailed {
width: 280px;
height: 210px;
}
/* Simplified hover interactions for touch */
.overview-image-container {
touch-action: manipulation;
}
/* Larger touch targets */
.overview-badge {
padding: 6px 10px;
font-size: 14px;
}
}
Tablet Adaptations (768px - 1024px)
@media (min-width: 768px) and (max-width: 1024px) {
.overview-image-thumbnail {
width: 160px;
height: 107px;
}
.overview-image-detailed {
width: 300px;
height: 225px;
}
}
Performance Optimization
Image Loading and Caching
Progressive Loading Strategy
const useProgressiveImageLoading = (overviewImageUrl: string) => {
const [loadingState, setLoadingState] = useState<'placeholder' | 'lowres' | 'fullres'>('placeholder');
const [imageUrl, setImageUrl] = useState<string>('');
useEffect(() => {
if (!overviewImageUrl) return;
// Load low-resolution placeholder first
const lowResUrl = `${overviewImageUrl}?w=84&h=56&q=30`;
const fullResUrl = overviewImageUrl;
// Load low-res first
const lowResImage = new Image();
lowResImage.onload = () => {
setImageUrl(lowResUrl);
setLoadingState('lowres');
// Then load full resolution
const fullResImage = new Image();
fullResImage.onload = () => {
setImageUrl(fullResUrl);
setLoadingState('fullres');
};
fullResImage.src = fullResUrl;
};
lowResImage.src = lowResUrl;
}, [overviewImageUrl]);
return { imageUrl, loadingState };
};
Intersection Observer for Lazy Loading
const useIntersectionObserver = () => {
const [isVisible, setIsVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{
threshold: 0.1,
rootMargin: '50px' // Start loading 50px before visible
}
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, []);
return { ref, isVisible };
};
// Usage in overview component
const ContainerOverviewDisplay: React.FC<ContainerOverviewDisplayProps> = (props) => {
const { ref, isVisible } = useIntersectionObserver();
return (
<div ref={ref} className="overview-container">
{isVisible ? (
<ActualOverviewImage {...props} />
) : (
<OverviewImagePlaceholder />
)}
</div>
);
};
Memory Management
Image Cleanup Strategy
const useImageMemoryManagement = () => {
const imageCache = useRef(new Map<string, HTMLImageElement>());
const maxCacheSize = 50; // Maximum number of cached images
const loadImage = useCallback((url: string): Promise<string> => {
return new Promise((resolve, reject) => {
// Check cache first
if (imageCache.current.has(url)) {
resolve(url);
return;
}
const img = new Image();
img.onload = () => {
// Add to cache with LRU eviction
if (imageCache.current.size >= maxCacheSize) {
const firstKey = imageCache.current.keys().next().value;
imageCache.current.delete(firstKey);
}
imageCache.current.set(url, img);
resolve(url);
};
img.onerror = reject;
img.src = url;
});
}, []);
const clearCache = useCallback(() => {
imageCache.current.clear();
}, []);
return { loadImage, clearCache };
};
Error Handling and Fallbacks
Overview Generation Error States
Error Handling Component
interface OverviewImageFallbackProps {
containerId: string;
style: 'thumbnail' | 'detailed';
enableGeneration: boolean;
onGenerateClick: () => Promise<void>;
isGenerating: boolean;
fallbackToObjectImage: boolean;
error?: Error;
}
const OverviewImageFallback: React.FC<OverviewImageFallbackProps> = ({
containerId,
style,
enableGeneration,
onGenerateClick,
isGenerating,
fallbackToObjectImage,
error
}) => {
const { data: containerData } = useQuery(GET_CONTAINER_BASIC_INFO, {
variables: { containerId }
});
// Fallback to container's main image if available
if (fallbackToObjectImage && containerData?.object?.mainImageUrl) {
return (
<ObjectImage
imageUrl={containerData.object.mainImageUrl}
objectName={containerData.object.name}
className={`fallback-image ${style}`}
/>
);
}
// Show generation UI
return (
<div className={`overview-fallback ${style}`}>
<div className="fallback-content">
{isGenerating ? (
<GenerationProgress containerId={containerId} />
) : (
<GenerationPrompt
onGenerate={onGenerateClick}
enabled={enableGeneration}
error={error}
/>
)}
</div>
</div>
);
};
Generation Progress Component
const GenerationProgress: React.FC<{ containerId: string }> = ({ containerId }) => {
const [progress, setProgress] = useState(0);
const [stage, setStage] = useState('Analyzing spatial layout...');
useEffect(() => {
// Poll for generation progress
const interval = setInterval(async () => {
try {
const response = await fetch(`/api/generation-progress/${containerId}`);
const data = await response.json();
setProgress(data.progress);
setStage(data.stage);
if (data.progress >= 100) {
clearInterval(interval);
}
} catch (error) {
console.error('Failed to fetch generation progress:', error);
}
}, 1000);
return () => clearInterval(interval);
}, [containerId]);
return (
<div className="generation-progress">
<div className="progress-spinner">
<Loader2 className="animate-spin" />
</div>
<div className="progress-info">
<div className="progress-stage">{stage}</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<div className="progress-percentage">{progress}%</div>
</div>
</div>
);
};
Network Error Recovery
Retry Logic with Exponential Backoff
const useRetryableQuery = <T>(
query: DocumentNode,
variables: any,
maxRetries = 3
) => {
const [retryCount, setRetryCount] = useState(0);
const [isRetrying, setIsRetrying] = useState(false);
const { data, error, loading, refetch } = useQuery<T>(query, {
variables,
errorPolicy: 'all',
notifyOnNetworkStatusChange: true
});
const retry = useCallback(async () => {
if (retryCount >= maxRetries) return;
setIsRetrying(true);
setRetryCount(prev => prev + 1);
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, retryCount) * 1000;
setTimeout(async () => {
try {
await refetch();
} finally {
setIsRetrying(false);
}
}, delay);
}, [retryCount, maxRetries, refetch]);
const canRetry = retryCount < maxRetries && error && !loading;
return {
data,
error,
loading: loading || isRetrying,
retry,
canRetry,
retryCount
};
};
Accessibility Implementation
Screen Reader Support
Semantic HTML and ARIA Labels
const ContainerOverviewDisplay: React.FC<ContainerOverviewDisplayProps> = ({
containerId,
style,
...props
}) => {
const { data } = useQuery(GET_CONTAINER_OVERVIEW_ACCESSIBILITY, {
variables: { containerId }
});
const overviewDescription = useMemo(() => {
if (!data?.object) return '';
const { spatialChildren, spatialLayoutData } = data.object;
const objectCount = spatialChildren.length;
const density = spatialLayoutData?.spatialDensity || 0;
const densityDescription = density < 0.3 ? 'sparsely filled' :
density < 0.7 ? 'moderately filled' :
'densely packed';
const objectTypes = spatialChildren.reduce((types, child) => {
types[child.type] = (types[child.type] || 0) + 1;
return types;
}, {} as Record<string, number>);
const typeDescription = Object.entries(objectTypes)
.map(([type, count]) => `${count} ${type}${count > 1 ? 's' : ''}`)
.join(', ');
return `Container overview showing ${objectCount} items: ${typeDescription}. Container is ${densityDescription}.`;
}, [data]);
return (
<div
className="overview-image-container"
role="img"
aria-label={overviewDescription}
aria-describedby={`overview-details-${containerId}`}
>
<img
src={transformedUrl}
alt="" // Empty alt since description is in aria-label
className={getOverviewImageClasses(style)}
/>
{/* Hidden detailed description for screen readers */}
<div
id={`overview-details-${containerId}`}
className="sr-only"
>
{data?.object?.spatialChildren.map((child, index) => (
<span key={child.id}>
{child.name} ({child.type})
{index < data.object.spatialChildren.length - 1 ? ', ' : '.'}
</span>
))}
</div>
</div>
);
};
Keyboard Navigation Support
const useKeyboardNavigation = (containerId: string) => {
const [focusedObjectIndex, setFocusedObjectIndex] = useState(-1);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
setIsDetailsOpen(prev => !prev);
break;
case 'ArrowRight':
case 'ArrowDown':
event.preventDefault();
setFocusedObjectIndex(prev =>
Math.min(prev + 1, spatialChildren.length - 1)
);
break;
case 'ArrowLeft':
case 'ArrowUp':
event.preventDefault();
setFocusedObjectIndex(prev => Math.max(prev - 1, 0));
break;
case 'Escape':
event.preventDefault();
setIsDetailsOpen(false);
setFocusedObjectIndex(-1);
break;
}
}, [spatialChildren]);
return {
focusedObjectIndex,
isDetailsOpen,
handleKeyDown
};
};
High Contrast and Dark Mode Support
CSS Custom Properties for Theming
:root {
--overview-bg: #ffffff;
--overview-border: #e5e7eb;
--overview-shadow: rgba(0, 0, 0, 0.1);
--overview-text: #374151;
--overview-accent: #3b82f6;
}
[data-theme="dark"] {
--overview-bg: #1f2937;
--overview-border: #374151;
--overview-shadow: rgba(0, 0, 0, 0.3);
--overview-text: #f3f4f6;
--overview-accent: #60a5fa;
}
@media (prefers-contrast: high) {
:root {
--overview-border: #000000;
--overview-shadow: rgba(0, 0, 0, 0.8);
--overview-text: #000000;
}
[data-theme="dark"] {
--overview-border: #ffffff;
--overview-text: #ffffff;
}
}
.overview-image-container {
background: var(--overview-bg);
border: 1px solid var(--overview-border);
box-shadow: 0 2px 8px var(--overview-shadow);
color: var(--overview-text);
}
Object Selection Strategy for Large Containers
Handling Containers with Many Objects
When containers have more than 5 objects, the system needs intelligent selection of which objects to display in the overview image to ensure clarity and representative visualization.
Type Diversity Selection Algorithm
Primary Strategy: Prioritize showing different object types to give users a comprehensive understanding of container contents.
interface ObjectSelectionStrategy {
selectDisplayObjects(allObjects: ObjectInstance[], maxDisplay: number): ObjectInstance[];
getSelectionMetadata(allObjects: ObjectInstance[], selected: ObjectInstance[]): SelectionMetadata;
}
interface SelectionMetadata {
totalObjects: number;
displayedObjects: number;
hiddenObjectCount: number;
representedTypes: string[];
hiddenTypes: string[];
selectionStrategy: string;
}
class TypeDiversityStrategy implements ObjectSelectionStrategy {
selectDisplayObjects(allObjects: ObjectInstance[], maxDisplay: number = 5): ObjectInstance[] {
if (allObjects.length <= maxDisplay) {
return allObjects;
}
// Group objects by type
const objectsByType = this.groupByType(allObjects);
const typeNames = Object.keys(objectsByType);
// If we have <= maxDisplay types, select one from each type
if (typeNames.length <= maxDisplay) {
return this.selectOneFromEachType(objectsByType, typeNames);
}
// If we have more types than display slots, prioritize by various factors
return this.selectMostRepresentativeTypes(objectsByType, maxDisplay);
}
private groupByType(objects: ObjectInstance[]): Record<string, ObjectInstance[]> {
return objects.reduce((groups, obj) => {
const type = obj.type || obj.category || 'Unknown';
if (!groups[type]) {
groups[type] = [];
}
groups[type].push(obj);
return groups;
}, {} as Record<string, ObjectInstance[]>);
}
private selectOneFromEachType(
objectsByType: Record<string, ObjectInstance[]>,
typeNames: string[]
): ObjectInstance[] {
return typeNames.map(type => {
const objectsOfType = objectsByType[type];
// Within each type, prefer objects with:
// 1. Larger/more prominent objects (if size data available)
// 2. Objects with high-quality images
// 3. Objects near container edges (better visibility)
return this.selectBestRepresentativeFromType(objectsOfType);
});
}
private selectMostRepresentativeTypes(
objectsByType: Record<string, ObjectInstance[]>,
maxDisplay: number
): ObjectInstance[] {
// Score each type by representativeness
const typeScores = Object.entries(objectsByType).map(([type, objects]) => ({
type,
objects,
score: this.calculateTypeRepresentativenessScore(type, objects)
}));
// Sort by score and take top types
typeScores.sort((a, b) => b.score - a.score);
const selectedTypes = typeScores.slice(0, maxDisplay);
return selectedTypes.map(({ objects }) =>
this.selectBestRepresentativeFromType(objects)
);
}
private calculateTypeRepresentativenessScore(type: string, objects: ObjectInstance[]): number {
let score = 0;
// Factor 1: Quantity (more common types are more representative)
score += objects.length * 10;
// Factor 2: Object importance (tools, equipment are more important than misc items)
const importanceBonus = this.getTypeImportanceBonus(type);
score += importanceBonus;
// Factor 3: Image quality (prefer types with good images)
const avgImageQuality = objects.reduce((sum, obj) =>
sum + (obj.imageQualityScore || 5), 0) / objects.length;
score += avgImageQuality;
// Factor 4: Spatial prominence (prefer objects that are easily visible)
const avgSpatialProminence = objects.reduce((sum, obj) =>
sum + this.calculateSpatialProminence(obj), 0) / objects.length;
score += avgSpatialProminence;
return score;
}
private getTypeImportanceBonus(type: string): number {
const importanceMap: Record<string, number> = {
'tools': 20,
'equipment': 18,
'machinery': 16,
'instruments': 14,
'components': 12,
'materials': 8,
'supplies': 6,
'documents': 4,
'unknown': 0
};
return importanceMap[type.toLowerCase()] || importanceMap['unknown'];
}
private selectBestRepresentativeFromType(objects: ObjectInstance[]): ObjectInstance {
// Within a type, select the object that's most representative and visible
return objects.reduce((best, current) => {
const bestScore = this.calculateObjectVisibilityScore(best);
const currentScore = this.calculateObjectVisibilityScore(current);
return currentScore > bestScore ? current : best;
});
}
private calculateObjectVisibilityScore(obj: ObjectInstance): number {
let score = 0;
// Prefer objects with good images
score += (obj.imageQualityScore || 5) * 10;
// Prefer larger objects (if size data available)
if (obj.estimatedSize) {
score += obj.estimatedSize * 5;
}
// Prefer objects near container edges for better visibility
score += this.calculateSpatialProminence(obj);
// Prefer objects with clear names/descriptions
if (obj.name && obj.name.length > 3) {
score += 5;
}
return score;
}
private calculateSpatialProminence(obj: ObjectInstance): number {
// Score based on spatial position - objects near edges are more visible
// This would use the existing spatial relationship data
if (!obj.spatialPosition) return 5; // default score
// Objects near container edges get higher scores
const edgeDistance = Math.min(
obj.spatialPosition.distanceFromTop || 100,
obj.spatialPosition.distanceFromLeft || 100,
obj.spatialPosition.distanceFromRight || 100,
obj.spatialPosition.distanceFromBottom || 100
);
// Closer to edge = higher score (inverted)
return Math.max(0, 20 - edgeDistance);
}
getSelectionMetadata(allObjects: ObjectInstance[], selected: ObjectInstance[]): SelectionMetadata {
const objectsByType = this.groupByType(allObjects);
const selectedTypes = new Set(selected.map(obj => obj.type || 'Unknown'));
const allTypes = Object.keys(objectsByType);
const hiddenTypes = allTypes.filter(type => !selectedTypes.has(type));
return {
totalObjects: allObjects.length,
displayedObjects: selected.length,
hiddenObjectCount: allObjects.length - selected.length,
representedTypes: Array.from(selectedTypes),
hiddenTypes,
selectionStrategy: 'type-diversity'
};
}
}
Backend Integration
GraphQL Resolver Enhancement:
// Enhanced resolver for container overview generation
async function generateContainerOverview(containerId: string): Promise<ContainerOverviewImage> {
// Get all objects in container
const allObjects = await getContainerObjects(containerId);
// Apply selection strategy for large containers
const selectionStrategy = new TypeDiversityStrategy();
const selectedObjects = selectionStrategy.selectDisplayObjects(allObjects, 5);
const selectionMetadata = selectionStrategy.getSelectionMetadata(allObjects, selectedObjects);
// Generate layout using selected objects
const spatialLayout = await calculateSpatialLayout(selectedObjects);
// Create overview image
const overviewImageUrl = await generateOverviewImage({
objects: selectedObjects,
layout: spatialLayout,
metadata: selectionMetadata
});
return {
overviewImageUrl,
selectionMetadata,
objectCount: allObjects.length,
displayedObjectCount: selectedObjects.length
};
}
UI Indicators for Hidden Objects
“+N More” Indicator Component:
interface MoreObjectsIndicatorProps {
hiddenCount: number;
hiddenTypes: string[];
onShowAll?: () => void;
}
const MoreObjectsIndicator: React.FC<MoreObjectsIndicatorProps> = ({
hiddenCount,
hiddenTypes,
onShowAll
}) => {
if (hiddenCount === 0) return null;
const hiddenTypesText = hiddenTypes.length > 0
? `(${hiddenTypes.slice(0, 2).join(', ')}${hiddenTypes.length > 2 ? '...' : ''})`
: '';
return (
<div className="more-objects-indicator">
<button
className="more-objects-badge"
onClick={onShowAll}
title={`${hiddenCount} more objects: ${hiddenTypes.join(', ')}`}
>
<PlusIcon className="w-3 h-3" />
<span>{hiddenCount} more</span>
</button>
{hiddenTypes.length > 0 && (
<div className="hidden-types-hint">
{hiddenTypesText}
</div>
)}
</div>
);
};
Enhanced Overview Display with Indicators:
const ContainerOverviewDisplay: React.FC<ContainerOverviewDisplayProps> = ({
containerId,
style,
// ... other props
}) => {
const { overviewData, loading, error } = useContainerOverview(containerId);
return (
<div className="overview-container">
<div className="overview-image-wrapper">
<img
src={overviewData?.overviewImageUrl}
alt="Container overview"
className={getOverviewImageClasses(style)}
/>
{/* More objects indicator overlay */}
{overviewData?.selectionMetadata?.hiddenObjectCount > 0 && (
<div className="overview-indicators">
<MoreObjectsIndicator
hiddenCount={overviewData.selectionMetadata.hiddenObjectCount}
hiddenTypes={overviewData.selectionMetadata.hiddenTypes}
onShowAll={() => navigateToContainer(containerId)}
/>
</div>
)}
</div>
{/* Type diversity summary for detailed view */}
{style === 'detailed' && overviewData?.selectionMetadata && (
<div className="selection-summary">
<span className="displayed-count">
Showing {overviewData.selectionMetadata.displayedObjects} of {overviewData.selectionMetadata.totalObjects}
</span>
<span className="type-summary">
{overviewData.selectionMetadata.representedTypes.length} types shown
</span>
</div>
)}
</div>
);
};
Visual Design Guidelines
“More Objects” Indicator Styling:
.more-objects-indicator {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
}
.more-objects-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: rgba(59, 130, 246, 0.9);
color: white;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
border: none;
cursor: pointer;
backdrop-filter: blur(4px);
transition: all 0.2s ease;
}
.more-objects-badge:hover {
background: rgba(59, 130, 246, 1);
transform: scale(1.05);
}
.hidden-types-hint {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
font-size: 10px;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s ease;
}
.more-objects-badge:hover + .hidden-types-hint,
.more-objects-indicator:hover .hidden-types-hint {
opacity: 1;
}
.selection-summary {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
Performance Considerations
Selection Algorithm Optimization:
- Caching: Cache type analysis results to avoid recalculation
- Lazy Evaluation: Only run selection algorithm when container has >5 objects
- Incremental Updates: When objects are added/removed, update selection incrementally
- Background Processing: Run type analysis in background for better UX
Memory Management:
- Image Optimization: Only load high-resolution images for selected objects
- Metadata Caching: Cache selection metadata to avoid repeated computation
- Progressive Loading: Load overview images progressively based on viewport visibility
Testing Strategy
Unit Testing
Component Testing with React Testing Library
// ContainerOverviewDisplay.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { ContainerOverviewDisplay } from '../ContainerOverviewDisplay';
import { GET_CONTAINER_OVERVIEW } from '@/graphql/queries';
const mockContainerOverview = {
request: {
query: GET_CONTAINER_OVERVIEW,
variables: { containerId: 'container-123' }
},
result: {
data: {
object: {
id: 'container-123',
name: 'Test Container',
overviewImage: {
id: 'overview-456',
publicUrl: 'https://example.com/overview.png',
version: 1,
generatedAt: '2025-07-07T10:00:00Z',
objectCount: 5
}
}
}
}
};
describe('ContainerOverviewDisplay', () => {
it('renders overview image when available', async () => {
render(
<MockedProvider mocks={[mockContainerOverview]}>
<ContainerOverviewDisplay
containerId="container-123"
style="thumbnail"
/>
</MockedProvider>
);
await waitFor(() => {
expect(screen.getByRole('img')).toBeInTheDocument();
});
expect(screen.getByAltText('Container layout overview')).toBeInTheDocument();
});
it('shows fallback when overview image fails to load', async () => {
const errorMock = {
...mockContainerOverview,
error: new Error('Failed to load overview')
};
render(
<MockedProvider mocks={[errorMock]}>
<ContainerOverviewDisplay
containerId="container-123"
style="thumbnail"
fallbackToObjectImage={true}
/>
</MockedProvider>
);
await waitFor(() => {
expect(screen.getByText(/generate overview/i)).toBeInTheDocument();
});
});
it('handles generation trigger', async () => {
const generateMock = jest.fn();
render(
<MockedProvider mocks={[]}>
<ContainerOverviewDisplay
containerId="container-123"
style="thumbnail"
onGenerateClick={generateMock}
/>
</MockedProvider>
);
const generateButton = screen.getByText(/generate overview/i);
fireEvent.click(generateButton);
expect(generateMock).toHaveBeenCalledWith();
});
});
Hook Testing
// useContainerOverview.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { useContainerOverview } from '../useContainerOverview';
describe('useContainerOverview', () => {
it('fetches container overview data', async () => {
const { result } = renderHook(
() => useContainerOverview('container-123'),
{
wrapper: ({ children }) => (
<MockedProvider mocks={[mockContainerOverview]}>
{children}
</MockedProvider>
)
}
);
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.overviewImageUrl).toBe('https://example.com/overview.png');
expect(result.current.version).toBe(1);
});
it('handles generation workflow', async () => {
const { result } = renderHook(() => useContainerOverview('container-123'));
expect(result.current.isGenerating).toBe(false);
act(() => {
result.current.generateOverview();
});
expect(result.current.isGenerating).toBe(true);
await waitFor(() => {
expect(result.current.isGenerating).toBe(false);
});
});
});
Integration Testing
End-to-End Testing with Playwright
// overview-images.e2e.ts
import { test, expect } from '@playwright/test';
test.describe('Container Overview Images', () => {
test('displays overview images in spatial hierarchy', async ({ page }) => {
await page.goto('/dashboard');
// Wait for spatial hierarchy to load
await page.waitForSelector('[data-testid="spatial-hierarchy"]');
// Check for container cards with overview images
const containerCards = page.locator('[data-testid="container-card"]');
await expect(containerCards.first()).toBeVisible();
// Verify overview image is displayed
const overviewImage = containerCards.first().locator('.overview-image');
await expect(overviewImage).toBeVisible();
// Check image has loaded
await expect(overviewImage).toHaveAttribute('src');
});
test('generates overview image on demand', async ({ page }) => {
await page.goto('/dashboard');
// Find container without overview image
const containerCard = page.locator('[data-testid="container-card"]').first();
const generateButton = containerCard.locator('[data-testid="generate-overview"]');
if (await generateButton.isVisible()) {
await generateButton.click();
// Wait for generation to complete
await page.waitForSelector('.overview-image', { timeout: 30000 });
// Verify image is now displayed
const overviewImage = containerCard.locator('.overview-image');
await expect(overviewImage).toBeVisible();
}
});
test('hover preview works correctly', async ({ page }) => {
await page.goto('/dashboard');
const containerCard = page.locator('[data-testid="container-card"]').first();
const overviewImage = containerCard.locator('.overview-image');
// Hover over overview image
await overviewImage.hover();
// Check for hover preview
const hoverPreview = page.locator('[data-testid="hover-preview"]');
await expect(hoverPreview).toBeVisible();
// Verify hover preview contains object labels
const objectLabels = hoverPreview.locator('.object-label');
await expect(objectLabels.first()).toBeVisible();
});
});
Visual Regression Testing
Automated Visual Testing
// visual-tests/overview-images.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Overview Images Visual Tests', () => {
test('thumbnail style renders correctly', async ({ page }) => {
await page.goto('/test-pages/overview-images');
const thumbnailContainer = page.locator('[data-testid="thumbnail-overview"]');
await expect(thumbnailContainer).toHaveScreenshot('overview-thumbnail.png');
});
test('detailed style renders correctly', async ({ page }) => {
await page.goto('/test-pages/overview-images');
const detailedContainer = page.locator('[data-testid="detailed-overview"]');
await expect(detailedContainer).toHaveScreenshot('overview-detailed.png');
});
test('dark mode styling', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/test-pages/overview-images');
const overviewContainer = page.locator('[data-testid="overview-container"]');
await expect(overviewContainer).toHaveScreenshot('overview-dark-mode.png');
});
test('high contrast mode', async ({ page }) => {
await page.emulateMedia({
colorScheme: 'light',
forcedColors: 'active'
});
await page.goto('/test-pages/overview-images');
const overviewContainer = page.locator('[data-testid="overview-container"]');
await expect(overviewContainer).toHaveScreenshot('overview-high-contrast.png');
});
});
Security Considerations
Access Control and Privacy
Image Access Validation
// Middleware for overview image access
const validateOverviewImageAccess = async (
containerId: string,
userId: string
): Promise<boolean> => {
// Check if user has access to container
const hasContainerAccess = await checkContainerAccess(containerId, userId);
if (!hasContainerAccess) {
return false;
}
// Check organization boundaries
const containerOrg = await getContainerOrganization(containerId);
const userOrgs = await getUserOrganizations(userId);
if (!userOrgs.includes(containerOrg)) {
return false;
}
// Check privacy settings
const containerPrivacy = await getContainerPrivacySettings(containerId);
if (containerPrivacy.isPrivate && containerPrivacy.ownerId !== userId) {
return false;
}
return true;
};
RLS Policy Integration
-- Overview images inherit container access permissions
CREATE POLICY "overview_images_access" ON container_overview_images
FOR ALL USING (
EXISTS (
SELECT 1 FROM object_instances oi
WHERE oi.id = container_overview_images.container_id
AND (
oi.created_by = auth.uid()
OR oi.owner_organization_id IN (
SELECT organization_id FROM user_organization_memberships
WHERE user_id = auth.uid()
)
)
)
);
Data Sanitization and Validation
Input Validation for Overview Generation
interface OverviewGenerationRequest {
containerId: string;
style: OverviewImageStyle;
forceRegenerate: boolean;
}
const validateOverviewRequest = (request: OverviewGenerationRequest): ValidationResult => {
const errors: string[] = [];
// Validate container ID format
if (!isValidUUID(request.containerId)) {
errors.push('Invalid container ID format');
}
// Validate style enum
if (!Object.values(OverviewImageStyle).includes(request.style)) {
errors.push('Invalid overview image style');
}
// Additional validation logic...
return {
isValid: errors.length === 0,
errors
};
};
Migration Strategy
Phase 1: Core Infrastructure (Weeks 1-2)
- Backend Integration: Overview image generation endpoint and storage
- GraphQL Schema: Extended schema with overview image fields
- Database Schema: Tables for overview image metadata
- Basic Frontend Hook:
useContainerOverviewimplementation
Phase 2: UI Integration (Weeks 3-4)
- Enhanced Components: Updated
HierarchicalSpatialCardwith overview support - Overview Display Component: Core
ContainerOverviewDisplaycomponent - Error Handling: Comprehensive fallback and error states
- Basic Styling: Thumbnail and detailed view styles
Phase 3: Advanced Features (Weeks 5-6)
- Hover Previews: Detailed hover interactions
- Progressive Loading: Performance optimizations
- Accessibility: Screen reader and keyboard navigation support
- Visual Polish: Animations, transitions, and visual feedback
Phase 4: Optimization and Analytics (Weeks 7-8)
- Performance Monitoring: Image generation and loading metrics
- User Analytics: Usage patterns and effectiveness measurement
- Advanced Caching: Intelligent cache invalidation and management
- Mobile Optimization: Touch-optimized interactions
Success Metrics and KPIs
User Experience Metrics
- Navigation Efficiency: 30% reduction in unnecessary container entries
- Object Discovery Time: 25% faster object location
- User Satisfaction: >4.5/5 rating for spatial navigation
- Feature Adoption: >80% of active users utilize overview images
Technical Performance Metrics
- Generation Time: <5 seconds average for overview generation
- Cache Hit Rate: >90% for overview image requests
- Error Rate: <2% for overview image operations
- Storage Efficiency: <100MB per 1000 containers
Business Impact Metrics
- Support Ticket Reduction: 40% fewer spatial navigation support requests
- User Engagement: 25% increase in spatial dashboard usage
- Organization Efficiency: Improved workspace organization scores
- Feature ROI: Positive return on development investment within 6 months
This comprehensive requirements document provides the foundation for implementing sub-container overview images as a seamless extension of the existing Plings spatial relationships system, ensuring both technical excellence and exceptional user experience.