glam/docs/plan/frontend/01-architecture.md
kempersc fa5680f0dd Add initial versions of custodian hub UML diagrams in Mermaid and PlantUML formats
- Introduced custodian_hub_v3.mmd, custodian_hub_v4_final.mmd, and custodian_hub_v5_FINAL.mmd for Mermaid representation.
- Created custodian_hub_FINAL.puml and custodian_hub_v3.puml for PlantUML representation.
- Defined entities such as CustodianReconstruction, Identifier, TimeSpan, Agent, CustodianName, CustodianObservation, ReconstructionActivity, Appellation, ConfidenceMeasure, Custodian, LanguageCode, and SourceDocument.
- Established relationships and associations between entities, including temporal extents, observations, and reconstruction activities.
- Incorporated enumerations for various types, statuses, and classifications relevant to custodians and their activities.
2025-11-22 14:33:51 +01:00

16 KiB

Frontend Architecture Design

System Overview

The Heritage Custodian Frontend follows a layered architecture pattern with clear separation of concerns, building upon proven patterns from the example_ld project while extending them for heritage institution data visualization needs.

Architecture Layers

1. Presentation Layer

┌─────────────────────────────────────────────┐
│            React Components                 │
├─────────────────────────────────────────────┤
│  Pages │ Layouts │ Features │ Common       │
├─────────────────────────────────────────────┤
│         Component Library (UI Kit)          │
└─────────────────────────────────────────────┘

Components Structure:

src/
├── components/
│   ├── common/
│   │   ├── Header.tsx
│   │   ├── Footer.tsx
│   │   ├── Navigation.tsx
│   │   └── LoadingSpinner.tsx
│   ├── rdf/
│   │   ├── RDFViewer.tsx
│   │   ├── TripleTable.tsx
│   │   ├── GraphExplorer.tsx
│   │   └── FormatSelector.tsx
│   ├── visualization/
│   │   ├── NetworkGraph.tsx
│   │   ├── Timeline.tsx
│   │   ├── GeoMap.tsx
│   │   └── UMLDiagram.tsx
│   ├── query/
│   │   ├── SPARQLEditor.tsx
│   │   ├── QueryBuilder.tsx
│   │   ├── ResultsView.tsx
│   │   └── SavedQueries.tsx
│   └── custodian/
│       ├── CustodianCard.tsx
│       ├── CustodianDetail.tsx
│       ├── CustodianList.tsx
│       └── CustodianSearch.tsx

2. State Management Layer

┌─────────────────────────────────────────────┐
│            Zustand Stores                   │
├─────────────────────────────────────────────┤
│  RDF Store │ Query Store │ UI Store        │
├─────────────────────────────────────────────┤
│         React Query (Data Fetching)         │
└─────────────────────────────────────────────┘

Store Architecture:

// stores/rdfStore.ts
interface RDFStore {
  triples: Triple[];
  graphs: Graph[];
  currentFormat: RDFFormat;
  loadRDF: (url: string, format: RDFFormat) => Promise<void>;
  parseRDF: (content: string, format: RDFFormat) => void;
  clearStore: () => void;
}

// stores/queryStore.ts
interface QueryStore {
  queries: SavedQuery[];
  currentQuery: string;
  results: QueryResult[];
  isExecuting: boolean;
  executeQuery: (sparql: string) => Promise<void>;
  saveQuery: (query: SavedQuery) => void;
}

// stores/uiStore.ts
interface UIStore {
  theme: 'light' | 'dark';
  sidebarOpen: boolean;
  activeView: ViewType;
  notifications: Notification[];
  toggleSidebar: () => void;
  setActiveView: (view: ViewType) => void;
}

3. Data Processing Layer

┌─────────────────────────────────────────────┐
│           Data Processors                   │
├─────────────────────────────────────────────┤
│ RDF Parser │ SPARQL Engine │ Transformer   │
├─────────────────────────────────────────────┤
│         Data Validation & Sanitization      │
└─────────────────────────────────────────────┘

Processing Modules:

// lib/rdf/parser.ts
export class RDFParser {
  async parse(content: string, format: RDFFormat): Promise<Triple[]>
  async serialize(triples: Triple[], format: RDFFormat): Promise<string>
  validateRDF(content: string): ValidationResult
}

// lib/sparql/engine.ts
export class SPARQLEngine {
  constructor(store: N3.Store)
  execute(query: string): Promise<QueryResult>
  prepare(query: string): PreparedQuery
  explain(query: string): QueryPlan
}

// lib/transform/custodian.ts
export class CustodianTransformer {
  fromRDF(triples: Triple[]): CustodianObservation[]
  toRDF(custodian: CustodianObservation): Triple[]
  toGeoJSON(custodians: CustodianObservation[]): FeatureCollection
  toCSV(custodians: CustodianObservation[]): string
}

4. Visualization Layer

┌─────────────────────────────────────────────┐
│         D3.js Visualizations                │
├─────────────────────────────────────────────┤
│ Force Graph │ Tree │ Timeline │ Geo Map    │
├─────────────────────────────────────────────┤
│         Visualization Utilities             │
└─────────────────────────────────────────────┘

Visualization Services:

// lib/viz/d3/uml.ts
export class UMLVisualizer {
  renderClassDiagram(schema: LinkMLSchema): D3Selection
  renderRelationships(relations: Relationship[]): D3Selection
  animateTransition(from: Diagram, to: Diagram): void
}

// lib/viz/d3/network.ts
export class NetworkVisualizer {
  renderForceGraph(nodes: Node[], edges: Edge[]): D3Selection
  applyLayout(layout: LayoutType): void
  enableInteraction(callbacks: InteractionCallbacks): void
}

// lib/viz/maps/geo.ts
export class GeoVisualizer {
  renderMap(center: [number, number], zoom: number): LeafletMap
  addCustodianMarkers(custodians: CustodianObservation[]): void
  createChoropleth(data: GeoStats): void
}

5. API Communication Layer

┌─────────────────────────────────────────────┐
│            API Clients                      │
├─────────────────────────────────────────────┤
│ GraphQL │ REST │ WebSocket │ SPARQL        │
├─────────────────────────────────────────────┤
│         Request/Response Handling           │
└─────────────────────────────────────────────┘

API Services:

// lib/api/graphql.ts
export class GraphQLClient {
  async query<T>(query: string, variables?: Variables): Promise<T>
  async mutation<T>(mutation: string, variables?: Variables): Promise<T>
  subscribe<T>(subscription: string): Observable<T>
}

// lib/api/sparql.ts
export class SPARQLClient {
  constructor(endpoint: string)
  async query(sparql: string): Promise<ResultSet>
  async update(sparql: string): Promise<void>
  async construct(sparql: string): Promise<Triple[]>
}

// lib/api/websocket.ts
export class WebSocketClient {
  connect(url: string): void
  on(event: string, handler: EventHandler): void
  emit(event: string, data: any): void
  disconnect(): void
}

Data Flow Architecture

1. RDF Loading Flow

User selects RDF file
    ↓
FileReader API reads content
    ↓
RDFParser validates and parses
    ↓
Triples stored in N3.Store
    ↓
UI components re-render
    ↓
Visualizations update

2. SPARQL Query Flow

User writes SPARQL query
    ↓
Query syntax validation
    ↓
SPARQLEngine execution
    ↓
Results transformation
    ↓
Visualization selection
    ↓
D3.js rendering

3. UML Diagram Rendering Flow

Load Mermaid/PlantUML file
    ↓
Parse diagram syntax
    ↓
Extract nodes and relationships
    ↓
Transform to D3.js data structure
    ↓
Apply force-directed layout
    ↓
Render interactive SVG
    ↓
Enable pan/zoom/click interactions

Component Communication

Event-Driven Architecture

// Event bus for decoupled communication
class EventBus {
  private events: Map<string, EventHandler[]> = new Map();
  
  on(event: string, handler: EventHandler): void {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    this.events.get(event)!.push(handler);
  }
  
  emit(event: string, data?: any): void {
    const handlers = this.events.get(event);
    handlers?.forEach(handler => handler(data));
  }
  
  off(event: string, handler: EventHandler): void {
    const handlers = this.events.get(event);
    if (handlers) {
      const index = handlers.indexOf(handler);
      if (index > -1) {
        handlers.splice(index, 1);
      }
    }
  }
}

// Usage example
eventBus.on('custodian:selected', (custodian) => {
  // Update detail view
  // Highlight on map
  // Show in timeline
});

eventBus.emit('custodian:selected', selectedCustodian);

Props Drilling Prevention

Using React Context for cross-cutting concerns:

// contexts/RDFContext.tsx
export const RDFContext = createContext<RDFContextValue>({
  store: new N3.Store(),
  format: 'turtle',
  loading: false,
});

// contexts/ThemeContext.tsx
export const ThemeContext = createContext<ThemeContextValue>({
  theme: 'light',
  toggleTheme: () => {},
});

// contexts/AuthContext.tsx
export const AuthContext = createContext<AuthContextValue>({
  user: null,
  login: async () => {},
  logout: () => {},
});

Performance Optimization

1. Code Splitting

// Lazy load heavy components
const NetworkGraph = lazy(() => import('./components/visualization/NetworkGraph'));
const SPARQLEditor = lazy(() => import('./components/query/SPARQLEditor'));
const UMLDiagram = lazy(() => import('./components/visualization/UMLDiagram'));

// Route-based splitting
const routes = [
  {
    path: '/explore',
    element: <Suspense fallback={<Loading />}><ExplorePage /></Suspense>
  },
  {
    path: '/query',
    element: <Suspense fallback={<Loading />}><QueryPage /></Suspense>
  }
];

2. Virtual Scrolling

// For large lists of custodians
import { useVirtualizer } from '@tanstack/react-virtual';

function CustodianList({ custodians }: { custodians: CustodianObservation[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  
  const virtualizer = useVirtualizer({
    count: custodians.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 120, // Estimated item height
    overscan: 5,
  });
  
  return (
    <div ref={parentRef} className="h-screen overflow-auto">
      <div style={{ height: `${virtualizer.getTotalSize()}px` }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <CustodianCard custodian={custodians[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

3. Web Workers

// workers/rdfParser.worker.ts
import { parse } from 'n3';

self.addEventListener('message', async (event) => {
  const { content, format } = event.data;
  
  try {
    const triples = await parseRDF(content, format);
    self.postMessage({ type: 'success', triples });
  } catch (error) {
    self.postMessage({ type: 'error', error: error.message });
  }
});

// Usage in component
const parseInWorker = (content: string, format: string) => {
  return new Promise((resolve, reject) => {
    const worker = new Worker('/workers/rdfParser.worker.js');
    
    worker.onmessage = (event) => {
      if (event.data.type === 'success') {
        resolve(event.data.triples);
      } else {
        reject(new Error(event.data.error));
      }
      worker.terminate();
    };
    
    worker.postMessage({ content, format });
  });
};

Security Considerations

1. Content Security Policy

// helmet configuration
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"], // For D3.js
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://query.wikidata.org"],
    },
  },
}));

2. Input Sanitization

// Sanitize SPARQL queries
function sanitizeSPARQL(query: string): string {
  // Remove comments
  query = query.replace(/#.*$/gm, '');
  
  // Check for injection patterns
  const dangerous = [
    /DROP\s+GRAPH/i,
    /CLEAR\s+GRAPH/i,
    /DELETE\s+WHERE/i,
  ];
  
  for (const pattern of dangerous) {
    if (pattern.test(query)) {
      throw new Error('Potentially dangerous SPARQL query detected');
    }
  }
  
  return query;
}

Deployment Architecture

Docker Configuration

# Multi-stage build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Environment Configuration

// config/env.ts
export const config = {
  api: {
    baseUrl: process.env.VITE_API_URL || 'http://localhost:3000',
    timeout: parseInt(process.env.VITE_API_TIMEOUT || '30000'),
  },
  sparql: {
    endpoint: process.env.VITE_SPARQL_ENDPOINT || 'http://localhost:3030/dataset',
  },
  maps: {
    tileUrl: process.env.VITE_MAP_TILES || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
  },
  features: {
    enableWebSockets: process.env.VITE_ENABLE_WS === 'true',
    enableOffline: process.env.VITE_ENABLE_OFFLINE === 'true',
  },
};

Testing Architecture

1. Unit Testing

// Component testing with React Testing Library
describe('CustodianCard', () => {
  it('renders custodian information correctly', () => {
    const custodian = mockCustodianObservation();
    
    render(<CustodianCard custodian={custodian} />);
    
    expect(screen.getByText(custodian.name)).toBeInTheDocument();
    expect(screen.getByText(custodian.institution_type)).toBeInTheDocument();
  });
});

2. Integration Testing

// API integration tests
describe('SPARQL API', () => {
  it('executes queries and returns results', async () => {
    const query = 'SELECT * WHERE { ?s ?p ?o } LIMIT 10';
    
    const results = await sparqlClient.query(query);
    
    expect(results).toHaveLength(10);
    expect(results[0]).toHaveProperty('s');
    expect(results[0]).toHaveProperty('p');
    expect(results[0]).toHaveProperty('o');
  });
});

3. E2E Testing

// Playwright E2E tests
test('complete user journey', async ({ page }) => {
  await page.goto('/');
  
  // Load RDF file
  await page.click('[data-testid="load-rdf"]');
  await page.setInputFiles('input[type="file"]', 'fixtures/custodian.ttl');
  
  // Navigate to query page
  await page.click('a[href="/query"]');
  
  // Execute SPARQL query
  await page.fill('[data-testid="sparql-editor"]', 'SELECT * WHERE { ?s a :CustodianObservation }');
  await page.click('[data-testid="execute-query"]');
  
  // Verify results
  await expect(page.locator('[data-testid="results-table"]')).toBeVisible();
  await expect(page.locator('tr')).toHaveCount(11); // Header + 10 results
});