d3 = require("d3@7")
Plot = require("@observablehq/plot")
// Load data
rawData = {
try {
return await FileAttachment("document-graph/document-relationships-visualization.json").json();
} catch (error) {
console.error("Failed to load data:", error);
return {
health: {
documents: [],
summary: { total: 0, healthy: 0, warning: 0, critical: 0 },
thresholds: {}
},
metadata: { generated_at: 'Unknown' }
};
}
}
// Extract health data
healthData = rawData.health || { documents: [], summary: {}, thresholds: {} }
thresholds = healthData.thresholds || {}
documents = healthData.documents || []
summary = healthData.summary || { total: 0, healthy: 0, warning: 0, critical: 0 }Document Health Dashboard
Visualization
Quality Assurance
Interactive health analysis and metadata compliance dashboard for documentation
Authors
Luke Boening
generate_doc_relationships.py
Keywords
metadata, quality, thresholds, compliance, dashboard
Document Health Dashboard
This dashboard analyzes metadata compliance across all documentation files, tracking:
- Tags: Minimum 3 per document
- Keywords: Minimum 5 per document
- Topics: Minimum 2 per document
- Categories: Minimum 1 per document
- Authors: Minimum 1 per document
Overall Health Summary
html`<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0;">
<div style="background: #e8f5e9; padding: 20px; border-radius: 8px; border-left: 4px solid #4caf50;">
<div style="font-size: 32px; font-weight: bold; color: #2e7d32;">${summary.healthy}</div>
<div style="color: #666; margin-top: 5px;">Healthy Documents</div>
<div style="font-size: 14px; color: #888; margin-top: 5px;">${summary.healthy_percentage}% of total</div>
</div>
<div style="background: #fff3e0; padding: 20px; border-radius: 8px; border-left: 4px solid #ff9800;">
<div style="font-size: 32px; font-weight: bold; color: #f57c00;">${summary.warning}</div>
<div style="color: #666; margin-top: 5px;">Warning Documents</div>
<div style="font-size: 14px; color: #888; margin-top: 5px;">Needs attention</div>
</div>
<div style="background: #ffebee; padding: 20px; border-radius: 8px; border-left: 4px solid #f44336;">
<div style="font-size: 32px; font-weight: bold; color: #c62828;">${summary.critical}</div>
<div style="color: #666; margin-top: 5px;">Critical Documents</div>
<div style="font-size: 14px; color: #888; margin-top: 5px;">Requires immediate action</div>
</div>
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; border-left: 4px solid #757575;">
<div style="font-size: 32px; font-weight: bold; color: #424242;">${summary.total}</div>
<div style="color: #666; margin-top: 5px;">Total Documents</div>
<div style="font-size: 14px; color: #888; margin-top: 5px;">Generated: ${rawData.metadata?.generated_at?.split('T')[0] || 'Unknown'}</div>
</div>
</div>`Metadata Thresholds
html`<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin: 20px 0;">
<strong>Required Minimum Counts:</strong>
<ul style="margin-top: 10px; columns: 2;">
<li><strong>Tags:</strong> ${thresholds.tags || 'N/A'}</li>
<li><strong>Keywords:</strong> ${thresholds.keywords || 'N/A'}</li>
<li><strong>Topics:</strong> ${thresholds.topics || 'N/A'}</li>
<li><strong>Categories:</strong> ${thresholds.categories || 'N/A'}</li>
<li><strong>Authors:</strong> ${thresholds.authors || 'N/A'}</li>
</ul>
</div>`1. Metadata Scatter Plot: Tags vs Keywords
Documents are positioned by their tag and keyword counts. The quadrants show compliance:
- Green (top-right): Meets all minimums ✓
- Yellow: Meets one minimum
- Red (bottom-left): Below both minimums
scatterChart = {
if (!documents.length) {
return html`<p style="color: #999; padding: 40px; text-align: center;">No data available</p>`;
}
const width = 900;
const height = 600;
const margin = {top: 40, right: 150, bottom: 60, left: 60};
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.style("max-width", "100%")
.style("height", "auto");
const xScale = d3.scaleLinear()
.domain([0, d3.max(documents, d => d.counts.tags) + 1])
.range([margin.left, width - margin.right]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(documents, d => d.counts.keywords) + 2])
.range([height - margin.bottom, margin.top]);
const colorScale = d3.scaleOrdinal()
.domain(['healthy', 'warning', 'critical'])
.range(['#4caf50', '#ff9800', '#f44336']);
// Add threshold lines
const tagThreshold = thresholds.tags || 3;
const keywordThreshold = thresholds.keywords || 5;
svg.append("line")
.attr("x1", xScale(tagThreshold))
.attr("x2", xScale(tagThreshold))
.attr("y1", margin.top)
.attr("y2", height - margin.bottom)
.attr("stroke", "#999")
.attr("stroke-dasharray", "4,4")
.attr("stroke-width", 2);
svg.append("line")
.attr("x1", margin.left)
.attr("x2", width - margin.right)
.attr("y1", yScale(keywordThreshold))
.attr("y2", yScale(keywordThreshold))
.attr("stroke", "#999")
.attr("stroke-dasharray", "4,4")
.attr("stroke-width", 2);
// Add threshold labels
svg.append("text")
.attr("x", xScale(tagThreshold))
.attr("y", margin.top - 10)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", "#666")
.text(`Min Tags: ${tagThreshold}`);
svg.append("text")
.attr("x", width - margin.right + 10)
.attr("y", yScale(keywordThreshold))
.attr("text-anchor", "start")
.attr("font-size", "12px")
.attr("fill", "#666")
.text(`Min Keywords: ${keywordThreshold}`);
// X axis
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(xScale).ticks(10))
.call(g => g.append("text")
.attr("x", width - margin.right)
.attr("y", 40)
.attr("fill", "currentColor")
.attr("text-anchor", "end")
.attr("font-weight", "bold")
.text("Number of Tags"));
// Y axis
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(yScale).ticks(10))
.call(g => g.append("text")
.attr("x", -margin.left)
.attr("y", 15)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.attr("font-weight", "bold")
.text("Number of Keywords"));
// Add circles
const circles = svg.selectAll("circle")
.data(documents)
.join("circle")
.attr("cx", d => xScale(d.counts.tags))
.attr("cy", d => yScale(d.counts.keywords))
.attr("r", 8)
.attr("fill", d => colorScale(d.status))
.attr("opacity", 0.7)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer");
// Add tooltips
circles.append("title")
.text(d => `${d.title}\nTags: ${d.counts.tags} (min: ${tagThreshold})\nKeywords: ${d.counts.keywords} (min: ${keywordThreshold})\nStatus: ${d.status}\nScore: ${d.composite_score}%`);
// Hover effects
circles
.on("mouseenter", function() {
d3.select(this)
.transition()
.duration(200)
.attr("r", 12)
.attr("opacity", 1);
})
.on("mouseleave", function() {
d3.select(this)
.transition()
.duration(200)
.attr("r", 8)
.attr("opacity", 0.7);
});
// Add legend
const legend = svg.append("g")
.attr("transform", `translate(${width - margin.right + 20}, ${margin.top})`);
const statuses = ['healthy', 'warning', 'critical'];
const statusLabels = {
'healthy': 'Healthy',
'warning': 'Warning',
'critical': 'Critical'
};
statuses.forEach((status, i) => {
const legendRow = legend.append("g")
.attr("transform", `translate(0, ${i * 25})`);
legendRow.append("circle")
.attr("r", 6)
.attr("fill", colorScale(status))
.attr("opacity", 0.7);
legendRow.append("text")
.attr("x", 15)
.attr("y", 5)
.attr("font-size", "12px")
.text(statusLabels[status]);
});
return svg.node();
}2. Composite Health Scores
Each document’s overall metadata completeness as a percentage of requirements met:
barChart = {
if (!documents.length) {
return html`<p style="color: #999; padding: 40px; text-align: center;">No data available</p>`;
}
// Sort by score
const sortedDocs = [...documents].sort((a, b) => a.composite_score - b.composite_score);
return Plot.plot({
width: 900,
height: Math.max(400, documents.length * 25),
marginLeft: 200,
marginRight: 80,
x: {
domain: [0, 100],
label: "Composite Score (%)",
grid: true
},
y: {
label: null
},
color: {
type: "ordinal",
domain: ['healthy', 'warning', 'critical'],
range: ['#4caf50', '#ff9800', '#f44336'],
legend: true
},
marks: [
Plot.barX(sortedDocs, {
x: "composite_score",
y: "title",
fill: "status",
tip: true,
title: d => `${d.title}\nScore: ${d.composite_score}%\nTags: ${d.counts.tags}/${thresholds.tags}\nKeywords: ${d.counts.keywords}/${thresholds.keywords}\nTopics: ${d.counts.topics}/${thresholds.topics}\nCategories: ${d.counts.categories}/${thresholds.categories}`
}),
Plot.ruleX([70, 100], {stroke: "#999", strokeDasharray: "4,4"})
]
})
}3. Metadata Distribution Heatmap
Shows which metadata types need the most attention across documents:
heatmapData = documents.flatMap(doc => [
{ document: doc.title, metric: 'Tags', value: doc.counts.tags, threshold: thresholds.tags },
{ document: doc.title, metric: 'Keywords', value: doc.counts.keywords, threshold: thresholds.keywords },
{ document: doc.title, metric: 'Topics', value: doc.counts.topics, threshold: thresholds.topics },
{ document: doc.title, metric: 'Categories', value: doc.counts.categories, threshold: thresholds.categories },
{ document: doc.title, metric: 'Authors', value: doc.counts.authors, threshold: thresholds.authors }
])
heatmap = {
if (!documents.length) {
return html`<p style="color: #999; padding: 40px; text-align: center;">No data available</p>`;
}
return Plot.plot({
width: 900,
height: Math.max(400, documents.length * 30),
marginLeft: 200,
padding: 0,
x: {
label: "Metadata Type"
},
y: {
label: null
},
color: {
type: "linear",
scheme: "RdYlGn",
label: "Count",
legend: true
},
marks: [
Plot.cell(heatmapData, {
x: "metric",
y: "document",
fill: d => d.value >= d.threshold ? d.value : -d.value, // Negative for below threshold
tip: true,
title: d => `${d.document}\n${d.metric}: ${d.value} (min: ${d.threshold})\n${d.value >= d.threshold ? '✓ Meets threshold' : '✗ Below threshold'}`
}),
Plot.text(heatmapData, {
x: "metric",
y: "document",
text: d => d.value,
fill: "white",
fontSize: 11
})
]
})
}4. Radar Chart: Sample Document Analysis
Individual metadata profile for each document:
radarChart = {
if (!selectedDoc) {
return html`<p style="color: #999; padding: 40px; text-align: center;">Select a document</p>`;
}
const width = 500;
const height = 500;
const margin = 80;
const radius = Math.min(width, height) / 2 - margin;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.style("max-width", "100%")
.style("height", "auto");
const g = svg.append("g")
.attr("transform", `translate(${width/2},${height/2})`);
// Data structure
const metrics = [
{ label: 'Tags', value: selectedDoc.counts.tags, max: Math.max(thresholds.tags * 2, 10) },
{ label: 'Keywords', value: selectedDoc.counts.keywords, max: Math.max(thresholds.keywords * 2, 15) },
{ label: 'Topics', value: selectedDoc.counts.topics, max: Math.max(thresholds.topics * 2, 8) },
{ label: 'Categories', value: selectedDoc.counts.categories, max: Math.max(thresholds.categories * 2, 5) },
{ label: 'Authors', value: selectedDoc.counts.authors, max: Math.max(thresholds.authors * 2, 5) }
];
const angleSlice = Math.PI * 2 / metrics.length;
// Draw circular grid
const levels = 5;
for (let i = 1; i <= levels; i++) {
const levelRadius = radius * i / levels;
g.append("circle")
.attr("r", levelRadius)
.attr("fill", "none")
.attr("stroke", "#ddd")
.attr("stroke-width", 1);
}
// Draw axes
metrics.forEach((metric, i) => {
const angle = angleSlice * i - Math.PI / 2;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
// Axis line
g.append("line")
.attr("x1", 0)
.attr("y1", 0)
.attr("x2", x)
.attr("y2", y)
.attr("stroke", "#ddd")
.attr("stroke-width", 1);
// Label
const labelX = Math.cos(angle) * (radius + 30);
const labelY = Math.sin(angle) * (radius + 30);
g.append("text")
.attr("x", labelX)
.attr("y", labelY)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("font-weight", "bold")
.text(metric.label);
});
// Draw data polygon (actual values)
const dataPoints = metrics.map((metric, i) => {
const angle = angleSlice * i - Math.PI / 2;
const r = radius * (metric.value / metric.max);
return {
x: Math.cos(angle) * r,
y: Math.sin(angle) * r
};
});
const line = d3.line()
.x(d => d.x)
.y(d => d.y);
g.append("path")
.datum([...dataPoints, dataPoints[0]])
.attr("d", line)
.attr("fill", selectedDoc.status === 'healthy' ? '#4caf50' : selectedDoc.status === 'warning' ? '#ff9800' : '#f44336')
.attr("fill-opacity", 0.3)
.attr("stroke", selectedDoc.status === 'healthy' ? '#4caf50' : selectedDoc.status === 'warning' ? '#ff9800' : '#f44336')
.attr("stroke-width", 2);
// Draw threshold polygon
const thresholdPoints = metrics.map((metric, i) => {
const angle = angleSlice * i - Math.PI / 2;
const thresholdValue = thresholds[metric.label.toLowerCase()] || 1;
const r = radius * (thresholdValue / metric.max);
return {
x: Math.cos(angle) * r,
y: Math.sin(angle) * r
};
});
g.append("path")
.datum([...thresholdPoints, thresholdPoints[0]])
.attr("d", line)
.attr("fill", "none")
.attr("stroke", "#666")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "4,4");
// Add dots for actual values
dataPoints.forEach((point, i) => {
g.append("circle")
.attr("cx", point.x)
.attr("cy", point.y)
.attr("r", 4)
.attr("fill", selectedDoc.status === 'healthy' ? '#4caf50' : selectedDoc.status === 'warning' ? '#ff9800' : '#f44336');
// Value label
g.append("text")
.attr("x", point.x)
.attr("y", point.y - 10)
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.attr("fill", "#333")
.text(metrics[i].value);
});
// Add legend
svg.append("text")
.attr("x", width / 2)
.attr("y", 25)
.attr("text-anchor", "middle")
.attr("font-size", "16px")
.attr("font-weight", "bold")
.text(selectedDoc.title);
svg.append("text")
.attr("x", width / 2)
.attr("y", 45)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", "#666")
.text(`Status: ${selectedDoc.status.toUpperCase()} | Score: ${selectedDoc.composite_score}%`);
// Legend for lines
const legend = svg.append("g")
.attr("transform", `translate(20, ${height - 60})`);
legend.append("line")
.attr("x1", 0)
.attr("x2", 30)
.attr("stroke", "#666")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "4,4");
legend.append("text")
.attr("x", 35)
.attr("y", 5)
.attr("font-size", "10px")
.text("Minimum Threshold");
legend.append("line")
.attr("x1", 0)
.attr("x2", 30)
.attr("y1", 20)
.attr("y2", 20)
.attr("stroke", selectedDoc.status === 'healthy' ? '#4caf50' : selectedDoc.status === 'warning' ? '#ff9800' : '#f44336')
.attr("stroke-width", 2);
legend.append("text")
.attr("x", 35)
.attr("y", 25)
.attr("font-size", "10px")
.text("Current Value");
return svg.node();
}5. Document List with Status
Inputs.table(documents, {
columns: [
"title",
"status",
"composite_score",
"counts",
"last_updated"
],
header: {
title: "Document",
status: "Status",
composite_score: "Score (%)",
counts: "Counts",
last_updated: "Last Updated"
},
format: {
status: status => html`<span style="
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
color: white;
background-color: ${status === 'healthy' ? '#4caf50' : status === 'warning' ? '#ff9800' : '#f44336'};
">${status.toUpperCase()}</span>`,
counts: counts => `T:${counts.tags} K:${counts.keywords} To:${counts.topics} C:${counts.categories} A:${counts.authors}`,
composite_score: score => `${score}%`
},
width: {
title: 300,
status: 100,
composite_score: 100,
counts: 200,
last_updated: 120
}
})Recommendations
Based on the health analysis:
- Critical Documents: Require immediate metadata enhancement
- Warning Documents: Should be reviewed and updated within the next review cycle
- Healthy Documents: Maintain current standards
The thresholds can be adjusted in generate_doc_relationships.py to match your documentation standards.