mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(manacore/web): add spiral module with activity collection and page
Add spiral module (stores, components, data collection) and /spiral route to ManaCore web. Wire up navigation entry and command palette shortcut. Add spiral-db workspace dependency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1cbd9a25a6
commit
9c0613d920
8 changed files with 1720 additions and 344 deletions
|
|
@ -67,6 +67,7 @@
|
|||
"@manacore/shared-uload": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manacore/spiral-db": "workspace:*",
|
||||
"@manacore/wallpaper-generator": "workspace:*",
|
||||
"@calc/shared": "workspace:*",
|
||||
"@clock/shared": "workspace:*",
|
||||
|
|
|
|||
227
apps/manacore/apps/web/src/lib/modules/spiral/collect.ts
Normal file
227
apps/manacore/apps/web/src/lib/modules/spiral/collect.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* Cross-App Activity Collector
|
||||
*
|
||||
* Reads from all cross-app IndexedDB readers and produces
|
||||
* AppSnapshot objects for the Mana Spiral.
|
||||
*/
|
||||
|
||||
import { MANA_APP_INDEX } from '@manacore/spiral-db';
|
||||
import {
|
||||
crossTaskCollection,
|
||||
crossEventCollection,
|
||||
crossContactCollection,
|
||||
crossConversationCollection,
|
||||
crossFavoriteCollection,
|
||||
crossImageCollection,
|
||||
crossAlarmCollection,
|
||||
crossFileCollection,
|
||||
crossSongCollection,
|
||||
crossPresiDeckCollection,
|
||||
crossSpaceCollection,
|
||||
crossCardsDeckCollection,
|
||||
crossCardsCardCollection,
|
||||
type CrossAppTask,
|
||||
type CrossAppContact,
|
||||
type CrossAppImage,
|
||||
} from '$lib/data/cross-app-stores';
|
||||
import type { AppSnapshot } from './stores/mana-spiral.svelte';
|
||||
|
||||
/**
|
||||
* Collect snapshots from all cross-app readers.
|
||||
* Each collection is read once and summarized into an AppSnapshot.
|
||||
*/
|
||||
export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
|
||||
const snapshots: AppSnapshot[] = [];
|
||||
|
||||
// Run all reads in parallel
|
||||
const [
|
||||
tasks,
|
||||
events,
|
||||
contacts,
|
||||
conversations,
|
||||
favorites,
|
||||
images,
|
||||
alarms,
|
||||
files,
|
||||
songs,
|
||||
decks,
|
||||
spaces,
|
||||
cardDecks,
|
||||
cards,
|
||||
] = await Promise.all([
|
||||
safeGetAll(crossTaskCollection),
|
||||
safeGetAll(crossEventCollection),
|
||||
safeGetAll(crossContactCollection),
|
||||
safeGetAll(crossConversationCollection),
|
||||
safeGetAll(crossFavoriteCollection),
|
||||
safeGetAll(crossImageCollection),
|
||||
safeGetAll(crossAlarmCollection),
|
||||
safeGetAll(crossFileCollection),
|
||||
safeGetAll(crossSongCollection),
|
||||
safeGetAll(crossPresiDeckCollection),
|
||||
safeGetAll(crossSpaceCollection),
|
||||
safeGetAll(crossCardsDeckCollection),
|
||||
safeGetAll(crossCardsCardCollection),
|
||||
]);
|
||||
|
||||
// Todo
|
||||
if (tasks.length > 0) {
|
||||
const completed = (tasks as CrossAppTask[]).filter((t) => t.isCompleted).length;
|
||||
snapshots.push({
|
||||
app: 'Todo',
|
||||
appIndex: MANA_APP_INDEX.todo,
|
||||
totalItems: tasks.length,
|
||||
completedItems: completed,
|
||||
favoriteItems: 0,
|
||||
label: `${tasks.length} Tasks (${completed} erledigt)`,
|
||||
});
|
||||
}
|
||||
|
||||
// Calendar
|
||||
if (events.length > 0) {
|
||||
snapshots.push({
|
||||
app: 'Calendar',
|
||||
appIndex: MANA_APP_INDEX.calendar,
|
||||
totalItems: events.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: 0,
|
||||
label: `${events.length} Events`,
|
||||
});
|
||||
}
|
||||
|
||||
// Contacts
|
||||
if (contacts.length > 0) {
|
||||
const favs = (contacts as CrossAppContact[]).filter((c) => c.isFavorite).length;
|
||||
snapshots.push({
|
||||
app: 'Contacts',
|
||||
appIndex: MANA_APP_INDEX.contacts,
|
||||
totalItems: contacts.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: favs,
|
||||
label: `${contacts.length} Kontakte`,
|
||||
});
|
||||
}
|
||||
|
||||
// Chat
|
||||
if (conversations.length > 0) {
|
||||
snapshots.push({
|
||||
app: 'Chat',
|
||||
appIndex: MANA_APP_INDEX.chat,
|
||||
totalItems: conversations.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: 0,
|
||||
label: `${conversations.length} Gespräche`,
|
||||
});
|
||||
}
|
||||
|
||||
// Zitare
|
||||
if (favorites.length > 0) {
|
||||
snapshots.push({
|
||||
app: 'Zitare',
|
||||
appIndex: MANA_APP_INDEX.zitare,
|
||||
totalItems: favorites.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: favorites.length,
|
||||
label: `${favorites.length} Favoriten`,
|
||||
});
|
||||
}
|
||||
|
||||
// Picture
|
||||
if (images.length > 0) {
|
||||
const favs = (images as CrossAppImage[]).filter((i) => i.isFavorite).length;
|
||||
snapshots.push({
|
||||
app: 'Picture',
|
||||
appIndex: MANA_APP_INDEX.picture,
|
||||
totalItems: images.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: favs,
|
||||
label: `${images.length} Bilder`,
|
||||
});
|
||||
}
|
||||
|
||||
// Clock
|
||||
if (alarms.length > 0) {
|
||||
snapshots.push({
|
||||
app: 'Clock',
|
||||
appIndex: MANA_APP_INDEX.clock,
|
||||
totalItems: alarms.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: 0,
|
||||
label: `${alarms.length} Alarme`,
|
||||
});
|
||||
}
|
||||
|
||||
// Storage
|
||||
if (files.length > 0) {
|
||||
snapshots.push({
|
||||
app: 'Storage',
|
||||
appIndex: MANA_APP_INDEX.storage,
|
||||
totalItems: files.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: 0,
|
||||
label: `${files.length} Dateien`,
|
||||
});
|
||||
}
|
||||
|
||||
// Mukke
|
||||
if (songs.length > 0) {
|
||||
snapshots.push({
|
||||
app: 'Mukke',
|
||||
appIndex: MANA_APP_INDEX.mukke,
|
||||
totalItems: songs.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: 0,
|
||||
label: `${songs.length} Songs`,
|
||||
});
|
||||
}
|
||||
|
||||
// Presi
|
||||
if (decks.length > 0) {
|
||||
snapshots.push({
|
||||
app: 'Presi',
|
||||
appIndex: MANA_APP_INDEX.presi,
|
||||
totalItems: decks.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: 0,
|
||||
label: `${decks.length} Präsentationen`,
|
||||
});
|
||||
}
|
||||
|
||||
// Context
|
||||
if (spaces.length > 0) {
|
||||
snapshots.push({
|
||||
app: 'Context',
|
||||
appIndex: MANA_APP_INDEX.context,
|
||||
totalItems: spaces.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: 0,
|
||||
label: `${spaces.length} Spaces`,
|
||||
});
|
||||
}
|
||||
|
||||
// Cards
|
||||
if (cardDecks.length > 0 || cards.length > 0) {
|
||||
snapshots.push({
|
||||
app: 'Cards',
|
||||
appIndex: MANA_APP_INDEX.cards,
|
||||
totalItems: cards.length,
|
||||
completedItems: 0,
|
||||
favoriteItems: 0,
|
||||
label: `${cardDecks.length} Decks, ${cards.length} Karten`,
|
||||
});
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe wrapper for collection.getAll() — returns empty array on error
|
||||
* (e.g. if the other app's DB doesn't exist yet)
|
||||
*/
|
||||
async function safeGetAll(collection: { getAll: () => Promise<unknown[]> }): Promise<unknown[]> {
|
||||
try {
|
||||
return await collection.getAll();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
<script lang="ts">
|
||||
import type { SpiralImage } from '@manacore/spiral-db';
|
||||
import { spiralToXY, xyToSpiral } from '@manacore/spiral-db';
|
||||
|
||||
interface Props {
|
||||
image: SpiralImage;
|
||||
scale?: number;
|
||||
showGrid?: boolean;
|
||||
highlightIndex?: number | null;
|
||||
onPixelClick?: (index: number, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
image,
|
||||
scale = 10,
|
||||
showGrid = false,
|
||||
highlightIndex = null,
|
||||
onPixelClick,
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let hoveredIndex = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas || !image) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const { width, height, pixels } = image;
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const offset = (y * width + x) * 3;
|
||||
const r = pixels[offset];
|
||||
const g = pixels[offset + 1];
|
||||
const b = pixels[offset + 2];
|
||||
|
||||
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
ctx.fillRect(x * scale, y * scale, scale, scale);
|
||||
}
|
||||
}
|
||||
|
||||
if (showGrid && scale >= 8) {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x <= width; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * scale, 0);
|
||||
ctx.lineTo(x * scale, height * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= height; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * scale);
|
||||
ctx.lineTo(width * scale, y * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Center pixel highlight
|
||||
const center = Math.floor(width / 2);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(center * scale, center * scale, scale, scale);
|
||||
|
||||
if (highlightIndex !== null && highlightIndex >= 0) {
|
||||
const point = spiralToXY(highlightIndex, width);
|
||||
ctx.strokeStyle = '#fbbf24';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
|
||||
}
|
||||
|
||||
if (hoveredIndex !== null) {
|
||||
const point = spiralToXY(hoveredIndex, width);
|
||||
ctx.strokeStyle = '#8b5cf6';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
|
||||
}
|
||||
});
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!canvas || !image) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / scale);
|
||||
const y = Math.floor((e.clientY - rect.top) / scale);
|
||||
|
||||
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
|
||||
hoveredIndex = xyToSpiral(x, y, image.width);
|
||||
} else {
|
||||
hoveredIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
hoveredIndex = null;
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!canvas || !image || !onPixelClick) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / scale);
|
||||
const y = Math.floor((e.clientY - rect.top) / scale);
|
||||
|
||||
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
|
||||
const index = xyToSpiral(x, y, image.width);
|
||||
onPixelClick(index, x, y);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="spiral-canvas-container">
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseleave={handleMouseLeave}
|
||||
onclick={handleClick}
|
||||
class="spiral-canvas"
|
||||
class:clickable={!!onPixelClick}
|
||||
></canvas>
|
||||
|
||||
{#if hoveredIndex !== null}
|
||||
<div class="pixel-info">
|
||||
Pixel #{hoveredIndex}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spiral-canvas-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.spiral-canvas {
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 20px rgba(0, 0, 0, 0.3),
|
||||
0 0 40px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.spiral-canvas.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pixel-info {
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
12
apps/manacore/apps/web/src/lib/modules/spiral/index.ts
Normal file
12
apps/manacore/apps/web/src/lib/modules/spiral/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Mana Spiral module — barrel exports.
|
||||
*/
|
||||
|
||||
export { manaSpiralStore } from './stores/mana-spiral.svelte';
|
||||
export type {
|
||||
ManaActivityData,
|
||||
ManaActivityRecord,
|
||||
ManaSpiralStats,
|
||||
AppSnapshot,
|
||||
} from './stores/mana-spiral.svelte';
|
||||
export { collectAppSnapshots } from './collect';
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* Mana Spiral Store
|
||||
*
|
||||
* Unified cross-app spiral visualization.
|
||||
* Collects activity snapshots from all apps' IndexedDB collections
|
||||
* and encodes them into a single SpiralDB image.
|
||||
*/
|
||||
|
||||
import {
|
||||
SpiralDB,
|
||||
createManaActivitySchema,
|
||||
MANA_APP_INDEX,
|
||||
MANA_APP_NAMES,
|
||||
MANA_EVENT_TYPE,
|
||||
MANA_EVENT_NAMES,
|
||||
type SpiralImage,
|
||||
type SpiralRecord,
|
||||
exportToPngBytes,
|
||||
importFromPngBytes,
|
||||
downloadPng,
|
||||
} from '@manacore/spiral-db';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────
|
||||
|
||||
export interface ManaActivityData extends Record<string, unknown> {
|
||||
id: number;
|
||||
app: number;
|
||||
eventType: number;
|
||||
value: number;
|
||||
createdAt: Date;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ManaActivityRecord extends SpiralRecord<ManaActivityData> {}
|
||||
|
||||
export interface ManaSpiralStats {
|
||||
imageSize: number;
|
||||
totalPixels: number;
|
||||
usedPixels: number;
|
||||
totalRecords: number;
|
||||
activeRecords: number;
|
||||
deletedRecords: number;
|
||||
currentRing: number;
|
||||
compressionRatio: number;
|
||||
}
|
||||
|
||||
export interface AppSnapshot {
|
||||
app: string;
|
||||
appIndex: number;
|
||||
totalItems: number;
|
||||
completedItems: number;
|
||||
favoriteItems: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ─── Store ─────────────────────────────────────────────────
|
||||
|
||||
class ManaSpiralStore {
|
||||
private db: SpiralDB<ManaActivityData>;
|
||||
|
||||
image = $state<SpiralImage | null>(null);
|
||||
stats = $state<ManaSpiralStats | null>(null);
|
||||
records = $state<ManaActivityRecord[]>([]);
|
||||
snapshots = $state<AppSnapshot[]>([]);
|
||||
isLoading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
lastCollectedAt = $state<Date | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.db = new SpiralDB<ManaActivityData>({
|
||||
schema: createManaActivitySchema(),
|
||||
compression: true,
|
||||
});
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
private updateState() {
|
||||
this.image = this.db.getImage();
|
||||
this.records = this.db.getAll();
|
||||
|
||||
const dbStats = this.db.getStats();
|
||||
const jsonSize = JSON.stringify(this.records.map((r) => r.data)).length || 1;
|
||||
const pixelBytes = Math.ceil((dbStats.usedPixels * 3) / 8);
|
||||
|
||||
this.stats = {
|
||||
...dbStats,
|
||||
compressionRatio: Math.round((1 - pixelBytes / jsonSize) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect snapshots from cross-app readers and build the spiral.
|
||||
* Each app contributes a snapshot event with its item counts.
|
||||
*/
|
||||
collectFromApps(appSnapshots: AppSnapshot[]) {
|
||||
// Reset DB with fresh data
|
||||
this.db = new SpiralDB<ManaActivityData>({
|
||||
schema: createManaActivitySchema(),
|
||||
compression: true,
|
||||
});
|
||||
|
||||
this.snapshots = appSnapshots;
|
||||
const now = new Date();
|
||||
|
||||
for (const snap of appSnapshots) {
|
||||
if (snap.totalItems === 0) continue;
|
||||
|
||||
// Snapshot event: total count
|
||||
this.db.insert({
|
||||
id: 0,
|
||||
app: snap.appIndex,
|
||||
eventType: MANA_EVENT_TYPE.snapshot,
|
||||
value: snap.totalItems,
|
||||
createdAt: now,
|
||||
label: snap.label,
|
||||
});
|
||||
|
||||
// Completed event (if any)
|
||||
if (snap.completedItems > 0) {
|
||||
this.db.insert({
|
||||
id: 0,
|
||||
app: snap.appIndex,
|
||||
eventType: MANA_EVENT_TYPE.completed,
|
||||
value: snap.completedItems,
|
||||
createdAt: now,
|
||||
label: `${snap.app}: ${snap.completedItems} erledigt`,
|
||||
});
|
||||
}
|
||||
|
||||
// Favorites event (if any)
|
||||
if (snap.favoriteItems > 0) {
|
||||
this.db.insert({
|
||||
id: 0,
|
||||
app: snap.appIndex,
|
||||
eventType: MANA_EVENT_TYPE.favorited,
|
||||
value: snap.favoriteItems,
|
||||
createdAt: now,
|
||||
label: `${snap.app}: ${snap.favoriteItems} Favoriten`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.lastCollectedAt = now;
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for an app index
|
||||
*/
|
||||
getAppName(index: number): string {
|
||||
return MANA_APP_NAMES[index] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for an event type index
|
||||
*/
|
||||
getEventName(index: number): string {
|
||||
return MANA_EVENT_NAMES[index] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get records grouped by app
|
||||
*/
|
||||
getRecordsByApp(): Map<string, ManaActivityRecord[]> {
|
||||
const map = new Map<string, ManaActivityRecord[]>();
|
||||
for (const record of this.records) {
|
||||
const appName = this.getAppName(record.data.app);
|
||||
const list = map.get(appName) ?? [];
|
||||
list.push(record);
|
||||
map.set(appName, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download spiral as PNG
|
||||
*/
|
||||
downloadPng(filename = 'mana-spiral.png') {
|
||||
if (this.image) {
|
||||
downloadPng(this.image, filename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PNG bytes for sharing
|
||||
*/
|
||||
getPngBytes(): Uint8Array | null {
|
||||
if (!this.image) return null;
|
||||
return exportToPngBytes(this.image);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import from a PNG file
|
||||
*/
|
||||
async importFromPng(file: File): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const image = await importFromPngBytes(bytes);
|
||||
|
||||
this.db = SpiralDB.fromImage<ManaActivityData>(image, createManaActivitySchema());
|
||||
this.updateState();
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
this.error = errorMessage;
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data
|
||||
*/
|
||||
clear() {
|
||||
this.db = new SpiralDB<ManaActivityData>({
|
||||
schema: createManaActivitySchema(),
|
||||
compression: true,
|
||||
});
|
||||
this.snapshots = [];
|
||||
this.lastCollectedAt = null;
|
||||
this.updateState();
|
||||
}
|
||||
}
|
||||
|
||||
export const manaSpiralStore = new ManaSpiralStore();
|
||||
|
|
@ -135,6 +135,7 @@
|
|||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/home', label: 'Home', icon: 'home' },
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: 'grid' },
|
||||
{ href: '/spiral', label: 'Spiral', icon: 'spiral' },
|
||||
{ href: '/observatory', label: 'Observatory', icon: 'eye' },
|
||||
{ href: '/credits', label: 'Credits', icon: 'creditCard' },
|
||||
{ href: '/gifts', label: 'Geschenke', icon: 'gift' },
|
||||
|
|
@ -307,6 +308,12 @@
|
|||
category: 'Navigation',
|
||||
onExecute: () => goto('/dashboard'),
|
||||
},
|
||||
{
|
||||
id: 'spiral',
|
||||
label: 'Mana Spiral',
|
||||
category: 'Navigation',
|
||||
onExecute: () => goto('/spiral'),
|
||||
},
|
||||
{ id: 'credits', label: 'Credits', category: 'Navigation', onExecute: () => goto('/credits') },
|
||||
{ id: 'apps', label: 'Alle Apps', category: 'Navigation', onExecute: () => goto('/apps') },
|
||||
{
|
||||
|
|
|
|||
631
apps/manacore/apps/web/src/routes/(app)/spiral/+page.svelte
Normal file
631
apps/manacore/apps/web/src/routes/(app)/spiral/+page.svelte
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { COLORS } from '@manacore/spiral-db';
|
||||
import type { ColorDefinition } from '@manacore/spiral-db';
|
||||
import SpiralCanvas from '$lib/modules/spiral/components/SpiralCanvas.svelte';
|
||||
import { manaSpiralStore } from '$lib/modules/spiral';
|
||||
import type { AppSnapshot } from '$lib/modules/spiral';
|
||||
import { collectAppSnapshots } from '$lib/modules/spiral';
|
||||
|
||||
const colorsArray: ColorDefinition[] = Object.values(COLORS);
|
||||
|
||||
// UI state
|
||||
let scale = $state(10);
|
||||
let showGrid = $state(false);
|
||||
let selectedPixel = $state<number | null>(null);
|
||||
let isCollecting = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
// App icons for display
|
||||
const APP_ICONS: Record<string, string> = {
|
||||
Todo: 'check-square',
|
||||
Calendar: 'calendar',
|
||||
Contacts: 'users',
|
||||
Chat: 'message-circle',
|
||||
Zitare: 'quote',
|
||||
Picture: 'image',
|
||||
Clock: 'clock',
|
||||
Storage: 'hard-drive',
|
||||
Mukke: 'music',
|
||||
Presi: 'presentation',
|
||||
Context: 'file-text',
|
||||
Cards: 'layers',
|
||||
};
|
||||
|
||||
// Derived
|
||||
let recordsByApp = $derived(manaSpiralStore.getRecordsByApp());
|
||||
|
||||
async function handleCollect() {
|
||||
isCollecting = true;
|
||||
try {
|
||||
const snapshots = await collectAppSnapshots();
|
||||
manaSpiralStore.collectFromApps(snapshots);
|
||||
} finally {
|
||||
isCollecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePixelClick(index: number) {
|
||||
selectedPixel = selectedPixel === index ? null : index;
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
manaSpiralStore.downloadPng();
|
||||
}
|
||||
|
||||
function handleImportClick() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const result = await manaSpiralStore.importFromPng(file);
|
||||
if (!result.success) {
|
||||
alert(`Import fehlgeschlagen: ${result.error}`);
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (confirm('Alle Spiral-Daten löschen?')) {
|
||||
manaSpiralStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-collect on mount
|
||||
onMount(() => {
|
||||
handleCollect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mana Spiral</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="spiral-page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Mana Spiral</h1>
|
||||
<p class="page-subtitle">Dein digitaler Fussabdruck — alle Apps in einer Spirale</p>
|
||||
</header>
|
||||
|
||||
<div class="content-grid">
|
||||
<!-- Visualization -->
|
||||
<section class="section viz-section">
|
||||
<div class="viz-header">
|
||||
<h2>Visualisierung</h2>
|
||||
<div class="viz-controls">
|
||||
<label class="control">
|
||||
<span>Zoom</span>
|
||||
<input type="range" min="4" max="20" bind:value={scale} />
|
||||
<span class="mono">{scale}x</span>
|
||||
</label>
|
||||
<label class="control">
|
||||
<input type="checkbox" bind:checked={showGrid} />
|
||||
<span>Grid</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viz-container">
|
||||
{#if manaSpiralStore.image}
|
||||
<SpiralCanvas
|
||||
image={manaSpiralStore.image}
|
||||
{scale}
|
||||
{showGrid}
|
||||
highlightIndex={selectedPixel}
|
||||
onPixelClick={handlePixelClick}
|
||||
/>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<p>Keine Daten. Klicke "Daten sammeln" um deine Spirale zu generieren.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedPixel !== null}
|
||||
<div class="pixel-detail">
|
||||
Pixel <code>#{selectedPixel}</code>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Stats -->
|
||||
{#if manaSpiralStore.stats}
|
||||
<section class="section">
|
||||
<h2 class="section-title">Statistiken</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-value">
|
||||
{manaSpiralStore.stats.imageSize}x{manaSpiralStore.stats.imageSize}
|
||||
</span>
|
||||
<span class="stat-label">Bildgrösse</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{manaSpiralStore.stats.activeRecords}</span>
|
||||
<span class="stat-label">Events</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{manaSpiralStore.stats.usedPixels}</span>
|
||||
<span class="stat-label">Pixel belegt</span>
|
||||
</div>
|
||||
<div class="stat highlight">
|
||||
<span class="stat-value">{manaSpiralStore.stats.compressionRatio}%</span>
|
||||
<span class="stat-label">Kompression</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">Ring {manaSpiralStore.stats.currentRing}</span>
|
||||
<span class="stat-label">Aktueller Ring</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{manaSpiralStore.snapshots.length}</span>
|
||||
<span class="stat-label">Apps aktiv</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if manaSpiralStore.lastCollectedAt}
|
||||
<p class="collected-at">
|
||||
Zuletzt gesammelt: {manaSpiralStore.lastCollectedAt.toLocaleTimeString('de-DE')}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- App Breakdown -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
Apps
|
||||
{#if manaSpiralStore.snapshots.length > 0}
|
||||
<span class="badge">{manaSpiralStore.snapshots.length}</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
{#if manaSpiralStore.snapshots.length === 0}
|
||||
<p class="empty-hint">Noch keine App-Daten gesammelt.</p>
|
||||
{:else}
|
||||
<div class="app-list">
|
||||
{#each manaSpiralStore.snapshots as snap}
|
||||
{@const appRecords = recordsByApp.get(snap.app.toLowerCase()) ?? []}
|
||||
<div class="app-card">
|
||||
<div class="app-header">
|
||||
<span class="app-name">{snap.app}</span>
|
||||
<span class="app-count">{snap.totalItems}</span>
|
||||
</div>
|
||||
<div class="app-bar">
|
||||
<div
|
||||
class="app-bar-fill"
|
||||
style="width: {Math.min(
|
||||
100,
|
||||
(snap.totalItems /
|
||||
Math.max(1, ...manaSpiralStore.snapshots.map((s) => s.totalItems))) *
|
||||
100
|
||||
)}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="app-details">
|
||||
<span class="app-label">{snap.label}</span>
|
||||
<span class="app-events mono">{appRecords.length} Events</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Color Legend -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Farbpalette (3-Bit)</h2>
|
||||
<div class="color-legend">
|
||||
{#each colorsArray as color}
|
||||
<div class="color-item">
|
||||
<span
|
||||
class="color-swatch"
|
||||
style="background: rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b})"
|
||||
></span>
|
||||
<span class="color-name">{color.name}</span>
|
||||
<span class="color-bits mono">{color.bits.join('')}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Aktionen</h2>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick={handleCollect} disabled={isCollecting}>
|
||||
{isCollecting ? 'Sammle...' : 'Daten sammeln'}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
onclick={handleDownload}
|
||||
disabled={!manaSpiralStore.stats || manaSpiralStore.stats.totalRecords === 0}
|
||||
>
|
||||
PNG herunterladen
|
||||
</button>
|
||||
<button class="btn" onclick={handleImportClick}> PNG importieren </button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
onclick={handleClear}
|
||||
disabled={!manaSpiralStore.stats || manaSpiralStore.stats.totalRecords === 0}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h4>Mana Spiral</h4>
|
||||
<p>
|
||||
Die Mana Spiral sammelt Aktivitätsdaten aus allen deinen Apps und kodiert sie als farbige
|
||||
Pixel in einem Spiralmuster. Jeder Pixel speichert 3 Bit (8 Farben). Das Bild wächst von
|
||||
der Mitte nach aussen — je mehr du die Apps nutzt, desto grösser wird deine Spirale.
|
||||
Exportiere sie als PNG oder nutze sie als Wallpaper.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".png"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spiral-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--color-card, rgba(255, 255, 255, 0.05));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground);
|
||||
margin: 0 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--color-primary, #6366f1);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Visualization */
|
||||
.viz-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.viz-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.viz-header h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.viz-controls {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.control input[type='range'] {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.viz-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: radial-gradient(ellipse at center, rgba(99, 102, 241, 0.05) 0%, transparent 70%);
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.pixel-detail {
|
||||
text-align: center;
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pixel-detail code {
|
||||
background: var(--color-background, rgba(0, 0, 0, 0.2));
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background, rgba(0, 0, 0, 0.2));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-foreground);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat.highlight {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.2));
|
||||
}
|
||||
|
||||
.collected-at {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-align: right;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* App Breakdown */
|
||||
.app-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background, rgba(0, 0, 0, 0.2));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.app-count {
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary, #6366f1);
|
||||
}
|
||||
|
||||
.app-bar {
|
||||
height: 4px;
|
||||
background: var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.app-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.app-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.app-events {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Color Legend */
|
||||
.color-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.color-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-bits {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: var(--color-background, rgba(0, 0, 0, 0.2));
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--color-accent, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 1rem;
|
||||
background: var(--color-background, rgba(0, 0, 0, 0.2));
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--color-primary, #6366f1);
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
787
pnpm-lock.yaml
generated
787
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue