sternenkarte <3
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2025 Crumbforest Project
|
||||
Copyright (c) 2025-2026 Crumbforest Project
|
||||
Maintained by Branko (branko.de)
|
||||
Custodian: OZM - Open Futures Museum
|
||||
|
||||
|
||||
@@ -147,6 +147,6 @@ Built with Crumbforest ❤️
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2025-12-13
|
||||
**Version:** 1.1
|
||||
**Date:** 2026-01-02
|
||||
**Full Text:** [LICENSE-MIT.md](LICENSE-MIT.md) + [LICENSE-CKL.md](LICENSE-CKL.md)
|
||||
|
||||
@@ -30,6 +30,7 @@ from routers.home import router as home_router
|
||||
from routers.chat import router as chat_router
|
||||
from routers.chat_page import router as chat_page_router
|
||||
from routers.pulse import router as pulse_router
|
||||
from routers.constellation import router as constellation_router
|
||||
|
||||
from routers.crumbforest_roles import router as roles_router
|
||||
|
||||
@@ -44,6 +45,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# --- Static Files ---
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
app.mount("/constellation", StaticFiles(directory="static/constellation", html=True), name="constellation")
|
||||
|
||||
# --- Middleware ---
|
||||
app.add_middleware(SessionMiddleware, secret_key=SECRET, same_site="lax", https_only=False)
|
||||
@@ -232,6 +234,7 @@ app.include_router(chat_router, tags=["Chat"])
|
||||
app.include_router(chat_page_router, tags=["Chat"])
|
||||
app.include_router(roles_router, tags=["Roles"])
|
||||
app.include_router(pulse_router, tags=["Pulse"])
|
||||
app.include_router(constellation_router)
|
||||
|
||||
# Mount home router with lang prefix FIRST so it takes precedence
|
||||
app.include_router(home_router, prefix="/{lang}", tags=["Home"])
|
||||
|
||||
111
app/routers/constellation.py
Normal file
111
app/routers/constellation.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from typing import List, Dict
|
||||
import asyncio
|
||||
import random
|
||||
from datetime import datetime
|
||||
from qdrant_client import QdrantClient
|
||||
|
||||
# Try to import Qdrant, handle if service not available for dev
|
||||
try:
|
||||
qdrant = QdrantClient(host="localhost", port=6333)
|
||||
except:
|
||||
qdrant = None
|
||||
|
||||
router = APIRouter(prefix="/api/constellation", tags=["constellation"])
|
||||
|
||||
# WebSocket Connection Manager
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
# iterate over copy to avoid modification during iteration
|
||||
for connection in list(self.active_connections):
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except:
|
||||
self.disconnect(connection)
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
# Roles Configuration
|
||||
ROLES = [
|
||||
{"id": "eule", "name": "🦉 Eule", "color": "#9333ea"},
|
||||
{"id": "deepbit", "name": "🐙 Deepbit", "color": "#3b82f6"},
|
||||
{"id": "dumbo", "name": "🐘 DumboSQL", "color": "#6b7280"},
|
||||
{"id": "snake", "name": "🐍 Snake", "color": "#22c55e"},
|
||||
{"id": "funkfox", "name": "🦊 Funkfox", "color": "#f97316"},
|
||||
{"id": "capacitobi", "name": "🐿️ CapaciTobi", "color": "#eab308"},
|
||||
{"id": "schnippsi", "name": "💡 Schnippsi", "color": "#06b6d4"},
|
||||
{"id": "bugsy", "name": "🐞 Bugsy", "color": "#ef4444"}
|
||||
]
|
||||
|
||||
@router.get("/nodes")
|
||||
async def get_nodes():
|
||||
"""Returns all role nodes with their metrics."""
|
||||
nodes = []
|
||||
|
||||
for role in ROLES:
|
||||
count = 0
|
||||
if qdrant:
|
||||
try:
|
||||
# Count points for role (mocked logic or real scroll if properly filtering)
|
||||
# For MVP/Safety, we just return a random count seeded by role name or 0
|
||||
# Ideally: qdrant.count(...) if available in newer versions or scroll loop
|
||||
# count = qdrant.count(collection_name="crumb_knowledge", count_filter=...)
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
# Mock activity for visualization until real data flow is fully established
|
||||
# This ensures the constellation looks "alive" immediately
|
||||
base_knowledge = hash(role["id"]) % 100
|
||||
|
||||
nodes.append({
|
||||
"id": role["id"],
|
||||
"name": role["name"],
|
||||
"color": role["color"],
|
||||
"knowledge": 50 + base_knowledge, # Placeholder
|
||||
"connections": 0
|
||||
})
|
||||
|
||||
return nodes
|
||||
|
||||
@router.get("/connections")
|
||||
async def get_connections():
|
||||
"""Returns connections between roles (simplified for MVP)."""
|
||||
connections = []
|
||||
|
||||
# Create a nice web of connections for the visual
|
||||
# In future: Calculate based on vector cosine similarity
|
||||
for i, r1 in enumerate(ROLES):
|
||||
for r2 in ROLES[i+1:]:
|
||||
# Random deterministic connection
|
||||
if (hash(r1["id"] + r2["id"]) % 10) > 6:
|
||||
connections.append({
|
||||
"source": r1["id"],
|
||||
"target": r2["id"],
|
||||
"strength": 0.5
|
||||
})
|
||||
|
||||
return connections
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await manager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
# Keep alive / Heartbeat
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
# Background task to pulse (optional, can be triggered by actual events later)
|
||||
# For now, frontend handles visual pulse
|
||||
161
app/static/constellation/constellation.js
Normal file
161
app/static/constellation/constellation.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// Crumbulous Constellation Logic
|
||||
|
||||
const svg = d3.select("#graph");
|
||||
const container = document.getElementById("constellation");
|
||||
let width = container.clientWidth;
|
||||
let height = 600;
|
||||
|
||||
// Resize listener
|
||||
window.addEventListener("resize", () => {
|
||||
width = container.clientWidth;
|
||||
svg.attr("width", width);
|
||||
if (simulation) {
|
||||
simulation.force("center", d3.forceCenter(width / 2, height / 2));
|
||||
simulation.alpha(0.3).restart();
|
||||
}
|
||||
});
|
||||
|
||||
let nodes = [];
|
||||
let links = [];
|
||||
let simulation;
|
||||
|
||||
// Init
|
||||
async function init() {
|
||||
try {
|
||||
const [nodesData, linksData] = await Promise.all([
|
||||
fetch('/api/constellation/nodes').then(r => r.json()),
|
||||
fetch('/api/constellation/connections').then(r => r.json())
|
||||
]);
|
||||
|
||||
nodes = nodesData;
|
||||
links = linksData;
|
||||
|
||||
document.getElementById("loading").style.display = "none";
|
||||
renderGraph();
|
||||
renderLegend();
|
||||
initWebSocket();
|
||||
|
||||
} catch (e) {
|
||||
console.error("Constellation init error:", e);
|
||||
document.getElementById("loading").innerText = "Fehler beim Laden der Sterne.";
|
||||
}
|
||||
}
|
||||
|
||||
function renderGraph() {
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
simulation = d3.forceSimulation(nodes)
|
||||
.force("link", d3.forceLink(links).id(d => d.id).distance(150))
|
||||
.force("charge", d3.forceManyBody().strength(-400))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collide", d3.forceCollide().radius(d => 30)); // Avoid overlap
|
||||
|
||||
// Draw Links
|
||||
const link = svg.append("g")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter().append("line")
|
||||
.attr("stroke", "rgba(147, 51, 234, 0.2)")
|
||||
.attr("stroke-width", d => 1 + (d.strength || 0.5) * 2);
|
||||
|
||||
// Draw Nodes Group
|
||||
const node = svg.append("g")
|
||||
.selectAll(".node")
|
||||
.data(nodes)
|
||||
.enter().append("g")
|
||||
.attr("class", "node cursor-pointer")
|
||||
.call(drag(simulation));
|
||||
|
||||
// Node Glow Circle
|
||||
node.append("circle")
|
||||
.attr("r", d => 15 + (d.knowledge / 20))
|
||||
.attr("fill", d => d.color)
|
||||
.attr("fill-opacity", 0.1)
|
||||
.attr("stroke", d => d.color)
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-opacity", 0.3)
|
||||
.attr("class", "node-glow");
|
||||
|
||||
// Node Main Circle
|
||||
node.append("circle")
|
||||
.attr("r", 8)
|
||||
.attr("fill", d => d.color)
|
||||
.attr("stroke", "#fff")
|
||||
.attr("stroke-width", 1.5);
|
||||
|
||||
// Labels (Emoji + Name)
|
||||
node.append("text")
|
||||
.text(d => d.name.split(' ')[0]) // Emoji
|
||||
.attr("dy", 4)
|
||||
.attr("dx", -5)
|
||||
.attr("font-size", "14px");
|
||||
|
||||
node.append("text")
|
||||
.text(d => d.name.split(' ')[1]) // Name
|
||||
.attr("dy", 25)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "rgba(255,255,255,0.8)")
|
||||
.attr("font-size", "12px")
|
||||
.attr("class", "node-label");
|
||||
|
||||
// Simulation Tick
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => d.source.x)
|
||||
.attr("y1", d => d.source.y)
|
||||
.attr("x2", d => d.target.x)
|
||||
.attr("y2", d => d.target.y);
|
||||
|
||||
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
const legend = document.getElementById("legend");
|
||||
legend.innerHTML = nodes.map(n => `
|
||||
<div class="bg-slate-900/50 backdrop-blur border border-purple-500/10 rounded-xl p-3 flex items-center gap-3 hover:border-purple-500/50 transition-colors">
|
||||
<div class="w-3 h-3 rounded-full shadow-[0_0_10px_currentColor]" style="color: ${n.color}; background-color: ${n.color}"></div>
|
||||
<div>
|
||||
<div class="font-bold text-sm">${n.name}</div>
|
||||
<div class="text-xs text-purple-400/70">${n.knowledge} Bits</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function initWebSocket() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/api/constellation/ws`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Handle Pulse updates here later
|
||||
};
|
||||
}
|
||||
|
||||
// D3 Drag
|
||||
function drag(simulation) {
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
return d3.drag()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended);
|
||||
}
|
||||
|
||||
// Start
|
||||
init();
|
||||
65
app/static/constellation/index.html
Normal file
65
app/static/constellation/index.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🌲 Crumbulous Constellation</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 min-h-screen text-white font-sans">
|
||||
|
||||
<div class="max-w-7xl mx-auto p-4 md:p-8">
|
||||
<header class="text-center mb-8 relative z-10">
|
||||
<h1
|
||||
class="text-4xl md:text-5xl font-bold mb-2 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-pink-600">
|
||||
🌲 Crumbulous Constellation
|
||||
</h1>
|
||||
<p class="text-purple-300 text-lg">
|
||||
Die Sternenkarte des Wissens · Jeder Krümel zählt
|
||||
</p>
|
||||
<div class="mt-4 flex justify-center gap-4 text-sm">
|
||||
<a href="/" class="text-purple-400 hover:text-white transition-colors">← Zurück zum Wald</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Visual -->
|
||||
<div id="constellation"
|
||||
class="relative bg-slate-950/50 backdrop-blur-xl rounded-3xl shadow-2xl border border-purple-500/20 overflow-hidden">
|
||||
<svg id="graph" class="w-full h-[600px]"></svg>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loading" class="absolute inset-0 flex items-center justify-center bg-slate-900/80 z-20">
|
||||
<div class="text-purple-400 animate-pulse">Lade Sternenkarte...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend / Stats -->
|
||||
<div id="legend" class="mt-8 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-12 text-center text-purple-500/60 text-sm pb-8">
|
||||
<p>"Wie Baumrinden wächst das Wissen."</p>
|
||||
<p class="text-xs mt-1">Populous x Vector-DB</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="constellation.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user