refactor: split knowledge-graph monolith and extract rubberbanding logic

This commit is contained in:
Per Stark
2026-06-18 15:17:47 +02:00
parent b3d42d2586
commit 3b20adc50f
3 changed files with 761 additions and 527 deletions
+3
View File
@@ -2,6 +2,9 @@
## Unreleased ## Unreleased
- Refactor: split knowledge-graph.js monolith into focused functions (loadGraphData, buildSvg, createSimulation, drawLinks/Nodes/Labels, createHighlighting, createZoom, attachResize); fixed dead duplicate zoom instance
- Refactor: extracted rubberbanding scroll logic in design-polish.js into standalone attachRubberbanding helper; removed dead pullDistance state
- Evaluations: simplified crate layout — linear pipeline, sharded-only converted store, in-memory ingestion, `db/` and `cli/` modules; namespace reuse state in corpus manifest (removed `cache/snapshots/`); no legacy JSON/history compatibility (re-run `--warm` after upgrade) - Evaluations: simplified crate layout — linear pipeline, sharded-only converted store, in-memory ingestion, `db/` and `cli/` modules; namespace reuse state in corpus manifest (removed `cache/snapshots/`); no legacy JSON/history compatibility (re-run `--warm` after upgrade)
- Performance: ingestion skips per-task index rebuild; worker runs scheduled `REBUILD INDEX` (default every 24h via `index_rebuild_interval_secs`, `0` disables) - Performance: ingestion skips per-task index rebuild; worker runs scheduled `REBUILD INDEX` (default every 24h via `index_rebuild_interval_secs`, `0` disables)
- Performance: ingestion persists all artifacts in a single SurrealDB transaction per task (atomic replace by task id) - Performance: ingestion persists all artifacts in a single SurrealDB transaction per task (atomic replace by task id)
+179 -156
View File
@@ -8,183 +8,207 @@
* - Rubberbanding Scroll * - Rubberbanding Scroll
*/ */
(function() { (() => {
'use strict'; // === SCROLL-LINKED NAVBAR SHADOW ===
function initScrollShadow() {
const mainContent = document.querySelector("main");
const navbar = document.querySelector("nav");
if (!mainContent || !navbar) return;
// === SCROLL-LINKED NAVBAR SHADOW === mainContent.addEventListener(
function initScrollShadow() { "scroll",
const mainContent = document.querySelector('main'); () => {
const navbar = document.querySelector('nav'); const scrollTop = mainContent.scrollTop;
if (!mainContent || !navbar) return; const scrollHeight =
mainContent.scrollHeight - mainContent.clientHeight;
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0;
navbar.style.setProperty("--scroll-depth", scrollDepth.toFixed(2));
},
{ passive: true },
);
}
mainContent.addEventListener('scroll', () => { // === HTMX SWAP ANIMATION ===
const scrollTop = mainContent.scrollTop; function initHtmxSwapAnimation() {
const scrollHeight = mainContent.scrollHeight - mainContent.clientHeight; document.body.addEventListener("htmx:afterSwap", (event) => {
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0; let target = event.detail.target;
navbar.style.setProperty('--scroll-depth', scrollDepth.toFixed(2)); if (!target) return;
}, { passive: true });
}
// === HTMX SWAP ANIMATION === // If full body swap (hx-boost), animate only the main content
function initHtmxSwapAnimation() { if (target.tagName === "BODY") {
document.body.addEventListener('htmx:afterSwap', (event) => { const main = document.querySelector("main");
let target = event.detail.target; if (main) target = main;
if (!target) return; }
// If full body swap (hx-boost), animate only the main content // Only animate if target is valid and inside/is main content or a card/panel
if (target.tagName === 'BODY') { // Avoid animating sidebar or navbar updates
const main = document.querySelector('main'); if (target && (target.tagName === "MAIN" || target.closest("main"))) {
if (main) target = main; if (!target.classList.contains("animate-fade-up")) {
} target.classList.add("animate-fade-up");
// Remove class after animation completes to allow re-animation
setTimeout(() => {
target.classList.remove("animate-fade-up");
}, 250);
}
}
});
}
// Only animate if target is valid and inside/is main content or a card/panel // === TYPEWRITER AI RESPONSE ===
// Avoid animating sidebar or navbar updates // Works with SSE streaming - buffers text and reveals character by character
if (target && (target.tagName === 'MAIN' || target.closest('main'))) { window.initTypewriter = (element, options = {}) => {
if (!target.classList.contains('animate-fade-up')) { const { minDelay = 5, maxDelay = 15, showCursor = true } = options;
target.classList.add('animate-fade-up');
// Remove class after animation completes to allow re-animation
setTimeout(() => {
target.classList.remove('animate-fade-up');
}, 250);
}
}
});
}
// === TYPEWRITER AI RESPONSE === let buffer = "";
// Works with SSE streaming - buffers text and reveals character by character let isTyping = false;
window.initTypewriter = function(element, options = {}) { let cursorElement = null;
const {
minDelay = 5,
maxDelay = 15,
showCursor = true
} = options;
let buffer = ''; if (showCursor) {
let isTyping = false; cursorElement = document.createElement("span");
let cursorElement = null; cursorElement.className = "typewriter-cursor";
cursorElement.textContent = "▌";
cursorElement.style.animation = "blink 1s step-end infinite";
element.appendChild(cursorElement);
}
if (showCursor) { function typeNextChar() {
cursorElement = document.createElement('span'); if (buffer.length === 0) {
cursorElement.className = 'typewriter-cursor'; isTyping = false;
cursorElement.textContent = '▌'; return;
cursorElement.style.animation = 'blink 1s step-end infinite'; }
element.appendChild(cursorElement);
}
function typeNextChar() { isTyping = true;
if (buffer.length === 0) { const char = buffer.charAt(0);
isTyping = false; buffer = buffer.slice(1);
return;
}
isTyping = true; // Insert before cursor
const char = buffer.charAt(0); if (cursorElement && cursorElement.parentNode) {
buffer = buffer.slice(1); const textNode = document.createTextNode(char);
element.insertBefore(textNode, cursorElement);
} else {
element.textContent += char;
}
// Insert before cursor const delay = minDelay + Math.random() * (maxDelay - minDelay);
if (cursorElement && cursorElement.parentNode) { setTimeout(typeNextChar, delay);
const textNode = document.createTextNode(char); }
element.insertBefore(textNode, cursorElement);
} else {
element.textContent += char;
}
const delay = minDelay + Math.random() * (maxDelay - minDelay); return {
setTimeout(typeNextChar, delay); append: (text) => {
} buffer += text;
if (!isTyping) {
typeNextChar();
}
},
complete: () => {
// Flush remaining buffer immediately
if (cursorElement && cursorElement.parentNode) {
const textNode = document.createTextNode(buffer);
element.insertBefore(textNode, cursorElement);
cursorElement.remove();
} else {
element.textContent += buffer;
}
buffer = "";
isTyping = false;
},
};
};
return { // === RUBBERBANDING SCROLL ===
append: function(text) { function attachRubberbanding(
buffer += text; container,
if (!isTyping) { { maxPull = 60, resistance = 0.4 } = {},
typeNextChar(); ) {
} let startY = 0;
}, let pulling = false;
complete: function() {
// Flush remaining buffer immediately
if (cursorElement && cursorElement.parentNode) {
const textNode = document.createTextNode(buffer);
element.insertBefore(textNode, cursorElement);
cursorElement.remove();
} else {
element.textContent += buffer;
}
buffer = '';
isTyping = false;
}
};
};
// === RUBBERBANDING SCROLL === function applyPull(distance) {
function initRubberbanding() { container.style.transform = `translateY(${distance}px)`;
const containers = document.querySelectorAll('#chat-scroll-container, .content-scroll-container'); }
containers.forEach(container => { function release() {
let startY = 0; container.style.transition =
let pulling = false; "transform 300ms cubic-bezier(0.25, 1, 0.5, 1)";
let pullDistance = 0; container.style.transform = "translateY(0)";
const maxPull = 60; setTimeout(() => {
const resistance = 0.4; container.style.transition = "";
}, 300);
pulling = false;
}
container.addEventListener('touchstart', (e) => { function isAtTop() {
startY = e.touches[0].clientY; return container.scrollTop <= 0;
}, { passive: true }); }
function isAtBottom() {
return (
container.scrollTop + container.clientHeight >= container.scrollHeight
);
}
container.addEventListener('touchmove', (e) => { container.addEventListener(
const currentY = e.touches[0].clientY; "touchstart",
const diff = currentY - startY; (e) => {
startY = e.touches[0].clientY;
},
{ passive: true },
);
// At top boundary, pulling down container.addEventListener(
if (container.scrollTop <= 0 && diff > 0) { "touchmove",
pulling = true; (e) => {
pullDistance = Math.min(diff * resistance, maxPull); const diff = e.touches[0].clientY - startY;
container.style.transform = `translateY(${pullDistance}px)`; const isPullingDown = diff > 0 && isAtTop();
} const isPullingUp = diff < 0 && isAtBottom();
// At bottom boundary, pulling up
else if (container.scrollTop + container.clientHeight >= container.scrollHeight && diff < 0) {
pulling = true;
pullDistance = Math.max(diff * resistance, -maxPull);
container.style.transform = `translateY(${pullDistance}px)`;
}
}, { passive: true });
container.addEventListener('touchend', () => { if (isPullingDown) {
if (pulling) { pulling = true;
container.style.transition = 'transform 300ms cubic-bezier(0.25, 1, 0.5, 1)'; applyPull(Math.min(diff * resistance, maxPull));
container.style.transform = 'translateY(0)'; } else if (isPullingUp) {
setTimeout(() => { pulling = true;
container.style.transition = ''; applyPull(Math.max(diff * resistance, -maxPull));
}, 300); }
pulling = false; },
pullDistance = 0; { passive: true },
} );
}, { passive: true });
});
}
// === INITIALIZATION === container.addEventListener(
function init() { "touchend",
initScrollShadow(); () => {
initHtmxSwapAnimation(); if (pulling) release();
initRubberbanding(); },
} { passive: true },
);
}
// Run on DOMContentLoaded function initRubberbanding() {
if (document.readyState === 'loading') { document
document.addEventListener('DOMContentLoaded', init); .querySelectorAll("#chat-scroll-container, .content-scroll-container")
} else { .forEach((container) => attachRubberbanding(container));
init(); }
}
// Re-init rubberbanding after HTMX navigations // === INITIALIZATION ===
document.body.addEventListener('htmx:afterSettle', () => { function init() {
initRubberbanding(); initScrollShadow();
}); initHtmxSwapAnimation();
initRubberbanding();
}
// Add typewriter cursor blink animation // Run on DOMContentLoaded
const style = document.createElement('style'); if (document.readyState === "loading") {
style.textContent = ` document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Re-init rubberbanding after HTMX navigations
document.body.addEventListener("htmx:afterSettle", () => {
initRubberbanding();
});
// Add typewriter cursor blink animation
const style = document.createElement("style");
style.textContent = `
@keyframes blink { @keyframes blink {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0; } 50% { opacity: 0; }
@@ -194,6 +218,5 @@
font-weight: bold; font-weight: bold;
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
})(); })();
File diff suppressed because it is too large Load Diff