Files

3145 lines
122 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as d3 from 'd3';
import { enableTooltip } from '../tooltip/tooltip.js';
import { showPopover } from '../../libs/utils/utils.js';
import { generateProgressReport } from '../progressReport/progressReport.js';
import { getDBInstance } from '../../libs/utils/indexDb.js';
import { getAllChapterMapEntries } from '../../libs/utils/tocExtractor.js';
let modalInstance = null;
export class KnowledgeGraph {
constructor() {
this.width = window.innerWidth * 0.9;
this.height = window.innerHeight * 0.8;
this.nodes = [];
this.links = [];
this.simulation = null;
this.currentZoomLevel = 0;
this.tieredData = {};
this.selectedNodes = new Set();
// Zoom level configuration - optimized for performance
this.ZOOM_LEVEL_CONFIG = {
0: { // Bird's Eye - Only H1s
maxLevel: 1,
description: "Major Topics",
nodeSize: "large",
showDetails: false,
maxNodes: 50
},
1: { // Medium Overview - H1s + H2s
maxLevel: 2,
description: "Topics + Sections",
nodeSize: "medium",
showDetails: "minimal",
maxNodes: 200
},
2: { // Detailed View - H1s + H2s + H3s
maxLevel: 3,
description: "Topics + Sections + Subsections",
nodeSize: "small",
showDetails: "moderate",
maxNodes: 500
},
3: { // Deep Dive - Up to H4s
maxLevel: 4,
description: "Full Content Structure",
nodeSize: "tiny",
showDetails: "full",
maxNodes: 1000
}
};
// Performance settings
this.MAX_NODES_PER_LEVEL = 1000;
this.VIEWPORT_BUFFER = 200; // pixels outside viewport to render
this.colors = {
chapter: '#2c5282',
section: '#4299e1',
selected: '#63b3ed',
chapterLink: '#cbd5e0',
linkGreen: '#48bb78',
linkYellow: '#ecc94b',
linkRed: '#f56565',
linkBlue: '#4299e1',
// Domain-based colors
domainColors: {
'example.com': '#2c5282',
'github.com': '#24292e',
'stackoverflow.com': '#f48024',
'default': '#4299e1'
}
};
}
clearVisualization(container) {
d3.select(container).selectAll('svg').remove();
}
async getQuizScores() {
try {
const dbManager = await getDBInstance();
if (!dbManager) {
throw new Error('Database not initialized');
}
const scores = {};
const quizScores = await dbManager.getAll('quizHighScores');
quizScores.forEach(data => {
scores[data.quizTitle] = data.percentageScore;
});
return scores;
} catch (error) {
console.error('Error getting quiz scores:', error);
return {};
}
}
async getChapterSummaries() {
try {
const dbManager = await getDBInstance();
if (!dbManager) {
throw new Error('Database not initialized');
}
const summaries = {};
const allSummaries = await dbManager.getAll('chapterSummaries');
allSummaries.forEach(summary => {
summaries[summary.chapterId] = summary;
});
return summaries;
} catch (error) {
console.error('Error getting chapter summaries:', error);
return {};
}
}
async initData() {
const savedSelectedNodes = localStorage.getItem('knowledgeGraphSelectedNodes');
if (savedSelectedNodes) {
this.selectedNodes = new Set(JSON.parse(savedSelectedNodes));
}
// Build hierarchical tree data
this.treeData = await this.buildHierarchicalTreeData();
}
async buildHierarchicalTreeData(showAllPages = false, forListView = false) {
// Get all TOC entries from chapterMap
const allTOCEntries = await getAllChapterMapEntries();
// 🔍 DETAILED LOGGING: Log all chapterMap entries and their TOC content
// Uncomment below for detailed analysis
/*
console.group('📚 CHAPTERMAP DATA ANALYSIS');
allTOCEntries.forEach((entry, index) => {
console.log(`📖 Entry ${index + 1}:`, {
url: entry.url,
title: entry.title,
tocDataCount: entry.tocData ? entry.tocData.length : 0,
lastUpdated: entry.lastUpdated
});
if (entry.tocData && entry.tocData.length > 0) {
console.log(`📋 TOC Content for "${entry.title}":`);
entry.tocData.forEach((tocItem, tocIndex) => {
console.log(` ${tocIndex + 1}. [Level ${tocItem.level}] ${tocItem.text}`);
console.log(` Content: "${tocItem.content ? tocItem.content.substring(0, 100) + '...' : 'NO CONTENT'}"`);
console.log(` Position: ${tocItem.position}, Index: ${tocItem.index}`);
});
} else {
console.log(`❌ No TOC data found for "${entry.title}"`);
}
});
console.groupEnd();
*/
// Create root node
const root = {
name: "Knowledge Graph",
children: [],
level: 0,
id: "root",
pageUrl: "",
pageTitle: "",
domain: "",
position: 0,
isExpanded: false,
content: '' // Root node doesn't have content
};
// For list view, show ALL pages and ALL levels
// For radial graph, limit for performance
const MAX_PAGES = (showAllPages || forListView) ? allTOCEntries.length : 6;
allTOCEntries.slice(0, MAX_PAGES).forEach(pageEntry => {
// LOG: Check pageEntry structure and tocData
console.log('🔍 PROCESSING PAGE ENTRY:', pageEntry.title);
console.log(' - tocData length:', pageEntry.tocData ? pageEntry.tocData.length : 0);
if (pageEntry.tocData && pageEntry.tocData[0]) {
console.log(' - First tocData item:', pageEntry.tocData[0]);
console.log(' - Has content:', 'content' in pageEntry.tocData[0]);
}
const pageNode = {
name: pageEntry.title,
children: [],
level: 0,
id: `page-${pageEntry.url}`,
pageUrl: pageEntry.url,
pageTitle: pageEntry.title,
domain: pageEntry.domain,
position: 0,
isExpanded: false,
content: pageEntry.content || '' // Include page content if available
};
// Build hierarchy for this page
this.buildPageHierarchy(pageEntry.tocData, pageNode, forListView);
if (pageNode.children.length > 0) {
root.children.push(pageNode);
}
});
// Add truncation node if there are more pages and we're not showing all pages
if (!showAllPages && allTOCEntries.length > MAX_PAGES) {
const truncatedPageNode = {
name: `... ${allTOCEntries.length - MAX_PAGES} more pages`,
children: [],
level: 0,
id: 'truncated-pages',
pageUrl: '',
pageTitle: '',
domain: '',
position: 0,
isExpanded: false,
isTruncated: true,
content: '' // Truncated nodes don't have content
};
root.children.push(truncatedPageNode);
}
return root;
}
buildPageHierarchy(tocData, parentNode, forListView = false) {
const stack = [{ node: parentNode, level: 0 }];
// For list view, show ALL levels; for radial graph, limit to 2 levels for performance
const MAX_LEVEL = forListView ? 6 : 2; // Show H1-H6 for list view, H1-H2 for radial graph
const MAX_CHILDREN = forListView ? 1000 : 5; // Show all children for list view, limit for radial graph
// Filter out duplicate headings (like "Table of contents" that appears in nav)
const filteredTocData = this.filterDuplicateHeadings(tocData);
filteredTocData.forEach(heading => {
// Skip if beyond max level
if (heading.level > MAX_LEVEL) {
return;
}
// LOG: Check if heading has content
console.log('🔍 BUILDING HEADING NODE:', heading.text);
console.log(' - Has content:', 'content' in heading);
console.log(' - Content length:', heading.content ? heading.content.length : 0);
const headingNode = {
name: heading.text,
children: [],
level: heading.level,
id: `${parentNode.pageUrl}#${heading.id}`,
pageUrl: parentNode.pageUrl,
pageTitle: parentNode.pageTitle,
domain: parentNode.domain,
position: heading.position,
isExpanded: false,
isTruncated: false,
content: heading.content || '' // Include content from tocData
};
// LOG: Verify content was added to headingNode
console.log('✅ HEADING NODE CREATED:', headingNode.name, 'Content length:', headingNode.content ? headingNode.content.length : 0);
// Find the correct parent in the stack
while (stack.length > 1 && stack[stack.length - 1].level >= heading.level) {
stack.pop();
}
const currentParent = stack[stack.length - 1].node;
// Check if we need to truncate
if (currentParent.children.length >= MAX_CHILDREN) {
// Add truncation node if not already present
if (!currentParent.children.some(child => child.isTruncated)) {
const truncatedNode = {
name: `... ${this.countRemainingChildren(tocData, heading.position)} more`,
children: [],
level: heading.level,
id: `${parentNode.pageUrl}#truncated-${currentParent.children.length}`,
pageUrl: parentNode.pageUrl,
pageTitle: parentNode.pageTitle,
domain: parentNode.domain,
position: heading.position,
isExpanded: false,
isTruncated: true,
content: '' // Truncated nodes don't have content
};
currentParent.children.push(truncatedNode);
}
return; // Skip adding this node
}
currentParent.children.push(headingNode);
// Add to stack for potential children
stack.push({ node: headingNode, level: heading.level });
});
}
filterDuplicateHeadings(tocData) {
// Common navigation/duplicate headings to filter out
const duplicatePatterns = [
/^table\s+of\s+contents$/i,
/^toc$/i,
/^navigation$/i,
/^nav$/i,
/^menu$/i,
/^sidebar$/i,
/^footer$/i,
/^header$/i,
/^skip\s+to\s+content$/i,
/^skip\s+navigation$/i,
/^breadcrumb/i,
/^back\s+to\s+top$/i,
/^scroll\s+to\s+top$/i,
/^page\s+contents$/i,
/^contents$/i,
/^index$/i,
/^site\s+map$/i,
/^sitemap$/i
];
// Track headings we've seen to detect exact duplicates
const seenHeadings = new Set();
const filteredData = [];
for (const heading of tocData) {
const headingText = heading.text.trim().toLowerCase();
// Skip if it matches any duplicate pattern
const isDuplicatePattern = duplicatePatterns.some(pattern => pattern.test(headingText));
if (isDuplicatePattern) {
console.log(`🚫 Filtering out duplicate pattern: "${heading.text}"`);
continue;
}
// Skip if we've seen this exact heading before
if (seenHeadings.has(headingText)) {
console.log(`🚫 Filtering out duplicate heading: "${heading.text}"`);
continue;
}
// Skip very short headings that are likely navigation elements
if (headingText.length < 3) {
console.log(`🚫 Filtering out very short heading: "${heading.text}"`);
continue;
}
// Skip headings that are just numbers or symbols
if (/^[\d\s\-_\.]+$/.test(headingText)) {
console.log(`🚫 Filtering out numeric/symbol heading: "${heading.text}"`);
continue;
}
seenHeadings.add(headingText);
filteredData.push(heading);
}
return filteredData;
}
countRemainingChildren(tocData, currentPosition) {
// Count how many more children would be added after truncation
let count = 0;
let foundCurrent = false;
for (const heading of tocData) {
if (heading.position === currentPosition) {
foundCurrent = true;
continue;
}
if (foundCurrent && heading.position > currentPosition) {
count++;
}
}
return count;
}
buildInitialLinks(nodes) {
const links = [];
// Build simple sequential links between H1 nodes
const sortedNodes = nodes.sort((a, b) => a.position - b.position);
for (let i = 0; i < sortedNodes.length - 1; i++) {
const currentNode = sortedNodes[i];
const nextNode = sortedNodes[i + 1];
links.push({
source: currentNode.id,
target: nextNode.id,
type: 'sequential',
level: 1,
weight: 1,
color: '#cbd5e0'
});
}
return links;
}
createNodeFromTOC(heading, pageEntry) {
return {
// Core identifiers
id: `${pageEntry.url}#${heading.id}`,
headingId: heading.id,
pageUrl: pageEntry.url,
pageTitle: pageEntry.title,
domain: pageEntry.domain,
// Content
label: heading.text,
level: heading.level,
position: heading.position,
index: heading.index,
// Visual properties based on level
size: this.calculateNodeSize(heading.level),
color: this.calculateNodeColor(heading.level, pageEntry.domain),
opacity: this.calculateNodeOpacity(heading.level),
// Metadata
isVisible: true,
hasChildren: this.hasChildHeadings(heading, pageEntry.tocData),
isExpanded: false,
// Interaction properties
clickable: true,
expandable: heading.level < 6,
// Position for layout
x: null, // Will be set by D3 force simulation
y: null,
fx: null, // Fixed position if needed
fy: null
};
}
calculateNodeSize(level) {
// Base sizes for each heading level
const levelSizes = {
1: 40, // H1 - Largest
2: 30, // H2
3: 20, // H3
4: 15, // H4
5: 12, // H5
6: 10 // H6 - Smallest
};
return levelSizes[level] || 10;
}
calculateNodeColor(level, domain) {
// Simple color scheme based on heading level
const levelColors = {
1: '#1a365d', // Dark blue for H1s
2: '#2c5282', // Darker blue for H2s
3: '#3182ce', // Medium blue for H3s
4: '#4299e1', // Light blue for H4s
5: '#63b3ed', // Lighter blue for H5s
6: '#90cdf4' // Lightest blue for H6s
};
return levelColors[level] || '#4299e1';
}
calculateNodeOpacity(level) {
// Higher levels (H1, H2) are more opaque
const levelOpacities = {
1: 1.0, // H1 - Fully opaque
2: 0.9, // H2
3: 0.8, // H3
4: 0.7, // H4
5: 0.6, // H5
6: 0.5 // H6 - More transparent
};
return levelOpacities[level] || 0.5;
}
hasChildHeadings(heading, allHeadings) {
return allHeadings.some(h =>
h.level > heading.level &&
h.position > heading.position &&
h.index > heading.index
);
}
buildPageLinks(nodes, zoomLevel) {
const links = [];
const config = this.ZOOM_LEVEL_CONFIG[zoomLevel];
// Sort nodes by position for sequential links
const sortedNodes = nodes.sort((a, b) => a.position - b.position);
// Build hierarchical links (parent-child)
sortedNodes.forEach(node => {
const parentNode = this.findParentNode(node, sortedNodes);
if (parentNode) {
links.push({
source: parentNode.id,
target: node.id,
type: 'hierarchical',
level: node.level,
weight: this.calculateLinkWeight(node.level, 'hierarchical'),
color: this.calculateLinkColor(node.level, 'hierarchical')
});
}
});
// Build sequential links (same level, adjacent)
for (let i = 0; i < sortedNodes.length - 1; i++) {
const currentNode = sortedNodes[i];
const nextNode = sortedNodes[i + 1];
// Only link if they're close in position and same level
if (nextNode.position - currentNode.position < 5000 && // Within 5000px
currentNode.level === nextNode.level) {
links.push({
source: currentNode.id,
target: nextNode.id,
type: 'sequential',
level: currentNode.level,
weight: this.calculateLinkWeight(currentNode.level, 'sequential'),
color: this.calculateLinkColor(currentNode.level, 'sequential')
});
}
}
return links;
}
findParentNode(node, allNodes) {
// Find the closest parent heading (lower level number)
const candidates = allNodes.filter(n =>
n.level < node.level &&
n.position < node.position &&
n.pageUrl === node.pageUrl
);
if (candidates.length === 0) return null;
// Return the closest parent (highest level number among candidates)
return candidates.reduce((closest, current) =>
current.level > closest.level ? current : closest
);
}
calculateLinkWeight(level, type) {
const baseWeights = {
hierarchical: 2,
sequential: 1,
'cross-page': 0.5
};
const levelMultipliers = {
1: 1.0,
2: 0.8,
3: 0.6,
4: 0.4,
5: 0.3,
6: 0.2
};
return baseWeights[type] * levelMultipliers[level];
}
calculateLinkColor(level, type) {
const typeColors = {
hierarchical: '#4299e1',
sequential: '#cbd5e0',
'cross-page': '#e2e8f0'
};
return typeColors[type] || '#cbd5e0';
}
buildCrossPageLinks(allNodes, zoomLevel) {
const links = [];
// Group nodes by level
const nodesByLevel = {};
allNodes.forEach(node => {
if (!nodesByLevel[node.level]) {
nodesByLevel[node.level] = [];
}
nodesByLevel[node.level].push(node);
});
// Create cross-page links for each level
Object.entries(nodesByLevel).forEach(([level, nodes]) => {
// Sort by domain and position
const sortedNodes = nodes.sort((a, b) => {
if (a.domain !== b.domain) {
return a.domain.localeCompare(b.domain);
}
return a.position - b.position;
});
// Link H1s across pages (major topic flow)
if (parseInt(level) === 1) {
for (let i = 0; i < sortedNodes.length - 1; i++) {
const currentNode = sortedNodes[i];
const nextNode = sortedNodes[i + 1];
// Only link if different pages
if (currentNode.pageUrl !== nextNode.pageUrl) {
links.push({
source: currentNode.id,
target: nextNode.id,
type: 'cross-page',
level: parseInt(level),
weight: 0.5, // Lighter weight for cross-page links
color: '#cbd5e0' // Light gray for cross-page
});
}
}
}
});
return links;
}
calculateZoomLevel(scale) {
// More gradual zoom level changes for better area expansion
if (scale < 0.8) return 0; // Bird's eye
if (scale < 1.5) return 1; // Medium detail
if (scale < 3.0) return 2; // Detailed
if (scale < 6.0) return 3; // Very detailed
return 3; // Cap at level 3 for performance
}
updateGraphForZoomLevel(newZoomLevel) {
const config = this.ZOOM_LEVEL_CONFIG[newZoomLevel];
const data = this.tieredData[newZoomLevel];
console.log(`🔄 Updating graph to zoom level ${newZoomLevel}: ${config.description}`);
// Get visible nodes (limited for performance)
const visibleNodes = this.getVisibleNodes(data.nodes);
const visibleLinks = this.getVisibleLinks(data.links, visibleNodes);
console.log(`📊 Rendering ${visibleNodes.length} visible nodes out of ${data.nodes.length} total`);
// Clear existing nodes and links first
this.nodeGroup.selectAll('circle').remove();
this.linkGroup.selectAll('line').remove();
// Add new links first (so they appear behind nodes)
this.linkGroup.selectAll('line')
.data(visibleLinks, d => `${d.source.id || d.source}-${d.target.id || d.target}`)
.join('line')
.attr('class', 'link')
.attr('stroke-width', d => Math.max(2, d.weight * 2))
.attr('stroke', d => d.color || '#2d3748')
.attr('stroke-opacity', 0.8);
// Add new nodes
this.nodeGroup.selectAll('circle')
.data(visibleNodes, d => d.id)
.join('circle')
.attr('class', 'node')
.attr('r', d => d.size)
.attr('fill', d => {
if (this.selectedNodes.has(d.id)) {
return this.colors.selected;
}
return d.color;
})
.attr('stroke', d => {
if (this.selectedNodes.has(d.id)) {
return '#ffd700';
}
return 'none';
})
.attr('stroke-width', d => {
if (this.selectedNodes.has(d.id)) {
return 3;
}
return 0;
})
.style('opacity', d => d.opacity)
.on('click', (event, d) => {
this.handleNodeClick(event, d);
});
// Update simulation with visible data only
this.simulation.nodes(visibleNodes);
this.simulation.force('link').links(visibleLinks);
this.simulation.alpha(0.3).restart();
// Update zoom level indicator
this.updateZoomLevelIndicator(newZoomLevel, config);
}
updateLinksForZoomLevel(zoomLevel, visibleLinks = null) {
const links = visibleLinks || this.tieredData[zoomLevel].links;
// Update links
const linkUpdate = this.linkGroup.selectAll('line')
.data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
// Remove old links
linkUpdate.exit()
.transition()
.duration(300)
.style('opacity', 0)
.remove();
// Add new links
const linkEnter = linkUpdate.enter()
.append('line')
.attr('class', 'link')
.style('opacity', 0);
// Update all links
const linkMerge = linkEnter.merge(linkUpdate);
linkMerge
.transition()
.duration(300)
.style('stroke', d => d.color)
.style('stroke-width', d => d.weight)
.style('opacity', 0.6);
}
getVisibleNodes(allNodes) {
// For performance, limit nodes per zoom level
const config = this.ZOOM_LEVEL_CONFIG[this.currentZoomLevel];
const maxNodes = config.maxNodes || 200;
if (allNodes.length <= maxNodes) {
return allNodes;
}
// Prioritize nodes by level (H1s first, then H2s, etc.)
const sortedNodes = allNodes.sort((a, b) => {
// First sort by level (lower level = higher priority)
if (a.level !== b.level) {
return a.level - b.level;
}
// Then by position for same level
return a.position - b.position;
});
// Return only the most important nodes
return sortedNodes.slice(0, maxNodes);
}
getVisibleLinks(allLinks, visibleNodes) {
if (!visibleNodes || visibleNodes.length <= 100) {
return allLinks; // Return all links if small dataset
}
const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
// Only include links where both source and target are visible
return allLinks.filter(link => {
const sourceId = link.source.id || link.source;
const targetId = link.target.id || link.target;
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
}
showLoader() {
if (this.loader) return;
this.loader = document.createElement('div');
this.loader.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.9);
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
z-index: 1000;
display: flex;
align-items: center;
gap: 10px;
`;
const spinner = document.createElement('div');
spinner.style.cssText = `
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #4299e1;
border-radius: 50%;
animation: spin 1s linear infinite;
`;
const text = document.createElement('span');
text.textContent = 'Loading...';
text.style.cssText = `
font-size: 14px;
color: #2d3748;
`;
this.loader.appendChild(spinner);
this.loader.appendChild(text);
// Add spinner animation
const style = document.createElement('style');
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
this.loader.appendChild(style);
this.container.appendChild(this.loader);
}
hideLoader() {
if (this.loader) {
this.loader.remove();
this.loader = null;
}
}
updateZoomLevelIndicator(zoomLevel, config) {
let indicator = this.container.querySelector('.zoom-level-indicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'zoom-level-indicator';
indicator.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: rgba(255,255,255,0.9);
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 10;
`;
this.container.appendChild(indicator);
}
indicator.innerHTML = `
<div><strong>Detail Level ${zoomLevel}</strong></div>
<div>${config.description}</div>
<div>Showing H1-H${config.maxLevel}</div>
<div>${this.tieredData[zoomLevel].metadata.totalNodes} nodes</div>
`;
}
createVisualization(container) {
console.log("createVisualization container:", container)
// Store the whole container reference for shadow DOM access
this.wholeContainer = container;
// Create in-memory dictionary for sidebar nodes
this.sidebarNodes = new Map(); // key: nodeId, value: node data
// Define updateButtonState at the beginning of createVisualization
const updateButtonState = () => {
const generateButton = container.querySelector('#generate-summative-btn');
if (!generateButton) return;
const hasSelectedNodes = this.selectedNodes.size > 0;
generateButton.disabled = !hasSelectedNodes;
generateButton.style.backgroundColor = hasSelectedNodes ? '#4299e1' : '#CBD5E0';
generateButton.style.cursor = hasSelectedNodes ? 'pointer' : 'not-allowed';
// Save selected nodes to localStorage
localStorage.setItem('knowledgeGraphSelectedNodes',
JSON.stringify(Array.from(this.selectedNodes)));
};
// Create wrapper div for graph, sidebar, and right panel
const wrapper = document.createElement('div');
wrapper.style.cssText = `
display: flex;
width: 100%;
height: 100%;
position: relative;
`;
container.appendChild(wrapper);
// Create graph container with explicit dimensions
const graphContainer = document.createElement('div');
graphContainer.style.cssText = `
flex: 1;
position: relative;
min-height: 500px; // Ensure minimum height
height: 100%;
`;
wrapper.appendChild(graphContainer);
// Right panel for progress analysis will be created by the existing HTML structure
// Create sidebar
const sidebar = document.createElement('div');
sidebar.className = 'sidebar'; // Add class name for identification
sidebar.style.cssText = `
width: 250px;
background: #f7fafc;
padding: 20px;
border-left: 1px solid #e2e8f0;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
`;
wrapper.appendChild(sidebar);
// Add header section to sidebar
const sidebarHeader = document.createElement('div');
sidebarHeader.className = 'sidebar-header';
sidebarHeader.style.cssText = `
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #e2e8f0;
`;
const headerTitle = document.createElement('div');
headerTitle.textContent = 'Selected Components';
headerTitle.style.cssText = `
font-weight: bold;
font-size: 14px;
color: #2d3748;
margin-bottom: 8px;
`;
const explanatoryText = document.createElement('div');
explanatoryText.textContent = 'Selecting nodes will display here, allowing you to create custom quiz';
explanatoryText.style.cssText = `
font-size: 11px;
color: #718096;
line-height: 1.4;
margin-bottom: 8px;
`;
sidebarHeader.appendChild(headerTitle);
sidebarHeader.appendChild(explanatoryText);
sidebar.appendChild(sidebarHeader);
// Add scrollable container for quiz items
const quizItemsContainer = document.createElement('div');
quizItemsContainer.className = 'quiz-items-container';
quizItemsContainer.style.cssText = `
flex: 1;
overflow-y: auto;
margin-bottom: 80px; /* Space for fixed button container */
max-height: calc(100% - 120px); /* Adjust based on header and button space */
`;
sidebar.appendChild(quizItemsContainer);
// Add button container fixed to bottom
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
position: absolute;
bottom: 20px;
left: 20px;
right: 20px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
background: #f7fafc;
`;
const generateButton = document.createElement('button');
generateButton.id = 'generate-summative-btn';
generateButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
Generate Custom Quiz
`;
generateButton.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 16px;
background-color: #CBD5E0;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: not-allowed;
transition: background-color 0.2s;
gap: 8px;
`;
generateButton.disabled = true;
enableTooltip(generateButton, "Select nodes to generate custom quiz", container);
generateButton.addEventListener('mouseover', () => {
generateButton.style.backgroundColor = '#3182ce';
});
generateButton.addEventListener('mouseout', () => {
generateButton.style.backgroundColor = '#4299e1';
});
generateButton.addEventListener('click', async () => {
console.log('Generate Custom Quiz clicked, extracting data from sidebar DOM...');
// Extract data directly from sidebar DOM elements - use wholeContainer for shadow DOM access
const sidebar = this.wholeContainer.querySelector('.sidebar');
if (!sidebar) {
console.error('Sidebar not found in wholeContainer');
alert('Sidebar not found. Please try again.');
return;
}
const quizItems = sidebar.querySelectorAll('[data-quiz-id]');
console.log(`Found ${quizItems.length} quiz items in sidebar`);
if (quizItems.length === 0) {
console.error('No quiz items found in sidebar');
alert('No items selected. Please select some nodes and try again.');
return;
}
// Extract data from each quiz item
const selectedNodesData = Array.from(quizItems).map(quizItem => {
const nodeId = quizItem.dataset.quizId;
const content = quizItem.dataset.content || '';
const pageUrl = quizItem.dataset.pageUrl || '';
const domain = quizItem.dataset.domain || '';
const level = parseInt(quizItem.dataset.level) || 0;
const position = parseFloat(quizItem.dataset.position) || 0;
// Get the display name from the DOM
const nameElement = quizItem.querySelector('div[style*="font-weight: 500"]');
const label = nameElement ? nameElement.textContent.trim() : 'Unknown';
console.log('Extracted node data:', {
id: nodeId,
label: label,
content: content.substring(0, 100) + '...', // Log first 100 chars
pageUrl: pageUrl,
domain: domain,
level: level,
position: position
});
return {
id: nodeId,
label: label,
content: content,
pageUrl: pageUrl,
domain: domain,
level: level,
position: position
};
});
console.log('Selected nodes data extracted from sidebar:', selectedNodesData);
if (selectedNodesData.length === 0) {
console.error('No valid selected nodes found');
alert('No valid nodes selected. Please select some nodes and try again.');
return;
}
// Create title string from selected node labels
const title = selectedNodesData
.map(node => node.label)
.join(' | ');
// Create content with source mapping for reference tooltips
const contentWithSources = selectedNodesData.map((node, index) => ({
sourceId: `source-${index}`,
label: node.label,
content: node.content || `Section: ${node.label}\nContent: Please visit this section to generate content.`,
pageUrl: node.pageUrl,
domain: node.domain,
level: node.level,
position: node.position
}));
// Combine all content into one text with enhanced formatting
const quizPromptText = contentWithSources
.map(source => `## ${source.label}\n\n${source.content}`)
.join('\n\n---\n\n');
console.log('Generated quiz prompt text:', quizPromptText);
console.log('Content with sources:', contentWithSources);
const event = new CustomEvent('aiActionCompleted', {
detail: {
type: "summative",
title: title, // Add the combined title
text: quizPromptText, // Add the combined content for quiz generation
selectedNodesData: selectedNodesData, // Keep the original data if needed
contentWithSources: contentWithSources // NEW: Preserve source mapping for tooltips
},
bubbles: true,
composed: true
});
container.dispatchEvent(event);
// Close the modal after dispatching the event
const modal = container.closest('.modal') || container.parentElement;
if (modal) {
modal.style.display = 'none';
console.log('Modal closed after custom quiz generation');
}
});
buttonContainer.appendChild(generateButton);
// Add the new Analyze Progress button
const analyzeButton = document.createElement('button');
analyzeButton.id = 'analyze-progress-btn';
analyzeButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
</svg>
Analyze my Progress
`;
analyzeButton.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 16px;
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
gap: 8px;
margin-top: 8px;
`;
analyzeButton.addEventListener('mouseover', () => {
analyzeButton.style.backgroundColor = '#3182ce';
});
analyzeButton.addEventListener('mouseout', () => {
analyzeButton.style.backgroundColor = '#4299e1';
});
analyzeButton.addEventListener('click', async () => {
console.log("analyze button clicked");
let progressReport;
try {
progressReport = await generateProgressReport();
console.log("Full progress report data:", progressReport); // Log full report
if (progressReport.success) {
console.log('Progress Report Text:', progressReport.report);
console.log('Progress Report Charts:', {
xyChart: progressReport.charts?.xyChart,
quadrantChart: progressReport.charts?.quadrantChart
});
// Verify chart data before dispatching
if (!progressReport.charts?.xyChart || !progressReport.charts?.quadrantChart) {
console.warn('Missing chart data:', {
hasXYChart: !!progressReport.charts?.xyChart,
hasQuadrantChart: !!progressReport.charts?.quadrantChart
});
}
container.dispatchEvent(new CustomEvent('aiActionCompleted', {
bubbles: true,
composed: true,
detail: {
text: progressReport.report,
type: "progress_report",
xyChart: progressReport.charts?.xyChart || '',
quadrantChart: progressReport.charts?.quadrantChart || ''
}
}));
} else {
console.log('Progress Report Status:', progressReport.message);
}
} catch (error) {
console.error('Error generating progress report:', error);
return;
}
const modal = container.closest('.modal') || container.parentElement;
if (modal) {
modal.style.display = 'none';
}
});
enableTooltip(analyzeButton, "View your learning progress analysis", container);
buttonContainer.appendChild(analyzeButton);
sidebar.appendChild(buttonContainer);
// Update dimensions based on actual container size
const containerRect = graphContainer.getBoundingClientRect();
this.width = containerRect.width;
this.height = containerRect.height;
// Store the graph container reference
this.graphContainer = graphContainer;
// Add view toggle switch
this.addViewToggle(graphContainer);
// Create radial tree visualization
this.createRadialTree(graphContainer);
// Add legend with detail level buttons
const legend = document.createElement('div');
legend.style.cssText = `
position: absolute;
top: 20px;
left: 20px;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 2;
min-width: 200px;
`;
const legendTitle = document.createElement('h3');
legendTitle.textContent = 'Knowledge Graph';
legendTitle.style.cssText = `
margin: 0 0 15px 0;
font-size: 14px;
color: #2d3748;
`;
legend.appendChild(legendTitle);
const legendItems = [
{ color: '#4299e1', label: 'Pages & Topics (clickable)' },
{ color: '#999', label: 'Sections & Subsections' },
{ color: '#ff6b6b', label: 'Truncated (too many items)' },
{ color: '#ffd700', label: 'Selected for Quiz' },
{ color: '#555', label: 'Tree Connections' }
];
legendItems.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.style.cssText = `
display: flex;
align-items: center;
margin-bottom: 5px;
`;
const colorBox = document.createElement('div');
colorBox.style.cssText = `
width: 15px;
height: 15px;
background: ${item.color};
margin-right: 8px;
border-radius: 3px;
`;
const label = document.createElement('span');
label.textContent = item.label;
label.style.cssText = `
font-size: 12px;
color: #4a5568;
`;
itemDiv.appendChild(colorBox);
itemDiv.appendChild(label);
legend.appendChild(itemDiv);
});
graphContainer.appendChild(legend);
// After adding the legend to graphContainer, add the explanation card
const explanation = document.createElement('div');
explanation.style.cssText = `
position: absolute;
top: ${legend.offsetHeight + 40}px;
left: 20px;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 2;
max-width: 300px;
font-family: monospace;
font-size: 12px;
line-height: 1.4;
`;
explanation.innerHTML = `
<h3 style="margin: 0 0 10px 0; font-size: 14px; color: #2d3748;">Radial Knowledge Tree</h3>
<p style="margin: 0 0 8px 0; color: #4a5568;">
This radial tree shows your learning structure:<br>
• Center = Root of knowledge<br>
• Branches = Pages & Topics<br>
• Leaves = Sections & Details
</p>
<p style="margin: 0 0 8px 0; color: #4a5568;">
Interactive Features:<br>
• Click blue nodes to expand<br>
• Click gray nodes to select<br>
• Generate quizzes from selections
</p>
<p style="margin: 0; color: #4a5568;">
Tip: Use zoom to explore<br>
different branches of your<br>
knowledge tree.
</p>
`;
graphContainer.appendChild(explanation);
}
addViewToggle(container) {
// Create toggle container
const toggleContainer = document.createElement('div');
toggleContainer.style.cssText = `
position: absolute;
top: 20px;
right: 20px;
z-index: 100;
background: rgba(255, 255, 255, 0.9);
padding: 8px 12px;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #2d3748;
`;
// Add labels
const treeLabel = document.createElement('span');
treeLabel.textContent = 'Tree';
treeLabel.style.cssText = `
color: #4299e1;
font-weight: bold;
`;
const listLabel = document.createElement('span');
listLabel.textContent = 'List';
listLabel.style.cssText = `
color: #718096;
`;
// Create toggle switch
const toggleSwitch = document.createElement('div');
toggleSwitch.style.cssText = `
position: relative;
width: 40px;
height: 20px;
background: #4299e1;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s ease;
`;
const toggleSlider = document.createElement('div');
toggleSlider.style.cssText = `
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
`;
toggleSwitch.appendChild(toggleSlider);
toggleContainer.appendChild(treeLabel);
toggleContainer.appendChild(toggleSwitch);
toggleContainer.appendChild(listLabel);
// Add click handler
toggleSwitch.addEventListener('click', () => {
this.toggleView();
});
container.appendChild(toggleContainer);
// Store references
this.toggleContainer = toggleContainer;
this.toggleSwitch = toggleSwitch;
this.toggleSlider = toggleSlider;
this.treeLabel = treeLabel;
this.listLabel = listLabel;
this.currentView = 'tree'; // Start with tree view
}
toggleView() {
if (this.currentView === 'tree') {
this.switchToListView();
} else {
this.switchToTreeView();
}
}
switchToListView() {
console.log('Switching to list view...');
this.currentView = 'list';
// Update toggle appearance
this.toggleSwitch.style.background = '#718096';
this.toggleSlider.style.left = '22px';
this.treeLabel.style.color = '#718096';
this.listLabel.style.color = '#4299e1';
this.listLabel.style.fontWeight = 'bold';
this.treeLabel.style.fontWeight = 'normal';
// Hide tree view
const svg = this.graphContainer ? this.graphContainer.querySelector('svg') : this.container.querySelector('svg');
if (svg) {
console.log('Hiding SVG tree view');
svg.style.display = 'none';
} else {
console.log('No SVG found to hide');
}
// Ensure tree data is available
if (!this.treeData) {
console.log('Tree data not available, attempting to rebuild...');
this.initData().then(() => {
this.createListView();
}).catch(error => {
console.error('Failed to initialize tree data:', error);
});
} else {
console.log('Tree data available, creating list view...');
// Show list view
this.createListView();
}
}
switchToTreeView() {
this.currentView = 'tree';
// Update toggle appearance
this.toggleSwitch.style.background = '#4299e1';
this.toggleSlider.style.left = '2px';
this.treeLabel.style.color = '#4299e1';
this.treeLabel.style.fontWeight = 'bold';
this.listLabel.style.color = '#718096';
this.listLabel.style.fontWeight = 'normal';
// Hide list view
const listContainer = this.container.querySelector('#list-view');
if (listContainer) {
listContainer.remove();
}
// Show tree view
const svg = this.container.querySelector('svg');
if (svg) {
svg.style.display = 'block';
}
}
createListView() {
console.log('Creating list view...');
// Prevent multiple simultaneous list view creations
if (this.isCreatingListView) {
console.log('List view creation already in progress, skipping...');
return;
}
this.isCreatingListView = true;
// Remove existing list view if any
const existingList = this.container.querySelector('#list-view');
if (existingList) {
console.log('Removing existing list view');
existingList.remove();
}
// Create list container
const listContainer = document.createElement('div');
listContainer.id = 'list-view';
listContainer.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: white;
overflow-y: auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
z-index: 10;
`;
// Rebuild tree data specifically for list view (showing ALL nodes)
console.log('Rebuilding tree data for list view with all nodes...');
this.buildHierarchicalTreeData(true, true).then(treeData => {
this.treeData = treeData;
this.renderListViewContent(listContainer);
this.isCreatingListView = false; // Reset the guard
}).catch(error => {
console.error('Failed to build tree data for list view:', error);
listContainer.innerHTML = '<div style="text-align: center; margin-top: 50px; color: #e53e3e;">Error loading data</div>';
this.container.appendChild(listContainer);
this.isCreatingListView = false; // Reset the guard
});
}
renderListViewContent(listContainer) {
// Check if tree data exists
if (!this.treeData) {
console.error('Tree data not available for list view');
listContainer.innerHTML = '<div style="text-align: center; margin-top: 50px; color: #718096;">Loading...</div>';
this.container.appendChild(listContainer);
return;
}
console.log('Tree data available, building hierarchical list...');
console.log('Tree data:', this.treeData);
// Add info panel at the top
const infoPanel = document.createElement('div');
infoPanel.style.cssText = `
background: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
color: #2d3748;
`;
infoPanel.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px; color: #2c5282;">KNOWLEDGE GRAPH LIST VIEW - ALL NODES</div>
<div style="margin-bottom: 6px;">• Shows ALL pages and ALL heading levels (H1-H6)</div>
<div style="margin-bottom: 6px;">• Click pages to expand topics</div>
<div style="margin-bottom: 6px;">• Click H1 topics to expand sections</div>
<div style="margin-bottom: 6px;">• Click H2+ sections to select for quiz</div>
<div style="margin-bottom: 6px;">• Selected items appear in sidebar</div>
<div style="margin-bottom: 6px;">• Use "Generate Custom Quiz" to create quiz</div>
<div style="margin-top: 12px; margin-bottom: 8px; font-weight: bold; color: #2c5282;">ICON LEGEND:</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 8px;">
<div style="display: flex; align-items: center; gap: 4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path><path d="M8 2v4"></path><path d="M12 2v4"></path><path d="M16 2v4"></path></svg>
<span style="font-size: 10px;">Page</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="9" x2="15" y2="9"></line><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="13" y2="17"></line></svg>
<span style="font-size: 10px;">H1</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg>
<span style="font-size: 10px;">H2</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 12l2 2 4-4"></path><path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path><path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path><path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path><path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path></svg>
<span style="font-size: 10px;">H3</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v6m0 6v6m11-7h-6m-6 0H1"></path></svg>
<span style="font-size: 10px;">H4</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>
<span style="font-size: 10px;">H5</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>
<span style="font-size: 10px;">H6</span>
</div>
</div>
<div style="color: #718096; font-size: 11px; margin-top: 8px;">
Duplicate navigation elements (like "Table of contents") are automatically filtered out
</div>
`;
listContainer.appendChild(infoPanel);
// Add column headers for the list items
const listHeaders = document.createElement('div');
listHeaders.style.cssText = `
background: #f8f9fa;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 10px;
font-size: 11px;
color: #718096;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
justify-content: space-between;
align-items: center;
`;
listHeaders.innerHTML = `
<span>Component</span>
<span>Last Visited</span>
`;
listContainer.appendChild(listHeaders);
// Create hierarchical list
this.buildHierarchicalList(listContainer, this.treeData);
console.log('Adding list container to DOM...');
console.log('Container:', this.container);
console.log('Graph container:', this.graphContainer);
// Use the graph container instead of the main container
if (this.graphContainer) {
this.graphContainer.appendChild(listContainer);
console.log('List view added to graph container');
} else {
this.container.appendChild(listContainer);
console.log('List view added to main container');
}
console.log('List view created successfully');
}
buildHierarchicalList(container, data, level = 0) {
if (!data) {
console.log('buildHierarchicalList: No data provided');
return;
}
console.log(`buildHierarchicalList: Building level ${level} with data:`, data);
const item = document.createElement('div');
item.className = 'list-item';
item.style.cssText = `
margin-left: ${level * 20}px;
margin-bottom: 8px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: space-between;
`;
// Store URL data in data attributes for accessibility by other components
// URLs are hidden from display to reduce clutter but remain accessible via data attributes
if (data.pageUrl) {
item.setAttribute('data-page-url', data.pageUrl);
}
if (data.id) {
item.setAttribute('data-node-id', data.id);
}
if (data.domain) {
item.setAttribute('data-domain', data.domain);
}
if (data.pageTitle) {
item.setAttribute('data-page-title', data.pageTitle);
}
// Add click handler
item.addEventListener('click', () => {
this.handleListItemClick(data, item);
});
// Add hover effect
item.addEventListener('mouseenter', () => {
item.style.backgroundColor = '#f7fafc';
});
item.addEventListener('mouseleave', () => {
item.style.backgroundColor = 'transparent';
});
// Create left content with icon and title
const leftContent = document.createElement('div');
leftContent.style.cssText = `
display: flex;
align-items: center;
flex: 1;
`;
// Add icon based on level
const icon = document.createElement('span');
icon.style.cssText = `
margin-right: 8px;
font-size: 14px;
display: inline-flex;
align-items: center;
`;
icon.innerHTML = this.getLevelIcon(data.level, level);
const title = document.createElement('span');
title.textContent = data.name;
title.style.cssText = `
font-weight: ${level <= 1 ? 'bold' : 'normal'};
font-size: ${level === 0 ? '16px' : level === 1 ? '14px' : '12px'};
color: #2d3748;
`;
leftContent.appendChild(icon);
leftContent.appendChild(title);
// Create right content with date and expand icon
const rightContent = document.createElement('div');
rightContent.style.cssText = `
display: flex;
align-items: center;
gap: 8px;
`;
// Add visit date if available
if (data.pageUrl && data.pageUrl !== '') {
const visitDate = document.createElement('span');
visitDate.style.cssText = `
font-size: 11px;
color: #718096;
font-family: 'Courier New', monospace;
`;
visitDate.textContent = `Last visited: ${this.getVisitDate(data.pageUrl)}`;
rightContent.appendChild(visitDate);
}
// Add expand icon for items with children
if (data.children && data.children.length > 0) {
const expandIcon = document.createElement('span');
expandIcon.style.cssText = `
font-size: 12px;
color: #718096;
`;
expandIcon.textContent = data.isExpanded ? '▼' : '▶';
rightContent.appendChild(expandIcon);
}
item.appendChild(leftContent);
item.appendChild(rightContent);
// Metadata section removed to reduce clutter - page title data still available in data attributes
container.appendChild(item);
console.log(`Added item to container: ${data.name} (level ${level})`);
// Add children if expanded
if (data.children && data.children.length > 0) {
console.log(`Adding ${data.children.length} children for ${data.name}`);
data.children.forEach(child => {
const childElement = this.buildHierarchicalList(container, child, level + 1);
// Hide children initially if parent is not expanded
if (!data.isExpanded && childElement) {
childElement.style.display = 'none';
}
});
}
return item;
}
getLevelIcon(nodeLevel, hierarchyLevel) {
// Unique SVG icons for each heading level
if (hierarchyLevel === 0) {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path><path d="M8 2v4"></path><path d="M12 2v4"></path><path d="M16 2v4"></path></svg>'; // Root/Page level - Book with pages
} else if (nodeLevel === 1) {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="9" x2="15" y2="9"></line><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="13" y2="17"></line></svg>'; // H1 - Document with lines
} else if (nodeLevel === 2) {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg>'; // H2 - File with content
} else if (nodeLevel === 3) {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 12l2 2 4-4"></path><path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path><path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path><path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path><path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path></svg>'; // H3 - Network/Connection nodes
} else if (nodeLevel === 4) {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v6m0 6v6m11-7h-6m-6 0H1"></path></svg>'; // H4 - Target/Crosshair
} else if (nodeLevel === 5) {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>'; // H5 - Star
} else if (nodeLevel === 6) {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>'; // H6 - Cube/3D shape
} else {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg>'; // Default - File with content
}
}
getVisitDate(pageUrl) {
// Try to get visit date from localStorage or return placeholder
try {
const visitData = localStorage.getItem(`visit_${pageUrl}`);
if (visitData) {
const data = JSON.parse(visitData);
return data.date || 'Unknown';
}
} catch (error) {
console.log('Could not get visit date:', error);
}
// Return a placeholder date for now
return new Date().toLocaleDateString();
}
/**
* Helper method to get URL data from list items for other components
* @param {HTMLElement} listItem - The list item element
* @returns {Object} Object containing URL-related data
*/
getListItemUrlData(listItem) {
return {
pageUrl: listItem.getAttribute('data-page-url'),
nodeId: listItem.getAttribute('data-node-id'),
domain: listItem.getAttribute('data-domain'),
pageTitle: listItem.getAttribute('data-page-title')
};
}
/**
* Helper method to get all list items with their URL data
* @returns {Array} Array of objects containing list items and their URL data
*/
getAllListItemsWithUrlData() {
const listItems = this.container.querySelectorAll('.list-item');
return Array.from(listItems).map(item => ({
element: item,
urlData: this.getListItemUrlData(item)
}));
}
handleListItemClick(data, element) {
if (data.isTruncated && data.id === 'truncated-pages') {
// Special handling for "... X more pages" - show all pages
console.log('Expanding truncated pages - rebuilding tree with all pages');
this.buildHierarchicalTreeData(true, true); // Force rebuild with all pages and all levels for list view
const listContainer = this.container.querySelector('#list-view');
listContainer.innerHTML = '';
this.createListView(); // Recreate the entire list view
return;
}
if (data.children && data.children.length > 0) {
// Toggle expansion
data.isExpanded = !data.isExpanded;
// Update icon
const icon = element.querySelector('span:last-child');
icon.textContent = data.isExpanded ? '▼' : '▶';
// Instead of recreating the entire list, just toggle the children visibility
this.toggleChildrenVisibility(element, data);
} else {
// For leaf nodes, add to summative selection
this.toggleNodeSelection(data);
}
}
toggleChildrenVisibility(element, data) {
// Find the next sibling elements that are children of this item
let nextElement = element.nextElementSibling;
const childrenElements = [];
// Collect all child elements (they have higher margin-left)
const currentMarginLeft = parseInt(element.style.marginLeft) || 0;
while (nextElement && nextElement.classList && nextElement.classList.contains('list-item')) {
const nextMarginLeft = parseInt(nextElement.style.marginLeft) || 0;
// If the next element has a higher margin-left, it's a child
if (nextMarginLeft > currentMarginLeft) {
childrenElements.push(nextElement);
nextElement = nextElement.nextElementSibling;
} else {
// We've reached a sibling or parent level, stop
break;
}
}
// Toggle visibility of children
childrenElements.forEach(childElement => {
if (data.isExpanded) {
childElement.style.display = 'flex';
} else {
childElement.style.display = 'none';
}
});
}
createRadialTree(container) {
// Specify the chart's dimensions
const width = Math.min(this.width, this.height);
const height = width;
const cx = width * 0.5;
const cy = height * 0.5;
const radius = Math.min(width, height) / 2 - 40;
// Create a radial tree layout
const tree = d3.tree()
.size([2 * Math.PI, radius])
.separation((a, b) => (a.parent == b.parent ? 1 : 2) / a.depth);
// Create the SVG container
const svg = d3.select(container)
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [-cx, -cy, width, height])
.attr('style', 'width: 100%; height: auto; font: 12px sans-serif;');
// Add zoom behavior
const g = svg.append('g');
const zoom = d3.zoom()
.scaleExtent([0.5, 3]) // Prevent zooming out too much
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Store references
this.svg = svg;
this.container = container;
this.g = g;
// Create the initial tree visualization
this.updateRadialTree();
}
updateRadialTree() {
if (!this.treeData) return;
// Sort the tree and apply the layout with optimized separation
const root = d3.tree()
.size([2 * Math.PI, Math.min(this.width, this.height) / 2 - 40])
.separation((a, b) => {
// Reduce separation for better performance
if (a.parent == b.parent) {
return 0.5 / Math.max(a.depth, 1);
}
return 1 / Math.max(a.depth, 1);
})
(d3.hierarchy(this.treeData)
.sort((a, b) => d3.ascending(a.data.name, b.data.name)));
// Clear existing content
this.g.selectAll('*').remove();
// Append links
this.g.append('g')
.attr('fill', 'none')
.attr('stroke', '#555')
.attr('stroke-opacity', 0.4)
.attr('stroke-width', 1.5)
.selectAll('path')
.data(root.links())
.join('path')
.attr('d', d3.linkRadial()
.angle(d => d.x)
.radius(d => d.y));
// Append nodes
const nodes = this.g.append('g')
.selectAll('g')
.data(root.descendants())
.join('g')
.attr('transform', d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`);
// Add circles for nodes
nodes.append('circle')
.attr('fill', d => {
if (this.selectedNodes.has(d.data.id)) {
return '#ffd700';
} else if (d.data.isTruncated) {
return '#ff6b6b'; // Truncated nodes
} else if (d.children) {
return '#4299e1'; // Expandable nodes
} else {
return '#999'; // Leaf nodes
}
})
.attr('stroke', d => {
if (this.selectedNodes.has(d.data.id)) {
return '#ff6b6b';
} else if (d.data.isTruncated) {
return '#e53e3e';
} else if (d.children) {
return '#2c5282';
}
return 'none';
})
.attr('stroke-width', d => {
if (this.selectedNodes.has(d.data.id)) {
return 3;
} else if (d.data.isTruncated) {
return 2;
} else if (d.children) {
return 2;
}
return 0;
})
.attr('r', d => {
if (d.depth === 0) return 8; // Root
if (d.depth === 1) return 6; // Pages
if (d.depth === 2) return 4; // H1s
return 3; // H2s and below
})
.style('cursor', d => d.data.isTruncated ? 'not-allowed' : 'pointer')
.on('mouseover', (event, d) => {
this.showNodeTooltip(event, d);
})
.on('mouseout', (event, d) => {
this.hideNodeTooltip();
})
.on('click', (event, d) => {
this.handleRadialNodeClick(event, d);
});
// Add labels
nodes.append('text')
.attr('dy', '0.31em')
.attr('x', d => d.x < Math.PI === !d.children ? 6 : -6)
.attr('text-anchor', d => d.x < Math.PI === !d.children ? 'start' : 'end')
.attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
.attr('paint-order', 'stroke')
.attr('stroke', 'white')
.attr('stroke-width', 3)
.attr('fill', 'currentColor')
.style('font-size', d => {
if (d.depth === 0) return '14px';
if (d.depth === 1) return '12px';
return '10px';
})
.style('font-weight', d => d.depth <= 2 ? 'bold' : 'normal')
.text(d => {
// Truncate long labels more aggressively
const maxLength = d.depth <= 1 ? 12 : 8; // Much shorter
return d.data.name.length > maxLength ?
d.data.name.substring(0, maxLength) + '...' :
d.data.name;
});
}
showNodeTooltip(event, d) {
// Hide any existing tooltip
this.hideNodeTooltip();
// Create tooltip
const tooltip = document.createElement('div');
tooltip.id = 'node-tooltip';
tooltip.style.cssText = `
position: absolute;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 12px;
border-radius: 8px;
font-size: 12px;
max-width: 300px;
z-index: 1000;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
`;
// Build tooltip content
let content = `<div style="font-weight: bold; margin-bottom: 8px; color: #4299e1;">${d.data.name}</div>`;
if (d.data.isTruncated) {
content += `<div style="color: #ff6b6b; font-style: italic;">Truncated content</div>`;
} else if (d.children && d.children.length > 0) {
content += `<div style="margin-bottom: 6px; color: #cbd5e0;">Children (${d.children.length}):</div>`;
d.children.forEach(child => {
const childName = child.data.name.length > 25 ?
child.data.name.substring(0, 25) + '...' :
child.data.name;
content += `<div style="margin-left: 12px; margin-bottom: 2px;">• ${childName}</div>`;
});
} else if (d.data.pageTitle) {
content += `<div style="color: #cbd5e0;">Page: ${d.data.pageTitle}</div>`;
if (d.data.domain) {
content += `<div style="color: #cbd5e0;">Domain: ${d.data.domain}</div>`;
}
}
tooltip.innerHTML = content;
// Position tooltip
const rect = this.container.getBoundingClientRect();
tooltip.style.left = (event.pageX - rect.left + 10) + 'px';
tooltip.style.top = (event.pageY - rect.top - 10) + 'px';
// Add to container
this.container.appendChild(tooltip);
}
hideNodeTooltip() {
const tooltip = this.container.querySelector('#node-tooltip');
if (tooltip) {
tooltip.remove();
}
}
handleRadialNodeClick(event, d) {
event.stopPropagation();
console.log('Radial node clicked:', d.data);
// Don't allow interaction with truncated nodes
if (d.data.isTruncated) {
console.log('Cannot interact with truncated node');
return;
}
console.log('Node is not truncated, proceeding with interaction...');
if (d.children) {
// Toggle expansion
if (d.data.isExpanded) {
// Collapse
d.data.isExpanded = false;
d.children = null;
} else {
// Expand - load more children if needed
this.expandNodeWithMoreChildren(d);
}
this.updateRadialTree();
} else {
// Toggle selection for leaf nodes
this.toggleNodeSelection(d.data);
}
}
async expandNodeWithMoreChildren(node) {
// If this is a page node, load more H1 topics
if (node.data.level === 0 && node.data.pageUrl) {
await this.loadMoreChildrenForPage(node);
}
// If this is an H1 node, load more H2 sections
else if (node.data.level === 1) {
await this.loadMoreChildrenForTopic(node);
}
node.data.isExpanded = true;
}
async loadMoreChildrenForPage(pageNode) {
// Get the original TOC data for this page
const allTOCEntries = await getAllChapterMapEntries();
const pageEntry = allTOCEntries.find(entry => entry.url === pageNode.data.pageUrl);
if (pageEntry) {
// Load more H1 topics (beyond the initial 5)
const h1Topics = pageEntry.tocData.filter(heading => heading.level === 1);
const additionalTopics = h1Topics.slice(5); // Get topics beyond the first 5
additionalTopics.forEach(heading => {
const topicNode = {
name: heading.text,
children: [],
level: heading.level,
id: `${pageNode.data.pageUrl}#${heading.id}`,
pageUrl: pageNode.data.pageUrl,
pageTitle: pageNode.data.pageTitle,
domain: pageNode.data.domain,
position: heading.position,
isExpanded: false,
isTruncated: false,
content: heading.content || '' // Include content from tocData
};
// Load H2 sections for this topic
const h2Sections = pageEntry.tocData.filter(h =>
h.level === 2 && h.position > heading.position &&
h.position < this.findNextH1Position(pageEntry.tocData, heading.position)
);
h2Sections.slice(0, 5).forEach(section => {
topicNode.children.push({
name: section.text,
children: [],
level: section.level,
id: `${pageNode.data.pageUrl}#${section.id}`,
pageUrl: pageNode.data.pageUrl,
pageTitle: pageNode.data.pageTitle,
domain: pageNode.data.domain,
position: section.position,
isExpanded: false,
isTruncated: false,
content: section.content || '' // Include content from tocData
});
});
pageNode.children.push(topicNode);
});
}
}
async loadMoreChildrenForTopic(topicNode) {
// Get the original TOC data for this page
const allTOCEntries = await getAllChapterMapEntries();
const pageEntry = allTOCEntries.find(entry => entry.url === topicNode.data.pageUrl);
if (pageEntry) {
// Load more H2 sections for this topic
const h2Sections = pageEntry.tocData.filter(h =>
h.level === 2 && h.position > topicNode.data.position &&
h.position < this.findNextH1Position(pageEntry.tocData, topicNode.data.position)
);
const additionalSections = h2Sections.slice(5); // Get sections beyond the first 5
additionalSections.forEach(section => {
topicNode.children.push({
name: section.text,
children: [],
level: section.level,
id: `${topicNode.data.pageUrl}#${section.id}`,
pageUrl: topicNode.data.pageUrl,
pageTitle: topicNode.data.pageTitle,
domain: topicNode.data.domain,
position: section.position,
isExpanded: false,
isTruncated: false,
content: section.content || '' // Include content from tocData
});
});
}
}
findNextH1Position(tocData, currentPosition) {
const nextH1 = tocData.find(h => h.level === 1 && h.position > currentPosition);
return nextH1 ? nextH1.position : Infinity;
}
handleNodeClick(event, d) {
console.log('Node clicked:', d);
// Check if node has children and is expandable
if (d.hasChildren && !d.isExpanded) {
this.expandNode(d);
} else if (d.isExpanded) {
this.collapseNode(d);
} else {
// For nodes without children, just select them
this.toggleNodeSelection(d);
}
}
expandNode(node) {
console.log(`Expanding node: ${node.label}`);
// Show loader
this.showLoader();
// Find child nodes from the same page
const childNodes = this.allTOCData.filter(child =>
child.pageUrl === node.pageUrl &&
child.level === node.level + 1 &&
child.position > node.position &&
child.position < this.findNextSiblingPosition(node)
);
if (childNodes.length > 0) {
// Position child nodes around the parent
this.positionChildNodesAroundParent(node, childNodes);
// Add child nodes to the graph
this.nodes.push(...childNodes);
// Create links from parent to children
const newLinks = childNodes.map(child => ({
source: node.id,
target: child.id,
type: 'hierarchical',
level: child.level,
weight: 2,
color: '#4299e1'
}));
this.links.push(...newLinks);
// Mark node as expanded
node.isExpanded = true;
// Update the visualization with better spacing
this.updateGraphVisualization();
console.log(`Added ${childNodes.length} child nodes`);
}
// Hide loader
setTimeout(() => this.hideLoader(), 300);
}
positionChildNodesAroundParent(parentNode, childNodes) {
const parentX = parentNode.x;
const parentY = parentNode.y;
const childCount = childNodes.length;
if (childCount === 0) return;
// Calculate positions in a circle around the parent
const radius = Math.max(150, childCount * 30); // Minimum radius, scales with child count
childNodes.forEach((child, index) => {
// Distribute children in a circle around the parent
const angle = (2 * Math.PI * index) / childCount;
const childX = parentX + radius * Math.cos(angle);
const childY = parentY + radius * Math.sin(angle);
// Add some randomness to prevent perfect circles
const randomOffset = 20;
child.x = childX + (Math.random() - 0.5) * randomOffset;
child.y = childY + (Math.random() - 0.5) * randomOffset;
console.log(`Positioned child ${child.label} at (${child.x}, ${child.y})`);
});
}
collapseNode(node) {
console.log(`Collapsing node: ${node.label}`);
// Find all child nodes to remove
const childNodes = this.nodes.filter(n =>
n.pageUrl === node.pageUrl &&
n.level > node.level &&
n.position > node.position &&
n.position < this.findNextSiblingPosition(node)
);
if (childNodes.length > 0) {
// Remove child nodes
this.nodes = this.nodes.filter(n => !childNodes.includes(n));
// Remove links involving child nodes
this.links = this.links.filter(link => {
const sourceId = link.source.id || link.source;
const targetId = link.target.id || link.target;
return !childNodes.some(child => child.id === sourceId || child.id === targetId);
});
// Mark node as collapsed
node.isExpanded = false;
// Update the visualization
this.updateGraphVisualization();
console.log(`Removed ${childNodes.length} child nodes`);
}
}
findNextSiblingPosition(node) {
// Find the position of the next sibling at the same level
const siblings = this.allTOCData.filter(n =>
n.pageUrl === node.pageUrl &&
n.level === node.level &&
n.position > node.position
);
if (siblings.length > 0) {
return Math.min(...siblings.map(s => s.position));
}
return Infinity;
}
toggleNodeSelection(node) {
const sidebar = this.container.querySelector('.sidebar') || this.container;
console.log("Sidebar found:", sidebar)
if (this.selectedNodes.has(node.id)) {
// Deselect node
this.selectedNodes.delete(node.id);
const listItem = sidebar.querySelector(`[data-id="${node.id}"]`);
if (listItem) {
listItem.style.opacity = '0';
setTimeout(() => listItem.remove(), 300);
}
// Hide right panel if no selections
if (this.selectedNodes.size === 0) {
if (rightPanel) {
rightPanel.style.display = 'none';
}
}
} else {
// Select node
this.selectedNodes.add(node.id);
// Add to sidebar
const listItem = document.createElement('div');
listItem.dataset.id = node.id;
listItem.textContent = node.name || node.label;
listItem.style.cssText = `
padding: 8px;
margin: 5px 0;
background: #edf2f7;
border-radius: 4px;
opacity: 0;
transition: opacity 0.3s ease;
`;
sidebar.appendChild(listItem);
setTimeout(() => listItem.style.opacity = '1', 10);
// Show right panel with node details
this.showNodeDetails(node);
}
// Update button state
const generateButton = this.wholeContainer.querySelector('#generate-summative-btn');
if (generateButton) {
const hasSelectedNodes = this.selectedNodes.size > 0;
generateButton.disabled = !hasSelectedNodes;
generateButton.style.backgroundColor = hasSelectedNodes ? '#4299e1' : '#CBD5E0';
generateButton.style.cursor = hasSelectedNodes ? 'pointer' : 'not-allowed';
}
// Save selected nodes to localStorage
localStorage.setItem('knowledgeGraphSelectedNodes',
JSON.stringify(Array.from(this.selectedNodes)));
}
showNodeDetails(node) {
// Just call selectNodeForQuiz directly since we're using sidebar only
this.selectNodeForQuiz(node);
}
selectNodeForQuiz(node) {
console.log('selectNodeForQuiz called with node:', node);
console.log('Current sidebar dictionary size:', this.sidebarNodes.size);
// Add the node to quiz selection if not already selected
if (!this.selectedNodes.has(node.id)) {
this.selectedNodes.add(node.id);
} else {
console.log('Node already in selected nodes:', node.id);
}
// Just add the selected item to the sidebar list with delete option
const sidebar = this.wholeContainer.querySelector('.sidebar');
console.log('Adding node to sidebar:', node.id);
// Check if this item is already in the sidebar
const existingItem = sidebar.querySelector(`[data-quiz-id="${node.id}"]`);
console.log('Existing item:', existingItem);
if (existingItem) {
console.log('Item already exists, returning');
return; // Already added
}
// Log node data being added to sidebar
console.log('Adding node to sidebar:', {
id: node.id,
name: node.name || node.label,
content: node.content ? node.content.substring(0, 100) + '...' : 'No content',
pageUrl: node.pageUrl,
level: node.level
});
// Create a new item for the quiz selection list
const quizItem = document.createElement('div');
// PREVIOUS UNDERSTANDING: Content was stored in dataset attributes (lines 2386-2390)
// These dataset attributes are used for data extraction when generating quiz
quizItem.dataset.quizId = node.id;
quizItem.dataset.content = node.content || ''; // Store content as data attribute
quizItem.dataset.pageUrl = node.pageUrl || '';
quizItem.dataset.domain = node.domain || '';
quizItem.dataset.level = node.level || 0;
quizItem.dataset.position = node.position || 0; // Store position for scrolling
quizItem.style.cssText = `
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin: 5px 0;
background: #e6fffa;
border: 1px solid #38b2ac;
border-radius: 6px;
font-size: 12px;
color: #2d3748;
`;
// Get additional info for the component
const pageUrl = node.pageUrl || '';
const domain = node.domain || '';
const visitDate = this.getVisitDate(pageUrl);
// LOG: Verify content is being added to data attributes
console.log('🔍 ADDING CONTENT TO SIDEBAR ITEM:');
console.log(' - Node ID:', node.id);
console.log(' - Node Name:', node.name || node.label);
console.log(' - Content Length:', node.content ? node.content.length : 0);
console.log(' - Content Preview:', node.content ? node.content.substring(0, 50) + '...' : 'NO CONTENT');
console.log(' - Page URL:', pageUrl);
console.log(' - Domain:', domain);
console.log(' - Level:', node.level);
quizItem.innerHTML = `
<div style="flex: 1; margin-right: 8px;">
<div style="font-weight: 500; margin-bottom: 2px;">${node.name || node.label}</div>
<div style="font-size: 10px; color: #718096; margin-bottom: 1px;">${domain}</div>
<div style="font-size: 9px; color: #a0aec0;">${visitDate}</div>
</div>
<button class="remove-quiz-item" style="
background: #e53e3e;
color: white;
border: none;
border-radius: 3px;
width: 20px;
height: 20px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
">×</button>
`;
// LOG: Verify data attributes were set correctly
console.log('✅ DATA ATTRIBUTES SET:');
console.log(' - data-quiz-id:', quizItem.dataset.quizId);
console.log(' - data-content length:', quizItem.dataset.content ? quizItem.dataset.content.length : 0);
console.log(' - data-page-url:', quizItem.dataset.pageUrl);
console.log(' - data-domain:', quizItem.dataset.domain);
console.log(' - data-level:', quizItem.dataset.level);
console.log(' - data-position:', quizItem.dataset.position);
// Add click handler for remove button
const removeBtn = quizItem.querySelector('.remove-quiz-item');
removeBtn.addEventListener('click', () => {
this.selectedNodes.delete(node.id);
quizItem.remove();
console.log('Removed node from sidebar:', node.id);
// Update button state
const generateButton = this.wholeContainer.querySelector('#generate-summative-btn');
if (generateButton) {
const hasSelectedNodes = this.selectedNodes.size > 0;
generateButton.disabled = !hasSelectedNodes;
generateButton.style.backgroundColor = hasSelectedNodes ? '#4299e1' : '#CBD5E0';
generateButton.style.cursor = hasSelectedNodes ? 'pointer' : 'not-allowed';
}
});
// Insert into the scrollable quiz items container
const quizItemsContainer = sidebar.querySelector('.quiz-items-container');
console.log('Quiz items container found:', quizItemsContainer);
if (quizItemsContainer) {
quizItemsContainer.appendChild(quizItem);
console.log('Quiz item appended to quiz items container');
} else {
// Fallback: insert before button container
const buttonContainer = sidebar.querySelector('div[style*="position: absolute; bottom"]');
if (buttonContainer) {
sidebar.insertBefore(quizItem, buttonContainer);
console.log('Quiz item inserted before button container (fallback)');
} else {
sidebar.appendChild(quizItem);
console.log('Quiz item appended to sidebar (fallback)');
}
}
// Update button state
const generateButton = this.wholeContainer.querySelector('#generate-summative-btn');
if (generateButton) {
const hasSelectedNodes = this.selectedNodes.size > 0;
generateButton.disabled = !hasSelectedNodes;
generateButton.style.backgroundColor = hasSelectedNodes ? '#4299e1' : '#CBD5E0';
generateButton.style.cursor = hasSelectedNodes ? 'pointer' : 'not-allowed';
}
// Save selected nodes to localStorage
localStorage.setItem('knowledgeGraphSelectedNodes',
JSON.stringify(Array.from(this.selectedNodes)));
}
resetToH1Level() {
console.log('Resetting to H1 level');
// Show loader
this.showLoader();
// Reset to only H1 nodes
this.nodes = this.allTOCData.filter(node => node.level === 1);
this.links = this.buildInitialLinks(this.nodes);
// Reset all expansion states
this.allTOCData.forEach(node => {
node.isExpanded = false;
});
// Clear selected nodes
this.selectedNodes.clear();
// Update the visualization
this.updateGraphVisualization();
// Hide loader
setTimeout(() => this.hideLoader(), 300);
}
updateGraphVisualization() {
// Update simulation with new data
this.simulation.nodes(this.nodes);
this.simulation.force('link').links(this.links);
// Update existing nodes and links
this.updateNodesAndLinks();
// Adjust simulation forces based on number of nodes
const nodeCount = this.nodes.length;
if (nodeCount > 20) {
// For many nodes, reduce forces to prevent chaos
this.simulation.force('charge').strength(-1500);
this.simulation.force('collision').radius(d => d.size + 60);
} else {
// For fewer nodes, use stronger forces for better spacing
this.simulation.force('charge').strength(-3000);
this.simulation.force('collision').radius(d => d.size + 80);
}
// Restart simulation with higher energy for better spacing
this.simulation.alpha(0.5).restart();
}
updateNodesAndLinks() {
// Update links
this.linkGroup.selectAll('line')
.data(this.links, d => `${d.source.id || d.source}-${d.target.id || d.target}`)
.join(
enter => enter.append('line')
.attr('class', 'link')
.attr('stroke-width', d => Math.max(2, d.weight * 2))
.attr('stroke', d => d.color || '#2d3748')
.attr('stroke-opacity', 0.8),
update => update,
exit => exit.remove()
);
// Update nodes
const nodeUpdate = this.nodeGroup.selectAll('g')
.data(this.nodes, d => d.id);
const nodeEnter = nodeUpdate.enter()
.append('g')
.each(function(d, i, nodes) {
// Only set position for new nodes that don't have one
if (d.x === null || d.y === null) {
const width = this.parentNode.parentNode.clientWidth || 1200;
const height = this.parentNode.parentNode.clientHeight || 800;
// Use a more spread out grid layout
const totalNodes = nodes.length;
const cols = Math.ceil(Math.sqrt(totalNodes));
const rows = Math.ceil(totalNodes / cols);
const colWidth = width / (cols + 1);
const rowHeight = height / (rows + 1);
const col = i % cols;
const row = Math.floor(i / cols);
// Add more spacing between nodes
d.x = (col + 1) * colWidth + (Math.random() - 0.5) * colWidth * 0.2;
d.y = (row + 1) * rowHeight + (Math.random() - 0.5) * rowHeight * 0.2;
}
});
// Add circles to new nodes
nodeEnter.append('circle')
.attr('r', d => d.size)
.attr('fill', d => {
if (this.selectedNodes.has(d.id)) {
return this.colors.selected;
}
return d.color;
})
.attr('stroke', d => {
if (this.selectedNodes.has(d.id)) {
return '#ffd700';
} else if (d.hasChildren && !d.isExpanded) {
return '#4299e1'; // Blue border for expandable nodes
} else if (d.isExpanded) {
return '#2c5282'; // Darker border for expanded nodes
}
return 'none';
})
.attr('stroke-width', d => {
if (this.selectedNodes.has(d.id)) {
return 3;
} else if (d.hasChildren) {
return 2; // Thicker border for expandable nodes
}
return 0;
})
.style('opacity', d => d.opacity)
.on('click', (event, d) => {
this.handleNodeClick(event, d);
});
// Add labels to new nodes
nodeEnter.append('text')
.text(d => d.label)
.attr('x', 15)
.attr('y', 5)
.style('font-size', '12px')
.style('fill', '#2d3748');
// Remove old nodes
nodeUpdate.exit().remove();
// Update node labels
this.nodeLabelGroup.selectAll('text')
.data(this.nodes, d => d.id)
.join(
enter => enter.append('text')
.attr('class', 'node-label')
.text(d => d.label)
.attr('font-size', d => Math.max(10, d.size * 0.4))
.attr('font-weight', d => d.level === 1 ? 'bold' : 'normal')
.attr('text-anchor', 'middle')
.attr('dy', d => d.size + 15)
.style('pointer-events', 'none')
.style('fill', '#2d3748'),
update => update,
exit => exit.remove()
);
}
fitToView() {
if (!this.svg || !this.nodes || !this.nodes.length) return;
// Wait for nodes to have positions
const nodesWithPositions = this.nodes.filter(node =>
node.x !== null && node.y !== null &&
!isNaN(node.x) && !isNaN(node.y)
);
if (nodesWithPositions.length === 0) {
// If no nodes have positions yet, try again later
setTimeout(() => this.fitToView(), 500);
return;
}
// Calculate bounds of positioned nodes
const bounds = nodesWithPositions.reduce((acc, node) => {
acc.minX = Math.min(acc.minX, node.x);
acc.maxX = Math.max(acc.maxX, node.x);
acc.minY = Math.min(acc.minY, node.y);
acc.maxY = Math.max(acc.maxY, node.y);
return acc;
}, { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity });
if (bounds.minX === Infinity) return;
// Add padding
const padding = 100;
const width = bounds.maxX - bounds.minX + padding * 2;
const height = bounds.maxY - bounds.minY + padding * 2;
// Calculate scale to fit
const scaleX = this.width / width;
const scaleY = this.height / height;
const scale = Math.min(scaleX, scaleY, 1); // Don't zoom in beyond 1:1
// Calculate center
const centerX = (bounds.minX + bounds.maxX) / 2;
const centerY = (bounds.minY + bounds.maxY) / 2;
// Calculate translate to center the graph
const translateX = this.width / 2 - centerX * scale;
const translateY = this.height / 2 - centerY * scale;
// Apply transform
const transform = d3.zoomIdentity
.translate(translateX, translateY)
.scale(scale);
this.svg.transition()
.duration(1000)
.call(d3.zoom().transform, transform);
}
// Updated drag handlers with simulation reference
dragstarted(event, d) {
if (!event.active) this.simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
dragended(event, d) {
if (!event.active) this.simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
async getSelectedNodesSummaries() {
const summaries = [];
try {
const dbManager = await getDBInstance();
if (!dbManager || !dbManager.db) {
throw new Error('Database not properly initialized');
}
for (const nodeId of this.selectedNodes) {
const node = this.nodes.find(n => n.id === nodeId);
if (!node) continue;
console.log('Processing node:', node);
console.log('Node chapter:', node.chapter);
console.log('Node pageUrl:', node.pageUrl);
try {
// Extract the normalized URL from the node ID for chapterMap lookup
let chapterKey = node.chapter;
// If node.chapter is not available, try to extract from node.id or node.pageUrl
if (!chapterKey) {
if (node.id && node.id.startsWith('http')) {
// Extract the base URL from the node ID (remove hash fragment)
const url = new URL(node.id);
chapterKey = `${url.protocol}//${url.host}${url.pathname}`;
console.log('Extracted chapterKey from node.id:', chapterKey);
} else if (node.pageUrl) {
chapterKey = node.pageUrl;
console.log('Using node.pageUrl as chapterKey:', chapterKey);
}
} else {
console.log('Using node.chapter as chapterKey:', chapterKey);
}
console.log(`Looking for chapterMap data with key: ${chapterKey}`);
const chapterMapData = await dbManager.getByKey('chapterMap', chapterKey);
if (!chapterMapData) {
console.log(`No chapterMap data found for chapter ${chapterKey} - you may need to visit this chapter first`);
// Try to find chapterMap entries that might match
const allChapterMapEntries = await dbManager.getAll('chapterMap');
console.log('Available chapterMap entries:', allChapterMapEntries.map(entry => ({
url: entry.url,
title: entry.title,
domain: entry.domain
})));
continue;
}
console.log('Retrieved chapterMap data:', chapterMapData);
// 🔍 DETAILED LOGGING: Log the TOC data structure and content
console.log(`📚 Processing TOC for "${chapterMapData.title}" (${chapterMapData.tocData ? chapterMapData.tocData.length : 0} entries)`);
// Uncomment below for detailed TOC analysis
/*
console.group(`📚 TOC DATA ANALYSIS for "${chapterMapData.title}"`);
console.log('📊 ChapterMap Entry Details:', {
url: chapterMapData.url,
originalUrl: chapterMapData.originalUrl,
title: chapterMapData.title,
tocDataCount: chapterMapData.tocData ? chapterMapData.tocData.length : 0,
lastUpdated: chapterMapData.lastUpdated
});
if (chapterMapData.tocData && chapterMapData.tocData.length > 0) {
console.log('📋 Full TOC Data Structure:');
chapterMapData.tocData.forEach((tocItem, index) => {
console.log(` ${index + 1}. [Level ${tocItem.level}] "${tocItem.text}"`);
console.log(` ID: ${tocItem.id}`);
console.log(` Position: ${tocItem.position}`);
console.log(` Index: ${tocItem.index}`);
console.log(` Content Length: ${tocItem.content ? tocItem.content.length : 0} chars`);
console.log(` Content Preview: "${tocItem.content ? tocItem.content.substring(0, 150) + '...' : 'NO CONTENT'}"`);
console.log(` Content Full: "${tocItem.content || 'NO CONTENT'}"`);
console.log(' ---');
});
} else {
console.log('❌ No TOC data found in chapterMap entry');
}
console.groupEnd();
*/
if (node.type === 'chapter') {
// For chapter nodes, use the page title and URL
const content = `Chapter: ${chapterMapData.title}\nURL: ${chapterMapData.originalUrl || chapterMapData.url}`;
summaries.push({
title: node.label,
content: content
});
console.log('Added chapter content:', content);
} else if (node.type === 'section') {
// 🔍 DETAILED LOGGING: Section content matching process
console.log(`🔍 Matching section "${node.label}"`);
// Uncomment below for detailed matching analysis
/*
console.group(`🔍 SECTION CONTENT MATCHING for "${node.label}"`);
console.log('🎯 Looking for TOC entry matching node label:', node.label);
console.log('📋 Available TOC entries to match against:');
chapterMapData.tocData.forEach((entry, index) => {
console.log(` ${index + 1}. "${entry.text}" (Level ${entry.level})`);
});
*/
// Find matching TOC entry
const tocEntry = chapterMapData.tocData.find(entry =>
entry.text === node.label ||
entry.text.includes(node.label) ||
node.label.includes(entry.text)
);
if (tocEntry) {
console.log(`✅ Found TOC match: "${tocEntry.text}" (${tocEntry.content ? tocEntry.content.length : 0} chars)`);
/*
console.log('✅ Found matching TOC entry:', {
text: tocEntry.text,
level: tocEntry.level,
id: tocEntry.id,
contentLength: tocEntry.content ? tocEntry.content.length : 0
});
console.log('📄 TOC Entry Content:', tocEntry.content);
*/
let content = tocEntry.content;
// If no content, use URL fallback
if (!content || content.trim() === '') {
content = `Section: ${tocEntry.text}\nURL: ${chapterMapData.originalUrl || chapterMapData.url}`;
console.log('⚠️ Using URL fallback (no content):', tocEntry.text);
} else {
console.log('✅ Using content snippet:', tocEntry.text);
}
summaries.push({
title: node.label,
content: content
});
} else {
// No matching TOC entry found, use URL fallback
console.log('❌ No TOC match found for:', node.label);
/*
console.log('🔍 Attempted matches:');
chapterMapData.tocData.forEach((entry, index) => {
const exactMatch = entry.text === node.label;
const includesLabel = entry.text.includes(node.label);
const labelIncludesEntry = node.label.includes(entry.text);
console.log(` ${index + 1}. "${entry.text}" - exact: ${exactMatch}, includes: ${includesLabel}, reverse: ${labelIncludesEntry}`);
});
*/
const content = `Section: ${node.label}\nURL: ${chapterMapData.originalUrl || chapterMapData.url}`;
summaries.push({
title: node.label,
content: content
});
console.log('⚠️ Using URL fallback for unmatched section:', node.label);
}
// console.groupEnd();
}
} catch (error) {
console.error(`Error processing node ${nodeId}:`, error);
// Continue with other nodes instead of breaking the entire process
}
}
} catch (error) {
console.error('Database error:', error);
showPopover(document, error.message || 'Failed to load summaries. Please try visiting some chapters first.', 'error');
return [];
}
console.log('Final summaries:', summaries);
return summaries;
}
}
function createLoadingSpinner() {
const spinner = document.createElement('div');
spinner.id = 'knowledge-graph-spinner';
spinner.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
border: 5px solid #f3f3f3;
border-top: 5px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
`;
// Add keyframes for spinner animation
const style = document.createElement('style');
style.textContent = `
@keyframes spin {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
`;
spinner.appendChild(style);
return spinner;
}
export function showKnowledgeGraph(shadowRoot) {
if (!modalInstance) {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
padding: 20px;
border-radius: 10px;
width: 90%;
height: 90%;
position: relative;
display: flex;
flex-direction: column;
`;
// Add click handlers for modal
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.style.display = 'none';
}
});
// Add escape key handler
const handleEscape = (e) => {
if (e.key === 'Escape') {
modal.style.display = 'none';
}
};
document.addEventListener('keydown', handleEscape);
// Clean up event listener when modal is hidden
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
if (modal.style.display === 'none') {
document.removeEventListener('keydown', handleEscape);
}
}
});
});
observer.observe(modal, { attributes: true });
const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
border: none;
background: none;
font-size: 24px;
cursor: pointer;
z-index: 1;
`;
content.appendChild(closeButton);
modal.appendChild(content);
shadowRoot.appendChild(modal);
closeButton.onclick = () => {
modal.style.display = 'none';
};
modalInstance = { modal, content };
}
const { modal, content } = modalInstance;
// Clear previous content except close button
const closeButton = content.firstChild;
content.innerHTML = '';
content.appendChild(closeButton);
// Show modal and add spinner
modal.style.display = 'flex';
const spinner = createLoadingSpinner();
content.appendChild(spinner);
// Create new graph
const graph = new KnowledgeGraph();
// Initialize and create visualization
graph.initData().then(() => {
if (spinner && spinner.parentNode === content) {
content.removeChild(spinner);
}
graph.createVisualization(content);
}).catch(error => {
console.error('Error creating knowledge graph:', error);
if (spinner && spinner.parentNode === content) {
content.removeChild(spinner);
}
const errorMsg = document.createElement('div');
errorMsg.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: red;
text-align: center;
`;
errorMsg.textContent = 'Error loading knowledge graph. Please try again.';
content.appendChild(errorMsg);
setTimeout(() => {
if (errorMsg && errorMsg.parentNode === content) {
content.removeChild(errorMsg);
}
}, 3000);
});
}
export function initKnowledgeGraph(shadowRoot) {
const knowledgeGraphBtn = shadowRoot.querySelector('#knowledge-graph-btn');
enableTooltip(knowledgeGraphBtn, "View the knowledge graph", shadowRoot);
knowledgeGraphBtn.addEventListener('click', () => {
showKnowledgeGraph(shadowRoot);
});
}