mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 10:59:39 +02:00
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:
parent
caa126f8de
commit
764843772a
17 changed files with 1638 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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' },
|
||||
};
|
||||
|
|
@ -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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue