| Metadata | Value |
|---|---|
| Status | Archived |
| Version | 0.1.0 |
| Last Updated | 2026-09-10 |
| Author | System |
| Document Type | Archive |
title: Graph Explorer Implementation Plan - PostgreSQL + Cytoscape.js status: Draft version: 1.0 last_updated: 2025-01-27 owners:
[!NOTE] Historical evidence: results, counts, commands, and observations below belong to the original work described here. The editorial update date is not a new test or corpus verification. For present behavior, use current feature map.
PostgreSQL + Cytoscape.js Approach
This document provides a comprehensive implementation plan for building a Music-Aware Graph Explorer using PostgreSQL (as the single source of truth) and Cytoscape.js (for client-side visualization). This approach avoids the operational complexity of Neo4j while delivering the required graph visualization functionality.
Note: This is a detailed implementation plan. For the complete feature requirements and architecture decision, see- Graph Explorer Feature Requirements .
Key Design Decisions:
Estimated Timeline: 11-15 days
┌─────────────────┐
│ Admin Web UI │
│ (React + TS) │
│ │
│ GraphExplorer │
│ + Cytoscape.js │
└────────┬────────┘
│ HTTP/REST
│
┌────────▼────────┐
│ Ktor Backend │
│ │
│ GraphService │
│ + Routes │
└────────┬────────┘
│
┌────────▼────────┐
│ PostgreSQL │
│ (Single Source)│
│ │
│ Recursive CTEs │
│ + Joins │
└─────────────────┘
Node Types:
Krithi, Composer, Raga, Tala, Deity, Kshetram (Temple), TagRelationships:
(Krithi)-[:COMPOSED_BY]->(Composer)(Krithi)-[:IN_RAGA]->(Raga) (via krithi_ragas)(Krithi)-[:IN_TALA]->(Tala)(Krithi)-[:ADDRESSES]->(Deity)(Krithi)-[:AT_KSHETRAM]->(Kshetram) (via temple)(Krithi)-[:HAS_TAG]->(Tag) (via krithi_tags)(Raga)-[:JANYA_OF]->(Raga) (via parent_raga_id)(Deity)-[:AT_KSHETRAM]->(Kshetram) (via temple’s primary_deity_id)Node Properties:
id: UUID (matches PostgreSQL)label: String (display name)type: String (entity type)properties: Map<String, Any?> (additional metadata)File: modules/shared/domain/src/commonMain/kotlin/com/sangita/grantha/shared/domain/model/GraphDtos.kt
package com.sangita.grantha.shared.domain.model
import kotlinx.serialization.Serializable
@Serializable data class GraphNodeDto( val id: String, val label: String, val type: String, val properties: Map<String, String> = emptyMap() )
@Serializable data class GraphEdgeDto( val id: String, val source: String, val target: String, val type: String, val properties: Map<String, String> = emptyMap() )
@Serializable
data class GraphResponseDto(
val nodes: List
@Serializable data class GraphSearchResultDto( val id: String, val label: String, val type: String )
@Serializable
enum class GraphEntityMode {
KRITHI,
COMPOSER,
RAGA,
TALA,
DEITY,
KSHETRAM,
TAG
}
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/GraphService.kt
Responsibilities:
Key Methods: class GraphService(private val dal: SangitaDal) { suspend fun getNeighborhood( mode: GraphEntityMode, id: Uuid, depth: Int ): GraphResponseDto
suspend fun search(
mode: GraphEntityMode,
query: String
): List<GraphSearchResultDto>
```kotlin
suspend fun getPresetGraph(
mode: GraphEntityMode,
query: String?,
depth: Int
): GraphResponseDto } ```
File: modules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/repositories/GraphRepository.kt
Responsibilities:
Query Strategy:
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/graphRoutes.kt
Endpoints: fun Route.graphRoutes(graphService: GraphService) { route(“/v1/admin/graph”) { // GET /v1/admin/graph/neighborhood?mode=Krithi&id={uuid}&depth=2 get(“/neighborhood”) { … }
// GET /v1/admin/graph/search?mode=Raga&q=shankarabharanam
get("/search") { ... }
```text
// GET /v1/admin/graph/preset?mode=Raga&q=shankarabharanam&depth=2
get("/preset") { ... }
} } ```
Authentication: Uses existing authenticate("admin-auth") middleware
Update: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/plugins/Routing.kt
fun Application.configureRouting(
// ... existing services
graphService: GraphService,
) {
// ... existing routes
authenticate("admin-auth") {
// ... existing routes
graphRoutes(graphService)
}
}
File: modules/frontend/sangita-admin-web/src/api/graphApi.ts
import { request } from ‘./client’;
export interface GraphNode { id: string; label: string; type: string; properties: Record<string, string>; }
export interface GraphEdge { id: string; source: string; target: string; type: string; properties: Record<string, string>; }
export interface GraphResponse { nodes: GraphNode[]; edges: GraphEdge[]; }
export interface GraphSearchResult { id: string; label: string; type: string; }
export type GraphEntityMode = | ‘KRITHI’ | ‘COMPOSER’ | ‘RAGA’ | ‘TALA’ | ‘DEITY’ | ‘KSHETRAM’ | ‘TAG’;
export const graphApi = {
search: (mode: GraphEntityMode, q: string): Promise<GraphSearchResult[]> => {
const params = new URLSearchParams({ mode, q });
return request<GraphSearchResult[]>(/admin/graph/search?${params});
},
neighborhood: (
mode: GraphEntityMode,
id: string,
depth: number
): Promise<GraphResponse> => {
const params = new URLSearchParams({
mode,
id,
depth: depth.toString()
});
return request<GraphResponse>(`/admin/graph/neighborhood?${params}`);
},
```text
preset: (
mode: GraphEntityMode,
q: string | null,
depth: number
): Promise<GraphResponse> => {
const params = new URLSearchParams({
mode,
depth: depth.toString()
});
if (q) params.append('q', q);
return request<GraphResponse>(`/admin/graph/preset?${params}`);
}, }; ```
File: modules/frontend/sangita-admin-web/src/pages/GraphExplorer.tsx
Layout:
┌─────────────────────────────────────────────────┐
│ [Mode ▼] [Search...] [Depth: 1─●─3] [Load] │
├──────────────────────────┬──────────────────────┤
│ │ │
│ Cytoscape Canvas │ Details Panel │
│ (80% width) │ (20% width) │
│ │ │
│ [Graph Visualization] │ [Node Properties] │
│ │ [Quick Actions] │
│ │ │
└──────────────────────────┴──────────────────────┘
Key Features:
**Dependencies:**
{
"cytoscape": "^3.27.0",
"cytoscape-fcose": "^2.2.0"
}
Component: modules/frontend/sangita-admin-web/src/components/graph/CytoscapeGraph.tsx
Node Shapes:
round-rectangleellipsehexagondiamondoctagonrectangletriangleLayout: Use fcose layout algorithm with reasonable defaults
Styling: Minimal, neutral colors using Tailwind CSS
File: modules/frontend/sangita-admin-web/src/pages/GraphExplorer.tsx
State:
Interactions:
Update: modules/frontend/sangita-admin-web/src/App.tsx
import GraphExplorer from ‘./pages/GraphExplorer’;
// In Routes:
<Route path="/graph-explorer" element={<GraphExplorer />} />
Update Sidebar: Add navigation link to Graph Explorer
Simple Case: Direct relationships from a single entity
**Example: Krithi → Related Entities**
-- Get Krithi and direct relationships
WITH krithi_node AS (
SELECT id, title as label, 'KRITHI' as type
FROM krithis
WHERE id = $id
),
composer_edge AS (
SELECT
k.id as source,
c.id as target,
'COMPOSED_BY' as rel_type
FROM krithis k
JOIN composers c ON k.composer_id = c.id
WHERE k.id = $id
),
raga_edges AS (
SELECT
kr.krithi_id as source,
r.id as target,
'IN_RAGA' as rel_type
FROM krithi_ragas kr
JOIN ragas r ON kr.raga_id = r.id
WHERE kr.krithi_id = $id
),
-- ... other relationships
all_nodes AS (
SELECT id, label, type FROM krithi_node
UNION
SELECT id, name as label, 'COMPOSER' FROM composers WHERE id IN (SELECT target FROM composer_edge)
UNION
SELECT id, name as label, 'RAGA' FROM ragas WHERE id IN (SELECT target FROM raga_edges)
-- ... other node types
),
all_edges AS (
SELECT source, target, rel_type FROM composer_edge
UNION
SELECT source, target, rel_type FROM raga_edges
-- ... other edges
)
SELECT * FROM all_nodes, all_edges;
Complex Case: Multi-hop traversal using recursive CTEs
Example: Raga → Krithis → Composers (Depth 2) WITH RECURSIVE graph_path AS ( – Base: Start node SELECT r.id, r.name as label, ‘RAGA’ as type, 0 as depth, r.id::text as path FROM ragas r WHERE r.id = $id
UNION ALL
-- Depth 1: Raga → Krithis
SELECT
k.id,
k.title as label,
'KRITHI' as type,
1 as depth,
gp.path || '->' || k.id::text
FROM graph_path gp
JOIN krithi_ragas kr ON kr.raga_id = gp.id
JOIN krithis k ON k.id = kr.krithi_id
WHERE gp.depth = 0
AND gp.type = 'RAGA'
AND k.workflow_state = 'published' -- Filter published only if needed
UNION ALL
```sql
-- Depth 2: Krithis → Composers
SELECT
c.id,
c.name as label,
'COMPOSER' as type,
2 as depth,
gp.path || '->' || c.id::text
FROM graph_path gp
JOIN krithis k ON k.id = gp.id
JOIN composers c ON c.id = k.composer_id
WHERE gp.depth = 1
AND gp.type = 'KRITHI' ) SELECT DISTINCT id, label, type, depth FROM graph_path WHERE depth <= $max_depth; ```
Note: This approach works but may be complex. Alternative: Use application-level expansion (fetch depth 1, then fetch neighbors of those nodes).
Strategy: Use existing normalized indexes
**Example: Raga Search**
SELECT id, name as label, 'RAGA' as type
FROM ragas
WHERE name_normalized LIKE '%' || lower($query) || '%'
ORDER BY name
LIMIT 20;
**Example: Krithi Search**
SELECT id, title as label, 'KRITHI' as type
FROM krithis
WHERE title_normalized LIKE '%' || lower($query) || '%'
AND workflow_state = 'published' -- Or allow all for admin
ORDER BY title
LIMIT 20;
Strategy: Mode-specific curated queries
Example: Raga Preset (Janya chain + Krithis) – Get raga and its janya hierarchy WITH RECURSIVE raga_hierarchy AS ( SELECT id, name, parent_raga_id, 0 as level FROM ragas WHERE id = $id OR name_normalized LIKE ‘%’ || lower($query) || ‘%’
UNION ALL
```sql
SELECT r.id, r.name, r.parent_raga_id, rh.level + 1
FROM ragas r
JOIN raga_hierarchy rh ON r.parent_raga_id = rh.id
WHERE rh.level < 3 ), -- Get krithis in these ragas krithi_connections AS (
SELECT DISTINCT kr.krithi_id, kr.raga_id
FROM krithi_ragas kr
JOIN raga_hierarchy rh ON kr.raga_id = rh.id ) -- Combine nodes and edges SELECT ...; ```
Indexes (should already exist):
krithis.id (primary key)krithis.title_normalized (for search)krithi_ragas.krithi_id, krithi_ragas.raga_id (for joins)ragas.name_normalized (for search)ragas.parent_raga_id (for janya hierarchy)Performance Targets:
GraphDtos.kt in shared domainGraphEntityMode enumGraphService.kt skeletonGraphRepository.kt skeletongraphRoutes.kt/neighborhood endpoint/search endpointgraphApi.tsCytoscapeGraph.tsx componentGraphExplorer.tsx pagedocs/architecture/graph-explorer.mdUnit Tests:
GraphService methods with mocked DALIntegration Tests:
Test Data:
Component Tests:
E2E Tests (Optional):
File: application_documentation/02-architecture/graph-explorer.md
Contents:
Update: application_documentation/03-api/api-contract.md
Add:
File: application_documentation/05-frontend/admin-web/graph-explorer-user-guide.md
Contents:
File: application_documentation/08-operations/runbooks/graph-explorer-dev.md
Contents:
modules/backend/
├── api/
│ ├── routes/
│ │ └── graphRoutes.kt # NEW
│ ├── services/
│ │ └── GraphService.kt # NEW
│ └── models/
│ └── GraphModels.kt # NEW (if needed)
├── dal/
│ └── repositories/
│ └── GraphRepository.kt # NEW
└── shared/
└── domain/
└── model/
└── GraphDtos.kt # NEW
modules/frontend/sangita-admin-web/src/
├── api/
│ └── graphApi.ts # NEW
├── pages/
│ └── GraphExplorer.tsx # NEW
├── components/
│ └── graph/
│ ├── CytoscapeGraph.tsx # NEW
│ └── GraphDetailsPanel.tsx # NEW
└── types.ts # UPDATE (add graph types)
Backend:
**Frontend:**
{
"dependencies": {
"cytoscape": "^3.27.0",
"cytoscape-fcose": "^2.2.0"
}
}
No new configuration needed (uses existing database connection)
Risk: Recursive CTEs may be slow for depth 3 Mitigation:
Risk: Complex queries may be hard to maintain Mitigation:
Risk: Large graphs may cause performance issues Mitigation:
– Get Krithi with all direct relationships SELECT ‘node’ as element_type, k.id::text as id, k.title as label, ‘KRITHI’ as type, jsonb_build_object( ‘workflow_state’, k.workflow_state, ‘musical_form’, k.musical_form ) as properties FROM krithis k WHERE k.id = $id
UNION ALL
SELECT ‘node’ as element_type, c.id::text, c.name, ‘COMPOSER’, jsonb_build_object(‘birth_year’, c.birth_year) FROM krithis k JOIN composers c ON k.composer_id = c.id WHERE k.id = $id
UNION ALL
SELECT
'edge' as element_type,
k.id::text || '-COMPOSED_BY->' || c.id::text,
k.id::text,
c.id::text,
'COMPOSED_BY',
'{}'::jsonb
FROM krithis k
JOIN composers c ON k.composer_id = c.id
WHERE k.id = $id;
WITH RECURSIVE raga_tree AS ( SELECT id, name, parent_raga_id, 0 as depth FROM ragas WHERE id = $id
UNION ALL
```sql
SELECT r.id, r.name, r.parent_raga_id, rt.depth + 1
FROM ragas r
JOIN raga_tree rt ON r.parent_raga_id = rt.id
WHERE rt.depth < 5 ) SELECT * FROM raga_tree; ```
import cytoscape from ‘cytoscape’; import fcose from ‘cytoscape-fcose’;
cytoscape.use(fcose);
const cy = cytoscape({
container: document.getElementById('cy'),
elements: {
nodes: nodes,
edges: edges
},
style: [
{
selector: 'node',
style: {
'label': 'data(label)',
'width': 60,
'height': 60,
'text-valign': 'center',
'text-halign': 'center',
'font-size': '12px',
'background-color': '#e8e8e8',
'border-width': 2,
'border-color': '#888'
}
},
{
selector: 'node[type="KRITHI"]',
style: {
'shape': 'round-rectangle',
'background-color': '#4a90e2'
}
},
{
selector: 'node[type="RAGA"]',
style: {
'shape': 'hexagon',
'background-color': '#7b68ee'
}
},
// ... other node types
{
selector: 'edge',
style: {
'width': 2,
'line-color': '#999',
'target-arrow-color': '#999',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'label': 'data(type)',
'font-size': '10px'
}
}
],
layout: {
name: 'fcose',
quality: 'default',
randomize: false,
animate: true,
animationDuration: 1000,
fit: true,
padding: 30
}
});
Document Status: Ready for implementation
Next Steps: