/* Hermes Wiki β€” Touch-First JS for Phase 2 * * Features: * - Tree render with collapsible folders * - Search with live results dropdown (title + tags) * - 3D Graph in modal (Three.js, NO auto-rotation, click-to-navigate) * - Mobile bottom-nav (Files, Search, Graph) * - Hamburger tree + FAB graph on mobile * - Backdrop click closes overlays * - Backlinks rendering * - Hash-based navigation (forward/back via pushState) */ (function() { 'use strict'; const data = window.HERMES_DATA; if (!data) { console.error('[Hermes] HERMES_DATA missing'); return; } const currentSlug = window.location.pathname .replace(/^\//, '') .replace(/\.html$/, ''); // ============================================================ // Tree render // ============================================================ function renderTree(node, container, basePath) { const wrap = document.createElement('div'); wrap.className = 'tree-root'; if (node.folders && node.folders.length > 0) { for (const folder of node.folders) { const fEl = document.createElement('div'); fEl.className = 'tree-folder'; if (folder.name === 'concepts' || folder.name === 'entities') { fEl.classList.add('open'); // auto-open most-used folders } const header = document.createElement('div'); header.className = 'tree-folder-header'; header.innerHTML = `β–Ά${escapeHtml(folder.name)}`; header.onclick = () => fEl.classList.toggle('open'); fEl.appendChild(header); const children = document.createElement('div'); children.className = 'tree-children'; fEl.appendChild(children); renderTree(folder, children, basePath + '/' + folder.name); wrap.appendChild(fEl); } } if (node.files && node.files.length > 0) { for (const file of node.files) { const a = document.createElement('a'); a.href = '/' + file.slug + '.html'; a.className = 'tree-file'; if (file.slug === currentSlug) a.classList.add('current'); a.innerHTML = `πŸ“„${escapeHtml(file.title)}`; wrap.appendChild(a); } } container.appendChild(wrap); } // ============================================================ // Search // ============================================================ function setupSearch() { const input = document.getElementById('search'); if (!input) return; // Wrap with relative parent for absolute dropdown const wrapper = document.createElement('div'); wrapper.className = 'search-wrap'; input.parentNode.insertBefore(wrapper, input); wrapper.appendChild(input); input.style.paddingLeft = '36px'; const dropdown = document.createElement('div'); dropdown.className = 'search-results'; wrapper.appendChild(dropdown); let activeIdx = -1; input.addEventListener('input', (e) => { const q = e.target.value.toLowerCase().trim(); if (!q) { dropdown.classList.remove('open'); return; } const results = data.pages.filter(p => (p.title || '').toLowerCase().includes(q) || (p.slug || '').toLowerCase().includes(q) || (p.tags || []).some(t => t.toLowerCase().includes(q)) ).slice(0, 20); dropdown.innerHTML = ''; if (results.length === 0) { dropdown.innerHTML = '
Keine Treffer
'; } else { for (let i = 0; i < results.length; i++) { const r = results[i]; const a = document.createElement('a'); a.href = '/' + r.slug + '.html'; a.className = 'search-result' + (i === activeIdx ? ' active' : ''); const tagsHtml = (r.tags || []).slice(0, 3).map(t => `${escapeHtml(t)}`).join(''); a.innerHTML = `${escapeHtml(r.title)}${tagsHtml}${escapeHtml(r.slug)}`; a.onmouseenter = () => { activeIdx = i; updateActive(); }; dropdown.appendChild(a); } } dropdown.classList.add('open'); function updateActive() { [...dropdown.querySelectorAll('.search-result')].forEach((el, idx) => { el.classList.toggle('active', idx === activeIdx); }); } }); input.addEventListener('keydown', (e) => { const items = [...dropdown.querySelectorAll('.search-result')]; if (e.key === 'ArrowDown') { e.preventDefault(); activeIdx = Math.min(activeIdx + 1, items.length - 1); [...dropdown.querySelectorAll('.search-result')].forEach((el, idx) => el.classList.toggle('active', idx === activeIdx) ); } else if (e.key === 'ArrowUp') { e.preventDefault(); activeIdx = Math.max(activeIdx - 1, 0); [...dropdown.querySelectorAll('.search-result')].forEach((el, idx) => el.classList.toggle('active', idx === activeIdx) ); } else if (e.key === 'Enter' && activeIdx >= 0 && items[activeIdx]) { e.preventDefault(); items[activeIdx].click(); } else if (e.key === 'Escape') { input.value = ''; dropdown.classList.remove('open'); } }); // Close dropdown on outside click document.addEventListener('click', (e) => { if (!wrapper.contains(e.target)) { dropdown.classList.remove('open'); } }); } // ============================================================ // Graph (Three.js) β€” modal, NO auto-rotation, click-to-navigate // ============================================================ let graphState = null; function buildGraph(canvas, container) { if (!window.THREE) { console.error('[Hermes] THREE.js not loaded'); return; } const ctx = { scene: new THREE.Scene(), camera: null, renderer: new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }), nodes: new Map(), edges: [], currentSlug: currentSlug, isDragging: false, dragStart: { x: 0, y: 0 }, cameraPos: { x: 0, y: 0, z: 1200 }, cameraTarget: { x: 0, y: 0, z: 0 }, needsRender: true, hoveredNode: null, }; const w = canvas.clientWidth || canvas.width || 600; const h = canvas.clientHeight || canvas.height || 400; ctx.renderer.setSize(w, h, false); ctx.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); ctx.renderer.setClearColor(0x0e0e1a, 1); ctx.camera = new THREE.PerspectiveCamera(60, w / h, 1, 10000); ctx.camera.position.set(0, 0, 1200); ctx.camera.lookAt(0, 0, 0); // Build nodes via deterministic layout (Fruchterman-Reingold style) const graph = data.graph; const slugs = graph.nodes.map(n => n.id); const positions = computeLayout(slugs, graph.edges, 600); // Group nodes by folder for visual variety const folderColors = {}; let colorIdx = 0; const palette = [0x89b4fa, 0xa6e3a1, 0xf9e2af, 0xf5c2e7, 0x94e2d5, 0xfab387, 0xcba6f7]; for (const slug of slugs) { const folder = slug.split('/')[0] || 'root'; if (!(folder in folderColors)) { folderColors[folder] = palette[colorIdx++ % palette.length]; } } // Current page edges for highlight const currentEdges = new Set(); const currentReverse = new Set(); if (graph.edges) { for (const e of graph.edges) { if (e.source === currentSlug) { currentEdges.add(e.target); currentReverse.add(e.source); } if (e.target === currentSlug) { currentEdges.add(e.source); } } } // Build meshes const sphereGeo = new THREE.SphereGeometry(5, 12, 12); for (const node of graph.nodes) { const isCurrent = node.id === currentSlug; const isConnected = currentEdges.has(node.id); const folder = node.id.split('/')[0] || 'root'; const baseColor = folderColors[folder]; let color = baseColor; let opacity = 0.5; let size = 1; if (isCurrent) { color = 0xFFD700; opacity = 1; size = 2.2; } else if (isConnected) { color = 0xFFD700; opacity = 0.95; size = 1.5; } else if (currentSlug) { opacity = 0.25; } else { opacity = 0.6; } const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity, depthWrite: isCurrent || isConnected, }); const mesh = new THREE.Mesh(sphereGeo, mat); mesh.position.set(positions[node.id].x, positions[node.id].y, positions[node.id].z); mesh.scale.setScalar(size); mesh.userData = { nodeId: node.id, baseScale: size, baseOpacity: opacity, isCurrent, isConnected }; ctx.scene.add(mesh); ctx.nodes.set(node.id, mesh); } // Build edges const edgeMat = new THREE.LineBasicMaterial({ color: 0x6c7086, transparent: true, opacity: 0.35, }); const currentEdgeMat = new THREE.LineBasicMaterial({ color: 0xFFD700, transparent: true, opacity: 0.85, }); for (const e of graph.edges) { const src = ctx.nodes.get(e.source); const tgt = ctx.nodes.get(e.target); if (!src || !tgt) continue; const lineGeo = new THREE.BufferGeometry().setFromPoints([src.position, tgt.position]); const isCurrentEdge = (e.source === currentSlug || e.target === currentSlug); const line = new THREE.Line(lineGeo, isCurrentEdge ? currentEdgeMat : edgeMat); ctx.scene.add(line); } // Render only on demand (NO animation loop!) function render() { ctx.renderer.render(ctx.scene, ctx.camera); } // Manual rotation: drag to rotate (pointer + touch fallback for older iOS) let isDragging = false; let lastX = 0, lastY = 0; let dragStartTime = 0; let dragStartXY = { x: 0, y: 0 }; let moved = false; function getXY(e, canvas) { let clientX, clientY; if (e.touches && e.touches.length > 0) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else if (e.changedTouches && e.changedTouches.length > 0) { clientX = e.changedTouches[0].clientX; clientY = e.changedTouches[0].clientY; } else { clientX = e.clientX; clientY = e.clientY; } const rect = canvas.getBoundingClientRect(); const x = ((clientX - rect.left) / rect.width) * 2 - 1; const y = -((clientY - rect.top) / rect.height) * 2 + 1; return { x, y, clientX, clientY }; } function rotateCamera(dx, dy) { const rotSpeed = 0.005; const offset = new THREE.Vector3().subVectors(ctx.camera.position, ctx.cameraTarget); const spherical = new THREE.Spherical().setFromVector3(offset); spherical.theta -= dx * rotSpeed; spherical.phi -= dy * rotSpeed; spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); offset.setFromSpherical(spherical); ctx.camera.position.copy(ctx.cameraTarget).add(offset); ctx.camera.lookAt(ctx.cameraTarget); } function onDown(e) { // Don't preventDefault here β€” breaks iOS-Safari pointer sequence const p = getXY(e, canvas); isDragging = true; moved = false; dragStartTime = Date.now(); dragStartXY = { x: p.clientX, y: p.clientY }; lastX = p.clientX; lastY = p.clientY; if (canvas.setPointerCapture && e.pointerId !== undefined) { try { canvas.setPointerCapture(e.pointerId); } catch {} } } function onMove(e) { if (!isDragging) return; e.preventDefault(); const p = getXY(e, canvas); const dx = p.clientX - lastX; const dy = p.clientY - lastY; if (Math.abs(p.clientX - dragStartXY.x) + Math.abs(p.clientY - dragStartXY.y) > 8) { moved = true; } rotateCamera(dx, dy); lastX = p.clientX; lastY = p.clientY; render(); } function onUp(e) { if (!isDragging) return; isDragging = false; const elapsed = Date.now() - dragStartTime; // Tap = quick release without much movement if (!moved && elapsed < 500) { const p = getXY(e.changedTouches ? { changedTouches: e.changedTouches } : e, canvas); const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(p.x, p.y); raycaster.setFromCamera(mouse, ctx.camera); const meshes = Array.from(ctx.nodes.values()); const intersects = raycaster.intersectObjects(meshes); if (intersects.length > 0) { const nodeId = intersects[0].object.userData.nodeId; if (nodeId) { const modal = document.querySelector('.graph-modal'); if (modal) modal.classList.remove('open'); window.location.href = '/' + nodeId + '.html'; return; } } } } // Pointer events (modern: iOS 13+, all modern browsers) canvas.addEventListener('pointerdown', onDown); canvas.addEventListener('pointermove', onMove); canvas.addEventListener('pointerup', onUp); canvas.addEventListener('pointercancel', onUp); canvas.addEventListener('pointerleave', onUp); // Touch events fallback (older iOS Safari 12-) canvas.addEventListener('touchstart', onDown, { passive: true }); canvas.addEventListener('touchmove', onMove, { passive: false }); canvas.addEventListener('touchend', onUp, { passive: false }); canvas.addEventListener('touchcancel', onUp, { passive: false }); // Mouse events fallback (older desktop browsers) canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousemove', onMove); canvas.addEventListener('mouseup', onUp); canvas.addEventListener('mouseleave', onUp); // Zoom with wheel (desktop) + pinch (mobile) let pinchStartDist = null; function getPinchDist(e) { if (e.touches && e.touches.length >= 2) { const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; return Math.sqrt(dx*dx + dy*dy); } return null; } function onWheel(e) { e.preventDefault(); const dir = e.deltaY > 0 ? 1.1 : 0.9; ctx.camera.position.multiplyScalar(dir); ctx.camera.lookAt(ctx.cameraTarget); render(); } canvas.addEventListener('wheel', onWheel, { passive: false }); function onTouchMoveForPinch(e) { const dist = getPinchDist(e); if (dist !== null) { e.preventDefault(); if (pinchStartDist === null) { pinchStartDist = dist; } else { const ratio = pinchStartDist / dist; if (Math.abs(ratio - 1) > 0.01) { ctx.camera.position.multiplyScalar(1 / ratio); ctx.camera.lookAt(ctx.cameraTarget); pinchStartDist = dist; render(); } } } } function onTouchEndForPinch(e) { if (e.touches && e.touches.length < 2) { pinchStartDist = null; } } canvas.addEventListener('touchmove', onTouchMoveForPinch, { passive: false }); canvas.addEventListener('touchend', onTouchEndForPinch, { passive: false }); // Resize handling β€” wait for actual visible dimensions function safeResize() { const w2 = canvas.clientWidth || canvas.parentElement.clientWidth; const h2 = canvas.clientHeight || canvas.parentElement.clientHeight; if (w2 === 0 || h2 === 0) return; ctx.camera.aspect = w2 / h2; ctx.camera.updateProjectionMatrix(); ctx.renderer.setSize(w2, h2, false); render(); } function onResize() { safeResize(); } window.addEventListener('resize', onResize); // Force-initial render with retry until canvas has dimensions let resizeRetries = 0; function tryInitialRender() { safeResize(); if ((canvas.clientWidth === 0 || canvas.clientHeight === 0) && resizeRetries < 10) { resizeRetries++; requestAnimationFrame(tryInitialRender); } } requestAnimationFrame(tryInitialRender); // Listen for modal-open to re-check sizing if (container.classList.contains('graph-modal')) { const observer = new ResizeObserver(() => safeResize()); observer.observe(canvas); ctx.cleanupObserver = () => observer.disconnect(); } // Cleanup function ctx.render = render; ctx.cleanup = () => { canvas.removeEventListener('pointerdown', onDown); canvas.removeEventListener('pointermove', onMove); canvas.removeEventListener('pointerup', onUp); canvas.removeEventListener('pointercancel', onUp); canvas.removeEventListener('pointerleave', onUp); canvas.removeEventListener('touchstart', onDown); canvas.removeEventListener('touchmove', onMove); canvas.removeEventListener('touchend', onUp); canvas.removeEventListener('touchcancel', onUp); canvas.removeEventListener('mousedown', onDown); canvas.removeEventListener('mousemove', onMove); canvas.removeEventListener('mouseup', onUp); canvas.removeEventListener('mouseleave', onUp); canvas.removeEventListener('wheel', onWheel); canvas.removeEventListener('touchmove', onTouchMoveForPinch); canvas.removeEventListener('touchend', onTouchEndForPinch); window.removeEventListener('resize', onResize); if (ctx.cleanupObserver) ctx.cleanupObserver(); }; return ctx; } function computeLayout(nodeIds, edges, radius) { // Simple force-directed-ish: angle-distributed with edge-based clustering const positions = {}; const N = nodeIds.length; if (N === 0) return positions; // Initialize: distribute on sphere for (let i = 0; i < N; i++) { const phi = Math.acos(-1 + (2 * i) / N); const theta = Math.sqrt(N * Math.PI) * phi; positions[nodeIds[i]] = { x: radius * Math.cos(theta) * Math.sin(phi), y: radius * Math.sin(theta) * Math.sin(phi), z: radius * Math.cos(phi), }; } // 1-2 iterations of relaxation for (let iter = 0; iter < 2; iter++) { // Repulsion for (let i = 0; i < N; i++) { const a = nodeIds[i]; let fx = 0, fy = 0, fz = 0; for (let j = 0; j < N; j++) { if (i === j) continue; const b = nodeIds[j]; const dx = positions[a].x - positions[b].x; const dy = positions[a].y - positions[b].y; const dz = positions[a].z - positions[b].z; const d2 = dx*dx + dy*dy + dz*dz + 0.01; const f = 50 / d2; fx += dx * f; fy += dy * f; fz += dz * f; } positions[a].x += fx * 0.01; positions[a].y += fy * 0.01; positions[a].z += fz * 0.01; } } return positions; } // ============================================================ // Backlinks render // ============================================================ function renderBacklinks() { const container = document.getElementById('backlinks'); if (!container) return; const links = (data.backlinks && data.backlinks[currentSlug]) || []; if (links.length === 0) { container.innerHTML = ''; return; } const html = [ '', '' ].join(''); container.innerHTML = html; } // ============================================================ // TOC (extract from rendered headings) // ============================================================ function renderTOC() { const container = document.getElementById('toc'); if (!container) return; const headings = [...document.querySelectorAll('.note-body h2, .note-body h3')]; if (headings.length === 0) { container.innerHTML = ''; return; } const items = headings.map(h => { const level = h.tagName === 'H2' ? 2 : 3; const text = h.textContent.replace(/\s*ΒΆ\s*$/, '').trim(); const id = h.id || text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); h.id = id; return `
  • ${escapeHtml(text)}
  • `; }).join(''); container.innerHTML = `
    Inhalt
    `; } // ============================================================ // Mobile bottom-nav handlers // ============================================================ function setupBottomNav() { document.querySelectorAll('.app-bottom-nav button').forEach(btn => { btn.addEventListener('click', (e) => { const action = btn.dataset.action; if (action === 'toggle-tree') { toggleTree(); } else if (action === 'toggle-graph') { openGraphModal(); } else if (action === 'focus-search') { const s = document.getElementById('search'); if (s) { s.scrollIntoView({ behavior: 'smooth', block: 'start' }); s.focus(); } } }); }); } function toggleTree() { const tree = document.querySelector('.app-tree'); const backdrop = document.querySelector('.backdrop'); if (!tree) return; const isOpen = tree.classList.contains('open'); tree.classList.toggle('open'); if (backdrop) backdrop.classList.toggle('open', !isOpen); } function openGraphModal() { const modal = document.querySelector('.graph-modal'); if (!modal) return; modal.classList.add('open'); document.body.style.overflow = 'hidden'; // Build graph in modal canvas (once) const canvas = modal.querySelector('canvas'); if (canvas && !canvas.dataset.built) { canvas.dataset.built = '1'; // Wait for layout requestAnimationFrame(() => { graphState = buildGraph(canvas, modal); }); } else if (canvas && graphState) { graphState.render(); } } function closeGraphModal() { const modal = document.querySelector('.graph-modal'); if (!modal) return; modal.classList.remove('open'); document.body.style.overflow = ''; } // ============================================================ // Sidebar/Desktop graph // ============================================================ function setupDesktopGraph() { const graphEl = document.querySelector('.app-graph'); if (!graphEl) return; const canvas = document.createElement('canvas'); graphEl.appendChild(canvas); requestAnimationFrame(() => { graphState = buildGraph(canvas, graphEl); }); } // ============================================================ // Top-bar buttons (toolbar actions) // ============================================================ function setupToolbar() { document.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; if (action === 'toggle-tree') { toggleTree(); } else if (action === 'toggle-graph') { const isMobile = window.innerWidth < 768; if (isMobile) openGraphModal(); else if (graphState) graphState.render(); } else if (action === 'focus-search') { const s = document.getElementById('search'); if (s) s.focus(); } }); } // ============================================================ // Modal close handlers // ============================================================ function setupModalClose() { const closeBtn = document.querySelector('.graph-modal-close'); if (closeBtn) closeBtn.onclick = closeGraphModal; document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { const modal = document.querySelector('.graph-modal'); if (modal && modal.classList.contains('open')) closeGraphModal(); } }); } // ============================================================ // Backdrop // ============================================================ function setupBackdrop() { let backdrop = document.querySelector('.backdrop'); if (!backdrop) { backdrop = document.createElement('div'); backdrop.className = 'backdrop'; document.body.appendChild(backdrop); } backdrop.onclick = () => { const tree = document.querySelector('.app-tree'); if (tree) tree.classList.remove('open'); backdrop.classList.remove('open'); }; } // ============================================================ // Utility // ============================================================ function escapeHtml(str) { return String(str).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } // ============================================================ // Init // ============================================================ function init() { const treeEl = document.getElementById('tree'); if (treeEl && data.tree) renderTree(data.tree, treeEl); setupSearch(); setupToolbar(); setupModalClose(); setupBackdrop(); setupBottomNav(); setupDesktopGraph(); renderTOC(); renderBacklinks(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();