feat(manacore): add Seenplatte ecosystem observatory visualization

Interactive SVG lake landscape that visualizes all ManaCore apps as plants
around interconnected lakes. Plant type and health reflects ManaScore:
- Oaks/Birches for mature/production apps (85+)
- Young trees and reeds for beta apps
- Sprouts for alpha/prototype apps
- Animated water (waves, river flow particles)
- Pan & zoom navigation
- 6 lakes representing infrastructure (Auth, Redis, MinIO, 3x PostgreSQL)
- 20 apps with real ManaScore data

Accessible at /observatory in the ManaCore web dashboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 20:01:49 +01:00
parent caa126f8de
commit 764843772a
17 changed files with 1638 additions and 0 deletions

View file

@ -0,0 +1,157 @@
<script lang="ts">
import { SCENE, LAKES, RIVERS } from './data/layout';
import { createMockEcosystem } from './data/mockData';
import Background from './terrain/Background.svelte';
import Terrain from './terrain/Terrain.svelte';
import WaterBody from './terrain/WaterBody.svelte';
import RiverFlow from './water/RiverFlow.svelte';
import PlantFactory from './plants/PlantFactory.svelte';
let { onSelectApp }: { onSelectApp?: (appId: string) => void } = $props();
let apps = $state(createMockEcosystem());
let selectedApp = $state<string | null>(null);
// Pan & zoom state
let svgEl: SVGSVGElement | undefined = $state();
let viewBox = $state({ x: 0, y: 0, w: SCENE.width as number, h: SCENE.height as number });
let isPanning = $state(false);
let panStart = $state({ x: 0, y: 0 });
let panViewBoxStart = $state({ x: 0, y: 0 });
function handleWheel(e: WheelEvent) {
e.preventDefault();
const scaleFactor = e.deltaY > 0 ? 1.08 : 0.92;
const rect = svgEl?.getBoundingClientRect();
if (!rect) return;
// Mouse position as fraction of SVG
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
// Point in viewBox coordinates under mouse
const px = viewBox.x + mx * viewBox.w;
const py = viewBox.y + my * viewBox.h;
// Scale viewBox dimensions
const nw = Math.max(400, Math.min(SCENE.width * 2, viewBox.w * scaleFactor));
const nh = Math.max(225, Math.min(SCENE.height * 2, viewBox.h * scaleFactor));
// Adjust position to keep point under mouse
viewBox = {
x: px - mx * nw,
y: py - my * nh,
w: nw,
h: nh,
};
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return;
isPanning = true;
panStart = { x: e.clientX, y: e.clientY };
panViewBoxStart = { x: viewBox.x, y: viewBox.y };
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (!isPanning || !svgEl) return;
const rect = svgEl.getBoundingClientRect();
const dx = ((e.clientX - panStart.x) / rect.width) * viewBox.w;
const dy = ((e.clientY - panStart.y) / rect.height) * viewBox.h;
viewBox = {
...viewBox,
x: panViewBoxStart.x - dx,
y: panViewBoxStart.y - dy,
};
}
function handlePointerUp() {
isPanning = false;
}
function handleAppClick(appId: string) {
selectedApp = appId;
onSelectApp?.(appId);
}
function resetView() {
viewBox = { x: 0, y: 0, w: SCENE.width, h: SCENE.height };
}
let viewBoxStr = $derived(`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
</script>
<div class="relative h-full w-full overflow-hidden rounded-xl bg-sky-100">
<!-- Reset zoom button -->
<button
type="button"
onclick={resetView}
class="absolute right-3 top-3 z-10 rounded-lg bg-white/80 px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm backdrop-blur-sm transition-colors hover:bg-white"
>
Reset View
</button>
<!-- Title overlay -->
<div class="absolute left-4 top-3 z-10">
<h2 class="text-lg font-semibold text-slate-700/80" style="font-family: serif;">
Mana Seenplatte
</h2>
<p class="text-xs text-slate-500/70">Ecosystem Observatory</p>
</div>
<!-- Legend -->
<div
class="absolute bottom-3 left-3 z-10 flex gap-3 rounded-lg bg-white/70 px-3 py-2 text-[10px] text-slate-600 backdrop-blur-sm"
>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#2D6B30" /></svg>
Mature
</span>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#3E8B42" /></svg>
Production
</span>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#7DB86A" /></svg>
Beta
</span>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#C4B848" /></svg>
Alpha
</span>
</div>
<svg
bind:this={svgEl}
viewBox={viewBoxStr}
class="h-full w-full"
style="cursor: {isPanning ? 'grabbing' : 'grab'};"
onwheel={handleWheel}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointerleave={handlePointerUp}
>
<!-- Layer 1: Sky + Mountains -->
<Background />
<!-- Layer 2: Terrain (meadows, shores) -->
<Terrain />
<!-- Layer 3: Rivers (behind lakes) -->
{#each RIVERS as river}
<RiverFlow {river} />
{/each}
<!-- Layer 4: Lakes -->
{#each LAKES as lake}
<WaterBody {lake} />
{/each}
<!-- Layer 5: Plants (apps) sorted by y-position for depth -->
{#each apps.toSorted((a, b) => a.position.y - b.position.y) as app (app.id)}
<PlantFactory {app} onclick={() => handleAppClick(app.id)} />
{/each}
</svg>
</div>

View file

@ -0,0 +1,84 @@
// Natural color palette for the Seenplatte ecosystem
export const sky = {
dayTop: '#87CEEB',
dayBottom: '#E0F0FF',
duskTop: '#2C1654',
duskBottom: '#E8956A',
nightTop: '#0B1026',
nightBottom: '#1A2444',
} as const;
export const mountains = {
far: '#8BA4B8',
mid: '#6B8A9E',
near: '#4A7085',
snow: '#E8F0F4',
} as const;
export const water = {
shallow: '#7EC8D9',
mid: '#4BA3B5',
deep: '#2A7A8C',
veryDeep: '#1A5666',
river: '#5BB5C5',
highlight: '#A8E4F0',
foam: '#E8F6FA',
} as const;
export const terrain = {
meadow: '#7DB86A',
meadowLight: '#96CC86',
meadowDark: '#5E9A4D',
shore: '#C4B48A',
shoreDark: '#A89870',
path: '#D4C4A0',
rock: '#8C8C80',
rockLight: '#A8A89C',
} as const;
export const vegetation = {
// Tree health colors based on ManaScore
healthyDark: '#2D6B30',
healthy: '#3E8B42',
healthyLight: '#5AAB5E',
moderate: '#7DB86A',
warning: '#C4B848',
stressed: '#D4944A',
critical: '#B85C4A',
// Specific plant colors
trunk: '#6B4E3D',
trunkLight: '#8B6E5D',
reed: '#7DA868',
reedDark: '#5A8548',
lilyPad: '#4A8B50',
lilyFlower: '#E8B0D0',
lilyFlowerCenter: '#F0D060',
moss: '#5A8B4A',
mossLight: '#78A868',
sprout: '#90C880',
sproutStake: '#A89070',
} as const;
/**
* Get tree crown color based on ManaScore (0-100)
*/
export function getHealthColor(score: number): string {
if (score >= 85) return vegetation.healthyDark;
if (score >= 70) return vegetation.healthy;
if (score >= 55) return vegetation.moderate;
if (score >= 40) return vegetation.warning;
if (score >= 25) return vegetation.stressed;
return vegetation.critical;
}
/**
* Get water clarity based on error rate (0 = clear, 1 = murky)
*/
export function getWaterColor(clarity: number): string {
if (clarity > 0.8) return water.shallow;
if (clarity > 0.5) return water.mid;
if (clarity > 0.2) return water.deep;
return water.veryDeep;
}

View file

@ -0,0 +1,172 @@
import type { LakeData, RiverData } from './types';
// Scene dimensions (SVG viewBox)
export const SCENE = {
width: 1600,
height: 900,
viewBox: '0 0 1600 900',
} as const;
// Mountain layer paths (3 layers, back to front)
export const MOUNTAIN_PATHS = {
far: 'M0,280 Q200,180 400,240 Q500,210 600,250 Q750,160 900,220 Q1050,180 1200,230 Q1350,170 1500,210 L1600,240 L1600,350 L0,350 Z',
mid: 'M0,310 Q150,250 300,290 Q450,240 550,280 Q700,220 850,270 Q1000,230 1150,275 Q1300,240 1450,265 L1600,280 L1600,380 L0,380 Z',
near: 'M0,340 Q100,300 250,330 Q400,290 500,320 Q650,280 800,315 Q950,285 1100,320 Q1250,295 1400,310 L1600,320 L1600,400 L0,400 Z',
} as const;
// Lake definitions with organic SVG paths
export const LAKES: LakeData[] = [
{
id: 'auth',
name: 'auth',
label: 'Zentralsee',
path: 'M680,440 Q720,410 790,415 Q860,420 890,445 Q910,470 900,500 Q885,535 850,550 Q800,565 750,558 Q700,550 680,525 Q660,500 665,470 Q668,450 680,440 Z',
color: '#4BA3B5',
colorDeep: '#2A7A8C',
clarity: 1,
level: 0.8,
position: { x: 790, y: 485 },
},
{
id: 'redis',
name: 'redis',
label: 'Bergsee',
path: 'M280,390 Q310,370 350,375 Q390,380 405,400 Q415,420 405,440 Q390,455 355,460 Q320,462 295,450 Q275,435 270,415 Q268,398 280,390 Z',
color: '#7EC8D9',
colorDeep: '#4BA3B5',
clarity: 1,
level: 0.9,
position: { x: 340, y: 420 },
},
{
id: 'minio',
name: 'minio',
label: 'Stausee',
path: 'M1180,400 Q1220,375 1280,380 Q1340,385 1370,410 Q1390,435 1380,465 Q1360,490 1320,500 Q1270,510 1220,502 Q1185,492 1170,468 Q1158,445 1165,420 Q1170,405 1180,400 Z',
color: '#5BB5C5',
colorDeep: '#3A8A9A',
clarity: 0.9,
level: 0.7,
position: { x: 1275, y: 440 },
},
{
id: 'db-left',
name: 'postgres-left',
label: 'Waldsee West',
path: 'M180,580 Q220,555 280,560 Q340,565 365,590 Q380,615 370,640 Q355,665 310,675 Q260,682 215,670 Q180,658 165,635 Q155,612 165,595 Q170,582 180,580 Z',
color: '#3A8A9A',
colorDeep: '#1A5666',
clarity: 0.85,
level: 0.75,
position: { x: 270, y: 620 },
},
{
id: 'db-center',
name: 'postgres-center',
label: 'Waldsee Mitte',
path: 'M650,620 Q700,595 770,600 Q840,605 870,635 Q890,660 878,690 Q862,718 810,730 Q760,738 710,730 Q660,720 640,695 Q625,670 632,645 Q638,628 650,620 Z',
color: '#3A8A9A',
colorDeep: '#1A5666',
clarity: 0.9,
level: 0.8,
position: { x: 760, y: 665 },
},
{
id: 'db-right',
name: 'postgres-right',
label: 'Waldsee Ost',
path: 'M1120,590 Q1160,568 1220,572 Q1280,578 1305,600 Q1322,625 1315,652 Q1300,678 1255,690 Q1210,698 1165,688 Q1130,678 1115,655 Q1102,632 1108,610 Q1112,595 1120,590 Z',
color: '#3A8A9A',
colorDeep: '#1A5666',
clarity: 0.85,
level: 0.75,
position: { x: 1215, y: 630 },
},
];
// Rivers connecting the lakes
export const RIVERS: RiverData[] = [
{
id: 'redis-to-auth',
from: 'redis',
to: 'auth',
path: 'M405,430 Q480,435 540,440 Q600,445 665,460',
flowSpeed: 0.8,
width: 8,
},
{
id: 'minio-to-auth',
from: 'minio',
to: 'auth',
path: 'M1170,450 Q1100,455 1020,460 Q940,465 900,475',
flowSpeed: 0.6,
width: 8,
},
{
id: 'auth-to-db-left',
from: 'auth',
to: 'db-left',
path: 'M710,555 Q620,570 520,580 Q420,590 365,595',
flowSpeed: 0.7,
width: 10,
},
{
id: 'auth-to-db-center',
from: 'auth',
to: 'db-center',
path: 'M790,560 Q785,580 778,600 Q770,610 765,620',
flowSpeed: 0.7,
width: 10,
},
{
id: 'auth-to-db-right',
from: 'auth',
to: 'db-right',
path: 'M870,548 Q960,565 1040,575 Q1080,582 1115,592',
flowSpeed: 0.7,
width: 10,
},
{
id: 'inlet',
from: 'source',
to: 'auth',
path: 'M790,350 Q792,370 790,390 Q788,410 790,420',
flowSpeed: 0.9,
width: 6,
},
];
// App positions around the lakes
export const APP_POSITIONS: Record<string, { x: number; y: number; lakeId: string }> = {
// Around Zentralsee (auth) - core/mature apps
manacore: { x: 730, y: 410, lakeId: 'auth' },
chat: { x: 860, y: 420, lakeId: 'auth' },
picture: { x: 910, y: 480, lakeId: 'auth' },
presi: { x: 660, y: 490, lakeId: 'auth' },
// Around Waldsee West (db-left)
calendar: { x: 170, y: 560, lakeId: 'db-left' },
todo: { x: 330, y: 555, lakeId: 'db-left' },
contacts: { x: 370, y: 630, lakeId: 'db-left' },
storage: { x: 160, y: 650, lakeId: 'db-left' },
// Around Waldsee Mitte (db-center)
zitare: { x: 640, y: 600, lakeId: 'db-center' },
mukke: { x: 850, y: 610, lakeId: 'db-center' },
clock: { x: 880, y: 680, lakeId: 'db-center' },
nutriphi: { x: 650, y: 720, lakeId: 'db-center' },
// Around Waldsee Ost (db-right)
photos: { x: 1110, y: 575, lakeId: 'db-right' },
skilltree: { x: 1310, y: 590, lakeId: 'db-right' },
context: { x: 1320, y: 660, lakeId: 'db-right' },
planta: { x: 1115, y: 675, lakeId: 'db-right' },
// Around Bergsee (redis) - lightweight/cache
matrix: { x: 260, y: 375, lakeId: 'redis' },
traces: { x: 400, y: 385, lakeId: 'redis' },
// Around Stausee (minio) - storage-heavy
manadeck: { x: 1180, y: 385, lakeId: 'minio' },
questions: { x: 1370, y: 400, lakeId: 'minio' },
};

View file

@ -0,0 +1,362 @@
import type { AppData, AppStatus, PlantType, CategoryScores } from './types';
import { APP_POSITIONS } from './layout';
interface AppDefinition {
id: string;
displayName: string;
score: number;
status: AppStatus;
categories: CategoryScores;
}
function getPlantType(status: AppStatus, score: number): PlantType {
if (score >= 85) return 'oak';
if (score >= 70) return 'birch';
if (status === 'beta' && score >= 55) return 'youngTree';
if (status === 'beta') return 'reed';
if (status === 'alpha') return 'sprout';
if (status === 'prototype') return 'sprout';
return 'youngTree';
}
const APP_DEFINITIONS: AppDefinition[] = [
{
id: 'calendar',
displayName: 'Calendar',
score: 97,
status: 'mature',
categories: {
backend: 95,
frontend: 96,
database: 95,
testing: 98,
deployment: 100,
documentation: 98,
security: 95,
ux: 98,
},
},
{
id: 'todo',
displayName: 'Todo',
score: 96,
status: 'mature',
categories: {
backend: 94,
frontend: 95,
database: 95,
testing: 97,
deployment: 98,
documentation: 95,
security: 95,
ux: 96,
},
},
{
id: 'contacts',
displayName: 'Contacts',
score: 94,
status: 'production',
categories: {
backend: 92,
frontend: 90,
database: 95,
testing: 92,
deployment: 95,
documentation: 95,
security: 95,
ux: 94,
},
},
{
id: 'manacore',
displayName: 'ManaCore',
score: 88,
status: 'production',
categories: {
backend: 55,
frontend: 90,
database: 70,
testing: 72,
deployment: 90,
documentation: 88,
security: 80,
ux: 92,
},
},
{
id: 'presi',
displayName: 'Presi',
score: 86,
status: 'mature',
categories: {
backend: 90,
frontend: 82,
database: 85,
testing: 80,
deployment: 90,
documentation: 85,
security: 85,
ux: 88,
},
},
{
id: 'storage',
displayName: 'Storage',
score: 84,
status: 'production',
categories: {
backend: 88,
frontend: 84,
database: 82,
testing: 78,
deployment: 88,
documentation: 82,
security: 85,
ux: 82,
},
},
{
id: 'chat',
displayName: 'Chat',
score: 82,
status: 'production',
categories: {
backend: 90,
frontend: 82,
database: 80,
testing: 68,
deployment: 88,
documentation: 85,
security: 80,
ux: 78,
},
},
{
id: 'picture',
displayName: 'Picture',
score: 81,
status: 'production',
categories: {
backend: 90,
frontend: 80,
database: 75,
testing: 65,
deployment: 85,
documentation: 82,
security: 82,
ux: 80,
},
},
{
id: 'mukke',
displayName: 'Mukke',
score: 80,
status: 'beta',
categories: {
backend: 90,
frontend: 78,
database: 80,
testing: 60,
deployment: 85,
documentation: 78,
security: 80,
ux: 80,
},
},
{
id: 'matrix',
displayName: 'Matrix',
score: 68,
status: 'production',
categories: {
backend: 10,
frontend: 78,
database: 60,
testing: 50,
deployment: 85,
documentation: 70,
security: 75,
ux: 72,
},
},
{
id: 'nutriphi',
displayName: 'NutriPhi',
score: 63,
status: 'beta',
categories: {
backend: 78,
frontend: 62,
database: 65,
testing: 42,
deployment: 70,
documentation: 58,
security: 62,
ux: 65,
},
},
{
id: 'photos',
displayName: 'Photos',
score: 62,
status: 'beta',
categories: {
backend: 82,
frontend: 65,
database: 60,
testing: 38,
deployment: 68,
documentation: 55,
security: 60,
ux: 62,
},
},
{
id: 'zitare',
displayName: 'Zitare',
score: 62,
status: 'beta',
categories: {
backend: 72,
frontend: 78,
database: 60,
testing: 38,
deployment: 68,
documentation: 55,
security: 58,
ux: 65,
},
},
{
id: 'context',
displayName: 'Context',
score: 60,
status: 'beta',
categories: {
backend: 75,
frontend: 75,
database: 55,
testing: 32,
deployment: 65,
documentation: 52,
security: 58,
ux: 60,
},
},
{
id: 'clock',
displayName: 'Clock',
score: 58,
status: 'beta',
categories: {
backend: 75,
frontend: 70,
database: 55,
testing: 30,
deployment: 62,
documentation: 48,
security: 55,
ux: 58,
},
},
{
id: 'skilltree',
displayName: 'SkillTree',
score: 58,
status: 'beta',
categories: {
backend: 65,
frontend: 68,
database: 55,
testing: 35,
deployment: 60,
documentation: 50,
security: 55,
ux: 60,
},
},
{
id: 'planta',
displayName: 'Planta',
score: 50,
status: 'alpha',
categories: {
backend: 68,
frontend: 58,
database: 45,
testing: 25,
deployment: 55,
documentation: 42,
security: 48,
ux: 50,
},
},
{
id: 'manadeck',
displayName: 'ManaDeck',
score: 48,
status: 'alpha',
categories: {
backend: 50,
frontend: 65,
database: 42,
testing: 28,
deployment: 52,
documentation: 45,
security: 42,
ux: 55,
},
},
{
id: 'questions',
displayName: 'Questions',
score: 48,
status: 'alpha',
categories: {
backend: 88,
frontend: 62,
database: 40,
testing: 20,
deployment: 45,
documentation: 38,
security: 40,
ux: 42,
},
},
{
id: 'traces',
displayName: 'Traces',
score: 35,
status: 'alpha',
categories: {
backend: 72,
frontend: 10,
database: 35,
testing: 15,
deployment: 40,
documentation: 30,
security: 35,
ux: 25,
},
},
];
export function createMockEcosystem(): AppData[] {
return APP_DEFINITIONS.map((def) => {
const pos = APP_POSITIONS[def.id] || { x: 800, y: 500, lakeId: 'auth' };
return {
id: def.id,
name: def.id,
displayName: def.displayName,
score: def.score,
status: def.status,
health: 'up' as const,
plantType: getPlantType(def.status, def.score),
categories: def.categories,
trend: 0,
lakeId: pos.lakeId,
position: { x: pos.x, y: pos.y },
};
});
}

View file

@ -0,0 +1,69 @@
export type PlantType =
| 'oak'
| 'birch'
| 'youngTree'
| 'reed'
| 'waterLily'
| 'moss'
| 'shrub'
| 'sprout'
| 'stump'
| 'swampCluster';
export type AppStatus = 'prototype' | 'alpha' | 'beta' | 'production' | 'mature';
export type HealthStatus = 'up' | 'degraded' | 'down' | 'unknown';
export interface CategoryScores {
backend: number;
frontend: number;
database: number;
testing: number;
deployment: number;
documentation: number;
security: number;
ux: number;
}
export interface AppData {
id: string;
name: string;
displayName: string;
score: number;
status: AppStatus;
health: HealthStatus;
plantType: PlantType;
categories: CategoryScores;
trend: number;
lakeId: string;
position: { x: number; y: number };
}
export interface LakeData {
id: string;
name: string;
label: string;
path: string;
color: string;
colorDeep: string;
clarity: number; // 0-1, 1 = crystal clear
level: number; // 0-1, normalized fill level
position: { x: number; y: number };
}
export interface RiverData {
id: string;
from: string;
to: string;
path: string;
flowSpeed: number; // 0-1
width: number;
}
export interface EcosystemState {
apps: AppData[];
lakes: LakeData[];
rivers: RiverData[];
timeOfDay: number; // 0-24
systemHealth: 'sunny' | 'cloudy' | 'stormy';
}

View file

@ -0,0 +1,63 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let spread = $derived(10 + (app.score / 100) * 15);
function mossPatches(seed: string) {
const patches = [];
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
}
const count = 4 + (Math.abs(hash) % 5);
for (let i = 0; i < count; i++) {
hash = ((hash << 5) - hash + i * 17) | 0;
patches.push({
cx: ((hash & 0xff) / 255 - 0.5) * spread * 2,
cy: (((hash >> 8) & 0xff) / 255 - 0.5) * spread * 0.8,
rx: 3 + ((hash >> 16) & 0x7),
ry: 2 + ((hash >> 20) & 0x5),
light: i % 3 === 0,
});
}
return patches;
}
let patches = $derived(mossPatches(app.id));
</script>
<g
class="plant-moss"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
{#each patches as patch}
<ellipse
cx={patch.cx}
cy={patch.cy}
rx={patch.rx}
ry={patch.ry}
fill={patch.light ? vegetation.mossLight : vegetation.moss}
opacity="0.6"
/>
{/each}
<!-- Label -->
<text
y={spread * 0.5 + 12}
text-anchor="middle"
font-size="8"
font-family="system-ui, sans-serif"
fill="#5A7060"
opacity="0.5"
>
{app.displayName}
</text>
</g>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type { AppData } from '../data/types';
import TreePlant from './TreePlant.svelte';
import ReedPlant from './ReedPlant.svelte';
import WaterLily from './WaterLily.svelte';
import Sprout from './Sprout.svelte';
import MossCluster from './MossCluster.svelte';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
</script>
{#if app.plantType === 'oak' || app.plantType === 'birch' || app.plantType === 'youngTree'}
<TreePlant {app} {onclick} />
{:else if app.plantType === 'reed'}
<ReedPlant {app} {onclick} />
{:else if app.plantType === 'waterLily'}
<WaterLily {app} {onclick} />
{:else if app.plantType === 'sprout'}
<Sprout {app} {onclick} />
{:else if app.plantType === 'moss'}
<MossCluster {app} {onclick} />
{:else}
<!-- Fallback: render as sprout -->
<Sprout {app} {onclick} />
{/if}

View file

@ -0,0 +1,98 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let height = $derived(25 + (app.score / 100) * 30); // 25-55px tall
let stalkCount = $derived(3 + Math.floor(app.score / 20)); // 3-8 stalks
let animDelay = $derived(`${(app.id.charCodeAt(0) % 8) * 0.4}s`);
function stalks(count: number) {
const result = [];
for (let i = 0; i < count; i++) {
const offset = (i - (count - 1) / 2) * 5;
const h = height + (i % 3) * 5 - 5;
const curve = offset * 0.5;
result.push({ offset, h, curve, hasBulrush: i % 2 === 0 });
}
return result;
}
let stalkData = $derived(stalks(stalkCount));
</script>
<g
class="plant-reed"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<g>
<animateTransform
attributeName="transform"
type="rotate"
values="-2,0,0; 2,0,0; -2,0,0"
dur="4s"
begin={animDelay}
repeatCount="indefinite"
/>
{#each stalkData as stalk}
<!-- Stalk -->
<path
d="M{stalk.offset},0 Q{stalk.offset + stalk.curve},{-stalk.h * 0.5} {stalk.offset +
stalk.curve * 1.2},{-stalk.h}"
fill="none"
stroke={vegetation.reed}
stroke-width="2"
stroke-linecap="round"
/>
<!-- Bulrush head (on some stalks) -->
{#if stalk.hasBulrush}
<ellipse
cx={stalk.offset + stalk.curve * 1.2}
cy={-stalk.h - 5}
rx="2.5"
ry="6"
fill={vegetation.reedDark}
/>
{/if}
{/each}
<!-- Leaf blades at base -->
<path
d="M-4,0 Q-10,-15 -8,-25"
fill="none"
stroke={vegetation.reed}
stroke-width="2.5"
stroke-linecap="round"
opacity="0.7"
/>
<path
d="M4,0 Q12,-12 10,-22"
fill="none"
stroke={vegetation.reed}
stroke-width="2.5"
stroke-linecap="round"
opacity="0.7"
/>
</g>
<!-- Label -->
<text
y="15"
text-anchor="middle"
font-size="9"
font-family="system-ui, sans-serif"
font-weight="500"
fill="#3A5040"
opacity="0.7"
>
{app.displayName}
</text>
</g>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let height = $derived(12 + (app.score / 100) * 18); // 12-30px
let animDelay = $derived(`${(app.id.charCodeAt(0) % 5) * 0.6}s`);
</script>
<g
class="plant-sprout"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<g>
<animateTransform
attributeName="transform"
type="rotate"
values="-3,0,0; 3,0,0; -3,0,0"
dur="3.5s"
begin={animDelay}
repeatCount="indefinite"
/>
<!-- Support stake -->
<line
x1="3"
y1="2"
x2="3"
y2={-height - 5}
stroke={vegetation.sproutStake}
stroke-width="1.5"
stroke-linecap="round"
/>
<!-- Stem -->
<path
d="M0,0 Q-1,{-height * 0.5} 0,{-height}"
fill="none"
stroke={vegetation.sprout}
stroke-width="2"
stroke-linecap="round"
/>
<!-- Two small leaves -->
<path
d="M0,{-height * 0.6} Q-8,{-height * 0.7} -6,{-height * 0.85}"
fill={vegetation.sprout}
stroke={vegetation.sprout}
stroke-width="1"
opacity="0.8"
/>
<path
d="M0,{-height * 0.75} Q6,{-height * 0.8} 5,{-height * 0.95}"
fill={vegetation.sprout}
stroke={vegetation.sprout}
stroke-width="1"
opacity="0.8"
/>
<!-- Tiny leaf at top -->
<ellipse cx="0" cy={-height - 3} rx="3" ry="4" fill={vegetation.sprout} opacity="0.9" />
</g>
<!-- Label -->
<text
y="14"
text-anchor="middle"
font-size="8"
font-family="system-ui, sans-serif"
fill="#5A7060"
opacity="0.6"
>
{app.displayName}
</text>
</g>

View file

@ -0,0 +1,117 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation, getHealthColor } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let crownColor = $derived(getHealthColor(app.score));
let size = $derived(0.6 + (app.score / 100) * 0.6); // 0.6 - 1.2 scale
let isOak = $derived(app.plantType === 'oak');
// Generate pseudo-random but deterministic crown blobs
function crownBlobs(seed: string, count: number) {
const blobs = [];
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
}
for (let i = 0; i < count; i++) {
hash = ((hash << 5) - hash + i * 37) | 0;
const angle = ((hash & 0xff) / 255) * Math.PI * 2;
const dist = ((hash >> 8) & 0xff) / 255;
blobs.push({
cx: Math.cos(angle) * dist * (isOak ? 22 : 16),
cy: -30 - Math.sin(angle) * dist * (isOak ? 18 : 14) - i * 3,
rx: (isOak ? 18 : 14) + ((hash >> 16) & 0xf) - 8,
ry: (isOak ? 14 : 11) + ((hash >> 20) & 0xf) - 8,
});
}
return blobs;
}
let blobs = $derived(crownBlobs(app.id, isOak ? 7 : 5));
let animDelay = $derived(`${(app.id.charCodeAt(0) % 10) * 0.3}s`);
</script>
<g
class="plant-tree"
transform="translate({app.position.x}, {app.position.y}) scale({size})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<!-- Wind sway animation wrapper -->
<g>
<animateTransform
attributeName="transform"
type="rotate"
values="-1,0,0; 1,0,0; -1,0,0"
dur="6s"
begin={animDelay}
repeatCount="indefinite"
/>
<!-- Shadow -->
<ellipse cx="8" cy="2" rx={isOak ? 20 : 14} ry="5" fill="black" opacity="0.1" />
<!-- Trunk -->
<path d="M-3,0 Q-4,-15 -2,-25 L2,-25 Q4,-15 3,0 Z" fill={vegetation.trunk} />
{#if isOak}
<!-- Branch left -->
<path d="M-2,-18 Q-12,-22 -16,-28 L-14,-30 Q-10,-24 -1,-20 Z" fill={vegetation.trunkLight} />
<!-- Branch right -->
<path d="M2,-20 Q10,-24 15,-30 L17,-28 Q12,-22 3,-18 Z" fill={vegetation.trunkLight} />
{/if}
<!-- Crown (cluster of ellipses) -->
<g>
{#each blobs as blob, i}
{@const lightness = i % 3 === 0 ? 15 : i % 3 === 1 ? 0 : -10}
<ellipse
cx={blob.cx}
cy={blob.cy}
rx={blob.rx}
ry={blob.ry}
fill={crownColor}
opacity={0.75 + (i % 3) * 0.08}
style="filter: brightness({100 + lightness}%)"
/>
{/each}
<!-- Highlight on top -->
<ellipse
cx={-4}
cy={-42}
rx={isOak ? 10 : 8}
ry={isOak ? 7 : 5}
fill="white"
opacity="0.08"
/>
</g>
</g>
<!-- Label -->
<text
y="18"
text-anchor="middle"
font-size="10"
font-family="system-ui, sans-serif"
font-weight="500"
fill="#3A5040"
opacity="0.8"
>
{app.displayName}
</text>
<text
y="30"
text-anchor="middle"
font-size="9"
font-family="system-ui, sans-serif"
fill="#5A7060"
opacity="0.6"
>
{app.score}
</text>
</g>

View file

@ -0,0 +1,118 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let padSize = $derived(8 + (app.score / 100) * 10); // 8-18px radius
let petalCount = $derived(app.score >= 70 ? 8 : app.score >= 40 ? 6 : 4);
let bloomAmount = $derived(app.score / 100); // 0-1 how open the flower is
let animDelay = $derived(`${(app.id.charCodeAt(0) % 6) * 0.5}s`);
function petals(count: number) {
const result = [];
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
result.push({ angle, i });
}
return result;
}
let petalData = $derived(petals(petalCount));
</script>
<g
class="plant-lily"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<!-- Gentle bob animation -->
<g>
<animateTransform
attributeName="transform"
type="translate"
values="0,0; 0,-1.5; 0,0; 0,1; 0,0"
dur="5s"
begin={animDelay}
repeatCount="indefinite"
/>
<!-- Shadow under pad -->
<ellipse cx="1" cy="2" rx={padSize + 1} ry={padSize * 0.5 + 1} fill="black" opacity="0.1" />
<!-- Lily pad (circle with notch) -->
<path
d="M{padSize},0
A{padSize},{padSize * 0.55} 0 1,0 {-padSize},0
A{padSize},{padSize * 0.55} 0 1,0 {padSize},0
L0,0 L{padSize * 0.3},{-padSize * 0.15} Z"
fill={vegetation.lilyPad}
opacity="0.85"
/>
<!-- Pad vein -->
<line
x1="0"
y1="0"
x2={-padSize * 0.6}
y2={padSize * 0.15}
stroke="#3A7040"
stroke-width="0.5"
opacity="0.4"
/>
<line
x1="0"
y1="0"
x2={-padSize * 0.5}
y2={-padSize * 0.2}
stroke="#3A7040"
stroke-width="0.5"
opacity="0.4"
/>
<!-- Flower (if score > 20) -->
{#if app.score > 20}
<g transform="translate({-padSize * 0.2}, {-padSize * 0.1})">
<!-- Petals -->
{#each petalData as petal}
{@const px = Math.cos(petal.angle) * 5 * bloomAmount}
{@const py = Math.sin(petal.angle) * 3 * bloomAmount}
<ellipse
cx={px}
cy={py - 2}
rx={3.5 * bloomAmount + 1}
ry={2 * bloomAmount + 0.5}
fill={vegetation.lilyFlower}
opacity={0.7 + bloomAmount * 0.3}
transform="rotate({(petal.angle * 180) / Math.PI}, {px}, {py - 2})"
/>
{/each}
<!-- Center -->
<circle
cx="0"
cy="-2"
r={2 * bloomAmount + 0.5}
fill={vegetation.lilyFlowerCenter}
opacity="0.9"
/>
</g>
{/if}
</g>
<!-- Label -->
<text
y={padSize * 0.55 + 14}
text-anchor="middle"
font-size="9"
font-family="system-ui, sans-serif"
font-weight="500"
fill="#3A5040"
opacity="0.7"
>
{app.displayName}
</text>
</g>

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { sky, mountains } from '../data/colors';
import { SCENE, MOUNTAIN_PATHS } from '../data/layout';
</script>
<!-- Sky gradient -->
<defs>
<linearGradient id="sky-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={sky.dayTop} />
<stop offset="100%" stop-color={sky.dayBottom} />
</linearGradient>
<!-- Subtle haze filter -->
<filter id="mountain-haze">
<feGaussianBlur stdDeviation="1.5" />
</filter>
<filter id="mountain-haze-light">
<feGaussianBlur stdDeviation="0.8" />
</filter>
</defs>
<!-- Sky fill -->
<rect x="0" y="0" width={SCENE.width} height={SCENE.height} fill="url(#sky-gradient)" />
<!-- Clouds (subtle, decorative) -->
<g opacity="0.35">
<ellipse cx="300" cy="120" rx="120" ry="30" fill="white" />
<ellipse cx="340" cy="115" rx="80" ry="25" fill="white" />
<ellipse cx="260" cy="118" rx="60" ry="20" fill="white" />
<ellipse cx="1100" cy="90" rx="100" ry="25" fill="white" />
<ellipse cx="1140" cy="85" rx="70" ry="22" fill="white" />
<ellipse cx="700" cy="150" rx="90" ry="22" fill="white" />
<ellipse cx="740" cy="145" rx="65" ry="18" fill="white" />
</g>
<!-- Mountain layers (back to front) -->
<g>
<!-- Far mountains (lightest, most hazy) -->
<path d={MOUNTAIN_PATHS.far} fill={mountains.far} opacity="0.6" filter="url(#mountain-haze)" />
<!-- Snow caps on far mountains -->
<path
d="M740,165 Q755,155 770,168 Q782,158 795,170 L790,180 Q775,172 760,180 Q748,172 740,178 Z"
fill={mountains.snow}
opacity="0.5"
filter="url(#mountain-haze)"
/>
<!-- Mid mountains -->
<path
d={MOUNTAIN_PATHS.mid}
fill={mountains.mid}
opacity="0.75"
filter="url(#mountain-haze-light)"
/>
<!-- Near mountains (darkest, sharpest) -->
<path d={MOUNTAIN_PATHS.near} fill={mountains.near} opacity="0.85" />
</g>
<!-- Tree line on near mountains (tiny triangles) -->
<g opacity="0.4" fill="#3A6050">
{#each Array(60) as _, i}
{@const x = i * 28 + 10}
{@const baseY = 335 + Math.sin(i * 0.8) * 15}
{@const h = 12 + Math.sin(i * 1.3) * 5}
<polygon points="{x},{baseY} {x + 4},{baseY - h} {x + 8},{baseY}" />
{/each}
</g>

View file

@ -0,0 +1,59 @@
<script lang="ts">
import { terrain } from '../data/colors';
import { SCENE } from '../data/layout';
</script>
<defs>
<!-- Meadow gradient (top = darker, bottom = lighter) -->
<linearGradient id="meadow-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={terrain.meadowDark} />
<stop offset="40%" stop-color={terrain.meadow} />
<stop offset="100%" stop-color={terrain.meadowLight} />
</linearGradient>
<!-- Shore texture pattern -->
<pattern id="shore-dots" width="8" height="8" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill={terrain.shoreDark} opacity="0.3" />
<circle cx="6" cy="6" r="0.6" fill={terrain.shoreDark} opacity="0.2" />
</pattern>
</defs>
<!-- Main terrain fill (everything below mountains) -->
<rect x="0" y="350" width={SCENE.width} height={SCENE.height - 350} fill="url(#meadow-gradient)" />
<!-- Rolling hills / terrain variation -->
<g>
<!-- Gentle hills in the meadow -->
<path
d="M0,380 Q200,360 400,375 Q600,365 800,378 Q1000,362 1200,372 Q1400,365 1600,380 L1600,420 Q1400,405 1200,410 Q1000,400 800,412 Q600,402 400,408 Q200,398 0,410 Z"
fill={terrain.meadow}
opacity="0.5"
/>
<!-- Shoreline areas around the lakes (sandy patches) -->
<ellipse cx="790" cy="485" rx="160" ry="95" fill={terrain.shore} opacity="0.3" />
<ellipse cx="340" cy="420" rx="90" ry="55" fill={terrain.shore} opacity="0.25" />
<ellipse cx="1275" cy="445" rx="130" ry="75" fill={terrain.shore} opacity="0.25" />
<ellipse cx="270" cy="625" rx="130" ry="75" fill={terrain.shore} opacity="0.3" />
<ellipse cx="760" cy="670" rx="140" ry="80" fill={terrain.shore} opacity="0.3" />
<ellipse cx="1215" cy="635" rx="125" ry="70" fill={terrain.shore} opacity="0.3" />
</g>
<!-- Small rocks scattered around -->
<g fill={terrain.rock} opacity="0.5">
<ellipse cx="520" cy="450" rx="8" ry="5" />
<ellipse cx="525" cy="448" rx="5" ry="3" fill={terrain.rockLight} />
<ellipse cx="1050" cy="520" rx="6" ry="4" />
<ellipse cx="450" cy="650" rx="7" ry="4" />
<ellipse cx="950" cy="710" rx="9" ry="5" />
<ellipse cx="1400" cy="550" rx="5" ry="3" />
</g>
<!-- Grass tufts along the bottom -->
<g opacity="0.4" fill={terrain.meadowDark}>
{#each Array(40) as _, i}
{@const x = i * 42 + 15}
{@const y = 820 + Math.sin(i * 2.1) * 20}
<path d="M{x},{y} Q{x + 2},{y - 12} {x + 5},{y} Q{x + 7},{y - 10} {x + 10},{y}" />
{/each}
</g>

View file

@ -0,0 +1,70 @@
<script lang="ts">
import type { LakeData } from '../data/types';
let { lake }: { lake: LakeData } = $props();
// Unique IDs for gradients
let gradientId = $derived(`lake-gradient-${lake.id}`);
let reflectionId = $derived(`lake-reflection-${lake.id}`);
</script>
<defs>
<!-- Radial gradient for depth effect -->
<radialGradient id={gradientId} cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color={lake.color} stop-opacity={lake.clarity} />
<stop offset="100%" stop-color={lake.colorDeep} stop-opacity={lake.clarity * 0.9} />
</radialGradient>
<!-- Subtle shimmer/reflection -->
<radialGradient id={reflectionId} cx="40%" cy="30%" r="30%">
<stop offset="0%" stop-color="white" stop-opacity="0.15" />
<stop offset="100%" stop-color="white" stop-opacity="0" />
</radialGradient>
</defs>
<g class="lake" data-lake={lake.id}>
<!-- Lake body -->
<path
d={lake.path}
fill="url(#{gradientId})"
stroke="#3A8A9A"
stroke-width="0.5"
stroke-opacity="0.3"
/>
<!-- Light reflection on surface -->
<path d={lake.path} fill="url(#{reflectionId})" />
<!-- Animated wave lines on the surface -->
<g opacity="0.15" stroke="white" stroke-width="0.8" fill="none">
<path d={lake.path} stroke-dasharray="4 12" stroke-dashoffset="0">
<animate attributeName="stroke-dashoffset" values="0;16" dur="4s" repeatCount="indefinite" />
</path>
</g>
<!-- Second wave layer (offset timing) -->
<g opacity="0.1" stroke="white" stroke-width="0.5" fill="none">
<path d={lake.path} stroke-dasharray="3 15" stroke-dashoffset="8">
<animate
attributeName="stroke-dashoffset"
values="8;26"
dur="5.5s"
repeatCount="indefinite"
/>
</path>
</g>
<!-- Lake label -->
<text
x={lake.position.x}
y={lake.position.y}
text-anchor="middle"
font-size="11"
font-family="serif"
font-style="italic"
fill="#1A5666"
opacity="0.6"
>
{lake.label}
</text>
</g>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import type { RiverData } from '../data/types';
import { water } from '../data/colors';
let { river }: { river: RiverData } = $props();
let duration = $derived(`${3 + (1 - river.flowSpeed) * 4}s`);
let dashLength = $derived(river.width * 1.5);
let gapLength = $derived(river.width * 3);
</script>
<g class="river" data-river={river.id}>
<!-- River bed (wider, darker) -->
<path
d={river.path}
fill="none"
stroke={water.deep}
stroke-width={river.width + 4}
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.3"
/>
<!-- River water -->
<path
d={river.path}
fill="none"
stroke={water.river}
stroke-width={river.width}
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.7"
/>
<!-- Animated flow particles -->
<path
d={river.path}
fill="none"
stroke={water.highlight}
stroke-width={river.width * 0.4}
stroke-linecap="round"
stroke-dasharray="{dashLength} {gapLength}"
opacity="0.4"
>
<animate
attributeName="stroke-dashoffset"
values="{dashLength + gapLength};0"
dur={duration}
repeatCount="indefinite"
/>
</path>
<!-- Surface shimmer -->
<path
d={river.path}
fill="none"
stroke="white"
stroke-width={river.width * 0.2}
stroke-dasharray="2 {gapLength * 1.5}"
opacity="0.2"
>
<animate
attributeName="stroke-dashoffset"
values="{gapLength * 1.5 + 2};0"
dur={duration}
repeatCount="indefinite"
/>
</path>
</g>

View file

@ -82,6 +82,7 @@
let baseNavItems: PillNavItem[] = [
{ href: '/home', label: 'Home', icon: 'home' },
{ href: '/dashboard', label: 'Dashboard', icon: 'grid' },
{ href: '/observatory', label: 'Observatory', icon: 'eye' },
{ href: '/credits', label: 'Credits', icon: 'creditCard' },
{ href: '/gifts', label: 'Geschenke', icon: 'gift' },
{ href: '/api-keys', label: 'API Keys', icon: 'key' },

View file

@ -0,0 +1,23 @@
<script lang="ts">
import SeenplatteScene from '$lib/components/observatory/SeenplatteScene.svelte';
let selectedApp = $state<string | null>(null);
function handleSelectApp(appId: string) {
selectedApp = appId;
}
</script>
<div class="flex flex-col gap-4">
<div class="h-[calc(100vh-12rem)] min-h-[500px] w-full">
<SeenplatteScene onSelectApp={handleSelectApp} />
</div>
{#if selectedApp}
<div class="rounded-xl bg-white/80 p-4 shadow-sm backdrop-blur-sm dark:bg-gray-800/80">
<p class="text-sm text-gray-600 dark:text-gray-300">
Selected: <strong>{selectedApp}</strong> — Detail panel coming in Phase 5
</p>
</div>
{/if}
</div>