Ukweli / web /src /views /GraphView.vue
Benard John
Initial commit
42a0d15
Raw
History Blame Contribute Delete
7.88 kB
<template>
<div class="graph-view">
<div class="graph-header">
<h1>Knowledge Graph</h1>
<p>Explore relationships between auditees, findings, recommendations, and legal frameworks.</p>
</div>
<!-- Query Panel -->
<div class="query-panel">
<n-card title="Graph Queries" :bordered="false">
<div class="query-grid">
<n-button
v-for="query in presetQueries"
:key="query.id"
quaternary
class="query-btn"
@click="runQuery(query.cypher)"
>
<template #icon>
<n-icon><search-outline /></n-icon>
</template>
{{ query.label }}
</n-button>
</div>
<n-divider />
<n-input
v-model:value="customQuery"
type="textarea"
placeholder="Enter Cypher query..."
:autosize="{ minRows: 3, maxRows: 6 }"
class="cypher-input"
/>
<div class="query-actions">
<n-button type="primary" @click="runQuery(customQuery)" :loading="isLoading">
<template #icon><n-icon><play-outline /></n-icon></template>
Run Query
</n-button>
<n-button quaternary @click="customQuery = ''">
Clear
</n-button>
</div>
</n-card>
</div>
<!-- Graph Visualization -->
<div class="graph-visualization">
<n-card :bordered="false" class="graph-card">
<div v-if="isLoading" class="graph-loading">
<n-spin size="large" />
<p>Loading graph data...</p>
</div>
<div v-else-if="graphData.nodes.length === 0" class="graph-empty">
<n-empty description="Run a query to visualize the knowledge graph">
<template #icon>
<n-icon size="48"><share-social-outline /></n-icon>
</template>
</n-empty>
</div>
<div v-else ref="graphContainer" class="graph-container">
<v-chart class="chart" :option="chartOption" autoresize />
</div>
</n-card>
</div>
<!-- Results Panel -->
<div v-if="queryResults.length > 0" class="results-panel">
<n-card title="Query Results" :bordered="false">
<n-data-table
:columns="resultColumns"
:data="queryResults"
:pagination="{ pageSize: 10 }"
size="small"
/>
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { GraphChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { api } from '@/api/client'
import type { GraphData, GraphNode, GraphEdge } from '@/types'
import { SearchOutline, PlayOutline, ShareSocialOutline } from '@vicons/ionicons5'
use([CanvasRenderer, GraphChart, TooltipComponent, LegendComponent])
const isLoading = ref(false)
const customQuery = ref('')
const graphData = ref<GraphData>({ nodes: [], edges: [] })
const queryResults = ref<any[]>([])
const presetQueries = [
{
id: 'pending-recs',
label: 'Pending Recommendations by Ministry',
cypher: 'MATCH (a:Auditee)<-[:AUDITS]-(r:AuditReport)-[:CONTAINS]->(f:Finding)-[:RECOMMENDS]->(rec:Recommendation) WHERE rec.status = "Pending" RETURN a.name, rec.description, rec.deadline ORDER BY rec.deadline',
},
{
id: 'county-findings',
label: 'Findings by County (2023/24)',
cypher: 'MATCH (a:Auditee)-[:AUDITS]-(r:AuditReport)-[:CONTAINS]->(f:Finding) WHERE r.fiscal_year = "2023/24" AND a.type = "county" RETURN a.name, count(f) as findings ORDER BY findings DESC',
},
{
id: 'legal-refs',
label: 'Most Cited Legal Provisions',
cypher: 'MATCH (f:Finding)-[:CITES_LAW]->(l:LegalProvision) RETURN l.section, count(f) as citations ORDER BY citations DESC LIMIT 10',
},
{
id: 'adverse-opinions',
label: 'Adverse/Disclaimer Opinions',
cypher: 'MATCH (r:AuditReport) WHERE r.audit_opinion IN ["adverse", "disclaimer"] RETURN r.title, r.auditee, r.fiscal_year, r.audit_opinion',
},
{
id: 'amount-entities',
label: 'Largest Financial Irregularities',
cypher: 'MATCH (f:Finding)-[:INVOLVES_AMOUNT]->(a:FinancialAmount) RETURN f.description, a.value, a.currency ORDER BY a.value DESC LIMIT 20',
},
]
const chartOption = computed(() => ({
tooltip: {
trigger: 'item',
formatter: (params: any) => {
if (params.dataType === 'node') {
return `<strong>${params.data.label}</strong><br/>Type: ${params.data.type}`
}
return `${params.data.source} -> ${params.data.target}`
},
},
legend: {
data: ['Auditee', 'Report', 'Finding', 'Legal', 'Amount'],
bottom: 0,
},
series: [
{
type: 'graph',
layout: 'force',
data: graphData.value.nodes.map((n: GraphNode) => ({
id: n.id,
name: n.label,
label: n.label,
type: n.type,
symbolSize: n.type === 'auditee' ? 40 : n.type === 'report' ? 30 : 20,
itemStyle: {
color: getNodeColor(n.type),
},
category: n.type,
})),
links: graphData.value.edges.map((e: GraphEdge) => ({
source: e.source,
target: e.target,
label: {
show: true,
formatter: e.type,
},
})),
categories: [
{ name: 'Auditee' },
{ name: 'Report' },
{ name: 'Finding' },
{ name: 'Legal' },
{ name: 'Amount' },
],
roam: true,
label: {
show: true,
position: 'right',
fontSize: 12,
},
force: {
repulsion: 300,
edgeLength: 100,
},
emphasis: {
focus: 'adjacency',
lineStyle: {
width: 4,
},
},
},
],
}))
const resultColumns = [
{ title: 'Property', key: 'property' },
{ title: 'Value', key: 'value' },
]
const getNodeColor = (type: string) => {
const colors: Record<string, string> = {
auditee: '#1a5fb4',
report: '#26a269',
finding: '#e5a50a',
legal: '#c01c28',
amount: '#9b59b6',
}
return colors[type] || '#7f8c8d'
}
const runQuery = async (cypher: string) => {
if (!cypher.trim()) return
isLoading.value = true
try {
const response = await api.graphQuery(cypher)
graphData.value = response
queryResults.value = response.nodes.map((n: GraphNode) => ({
property: n.label,
value: JSON.stringify(n.properties),
}))
} catch (error) {
console.error('Graph query failed:', error)
} finally {
isLoading.value = false
}
}
</script>
<style scoped lang="scss">
.graph-view {
padding-bottom: 48px;
}
.graph-header {
margin-bottom: 32px;
h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
p {
color: var(--text-secondary);
font-size: 15px;
}
}
.query-panel {
margin-bottom: 24px;
}
.query-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.query-btn {
justify-content: flex-start;
text-align: left;
height: auto;
padding: 8px 12px;
font-size: 13px;
}
.cypher-input {
font-family: 'SF Mono', Monaco, monospace;
font-size: 13px;
}
.query-actions {
display: flex;
gap: 12px;
margin-top: 12px;
}
.graph-visualization {
margin-bottom: 24px;
}
.graph-card {
min-height: 500px;
}
.graph-loading,
.graph-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 500px;
gap: 16px;
}
.graph-container {
height: 500px;
}
.chart {
width: 100%;
height: 100%;
}
.results-panel {
margin-top: 24px;
}
@media (max-width: 768px) {
.query-grid {
grid-template-columns: 1fr;
}
}
</style>