feat(observatory): add tabbed gallery views for plants and lakes

Three tabs on the observatory page:
- Seenplatte: the main interactive landscape (existing)
- Pflanzen: horizontal scroll gallery of all 20 apps as plant cards,
  grouped by status (Mature/Production/Beta/Alpha), each with SVG
  preview, score, type label, trend indicator. Click opens detail panel.
- Seen: horizontal scroll of all 6 infrastructure lakes with gradient
  preview, description, clarity and fill level stats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-25 08:24:29 +01:00
parent a432c77286
commit 80beef252c
3 changed files with 647 additions and 3 deletions

View file

@ -0,0 +1,177 @@
<script lang="ts">
import type { LakeData } from '../data/types';
let { lake }: { lake: LakeData } = $props();
// Normalize the lake path to fit inside the card SVG
// We'll render it at its original scale but crop via viewBox
let viewBox = $derived(() => {
// Parse path to get rough bounds
const nums = lake.path.match(/[\d.]+/g)?.map(Number) || [];
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (let i = 0; i < nums.length; i += 2) {
if (nums[i] < minX) minX = nums[i];
if (nums[i] > maxX) maxX = nums[i];
}
for (let i = 1; i < nums.length; i += 2) {
if (nums[i] < minY) minY = nums[i];
if (nums[i] > maxY) maxY = nums[i];
}
const pad = 20;
return `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
});
const lakeDescriptions: Record<string, string> = {
auth: 'Zentraler Authentifizierungs-Hub. Alle Services fliessen durch diesen See.',
redis: 'Schneller Cache-Speicher. Klein, kristallklar, sofort verfugbar.',
minio: 'Objekt-Speicher fur Dateien, Bilder und Medien aller Apps.',
'db-left': 'PostgreSQL-Datenbanken fur Calendar, Todo, Contacts, Storage.',
'db-center': 'PostgreSQL-Datenbanken fur Zitare, Mukke, Clock, NutriPhi.',
'db-right': 'PostgreSQL-Datenbanken fur Photos, SkillTree, Context, Planta.',
};
const lakeIcons: Record<string, string> = {
auth: 'Mana Core Auth',
redis: 'Redis Cache',
minio: 'MinIO Storage',
'db-left': 'PostgreSQL West',
'db-center': 'PostgreSQL Mitte',
'db-right': 'PostgreSQL Ost',
};
</script>
<div class="lake-card">
<!-- Lake SVG preview -->
<div class="lake-preview">
<svg
width="220"
height="120"
viewBox={viewBox()}
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<radialGradient id="lc-{lake.id}" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color={lake.color} />
<stop offset="100%" stop-color={lake.colorDeep} />
</radialGradient>
</defs>
<path d={lake.path} fill="url(#lc-{lake.id})" />
<!-- Shimmer -->
<path d={lake.path} fill="white" opacity="0.08" />
<!-- Waves -->
<path
d={lake.path}
fill="none"
stroke="white"
stroke-width="0.8"
stroke-dasharray="4 12"
opacity="0.2"
>
<animate
attributeName="stroke-dashoffset"
values="0;16"
dur="4s"
repeatCount="indefinite"
/>
</path>
</svg>
</div>
<!-- Info -->
<div class="lake-info">
<h3 class="lake-name">{lake.label}</h3>
<span class="lake-service">{lakeIcons[lake.id] || lake.name}</span>
<p class="lake-desc">{lakeDescriptions[lake.id] || ''}</p>
<div class="lake-stats">
<span class="lake-stat">
<span class="stat-label">Klarheit</span>
<span class="stat-value">{Math.round(lake.clarity * 100)}%</span>
</span>
<span class="lake-stat">
<span class="stat-label">Fullstand</span>
<span class="stat-value">{Math.round(lake.level * 100)}%</span>
</span>
</div>
</div>
</div>
<style>
.lake-card {
flex-shrink: 0;
width: 260px;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
overflow: hidden;
transition:
transform 0.2s,
border-color 0.2s;
}
.lake-card:hover {
transform: translateY(-3px);
border-color: rgba(59, 130, 246, 0.2);
}
.lake-preview {
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
padding: 12px;
}
.lake-info {
padding: 12px 14px;
}
.lake-name {
font-size: 15px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 2px 0;
}
.lake-service {
font-size: 11px;
color: #60a5fa;
font-weight: 500;
}
.lake-desc {
font-size: 11px;
color: #94a3b8;
margin: 8px 0;
line-height: 1.4;
}
.lake-stats {
display: flex;
gap: 16px;
}
.lake-stat {
display: flex;
flex-direction: column;
gap: 1px;
}
.stat-label {
font-size: 9px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 13px;
font-weight: 600;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,185 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { getHealthColor } from '../data/colors';
import PlantFactory from '../plants/PlantFactory.svelte';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
const statusLabels: Record<string, string> = {
mature: 'Mature',
production: 'Production',
beta: 'Beta',
alpha: 'Alpha',
prototype: 'Prototype',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
const plantTypeLabels: Record<string, string> = {
oak: 'Eiche',
birch: 'Birke',
youngTree: 'Junger Baum',
reed: 'Schilf',
waterLily: 'Seerose',
moss: 'Moos',
shrub: 'Busch',
sprout: 'Sprossling',
stump: 'Baumstumpf',
swampCluster: 'Sumpfpflanze',
};
// Create a centered version of the app for the card SVG
let cardApp = $derived({
...app,
position: { x: 80, y: 120 },
});
function scoreColor(score: number): string {
if (score >= 85) return '#34d399';
if (score >= 70) return '#60a5fa';
if (score >= 55) return '#fbbf24';
if (score >= 40) return '#f97316';
return '#ef4444';
}
</script>
<button type="button" class="plant-card" {onclick}>
<!-- Plant SVG preview -->
<div class="plant-preview">
<svg width="160" height="160" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg">
<!-- Ground line -->
<ellipse cx="80" cy="125" rx="50" ry="6" fill="#7DB86A" opacity="0.3" />
<PlantFactory app={cardApp} />
</svg>
</div>
<!-- Info -->
<div class="plant-info">
<div class="plant-header">
<span class="plant-name">{app.displayName}</span>
<span class="plant-score" style="color: {scoreColor(app.score)}">{app.score}</span>
</div>
<div class="plant-meta">
<span class="plant-type">{plantTypeLabels[app.plantType] || app.plantType}</span>
<span class="plant-status" style="color: {statusColors[app.status]}">
{statusLabels[app.status]}
</span>
</div>
<!-- Mini score bar -->
<div class="plant-bar">
<div
class="plant-bar-fill"
style="width: {app.score}%; background: {scoreColor(app.score)}"
></div>
</div>
{#if app.trend !== 0}
<div class="plant-trend" class:positive={app.trend > 0} class:negative={app.trend < 0}>
{app.trend > 0 ? '+' : ''}{app.trend} seit letztem Audit
</div>
{/if}
</div>
</button>
<style>
.plant-card {
flex-shrink: 0;
width: 200px;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition:
transform 0.2s,
border-color 0.2s,
box-shadow 0.2s;
text-align: left;
color: inherit;
padding: 0;
}
.plant-card:hover {
transform: translateY(-4px);
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.plant-preview {
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 40%, #7db86a 100%);
padding: 8px 0 0;
}
.plant-info {
padding: 12px;
}
.plant-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.plant-name {
font-size: 14px;
font-weight: 600;
color: #f1f5f9;
}
.plant-score {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.plant-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 8px;
}
.plant-type {
color: #64748b;
}
.plant-status {
font-weight: 500;
}
.plant-bar {
height: 3px;
background: rgba(148, 163, 184, 0.15);
border-radius: 2px;
overflow: hidden;
}
.plant-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.plant-trend {
font-size: 10px;
margin-top: 6px;
}
.plant-trend.positive {
color: #34d399;
}
.plant-trend.negative {
color: #ef4444;
}
</style>

View file

@ -1,9 +1,291 @@
<script lang="ts">
import SeenplatteScene from '$lib/components/observatory/SeenplatteScene.svelte';
import PlantCard from '$lib/components/observatory/ui/PlantCard.svelte';
import LakeCard from '$lib/components/observatory/ui/LakeCard.svelte';
import DetailPanel from '$lib/components/observatory/ui/DetailPanel.svelte';
import { createMockEcosystem } from '$lib/components/observatory/data/mockData';
import { LAKES } from '$lib/components/observatory/data/layout';
import type { AppData } from '$lib/components/observatory/data/types';
type Tab = 'scene' | 'plants' | 'lakes';
let activeTab = $state<Tab>('scene');
let selectedApp = $state<AppData | null>(null);
const apps = createMockEcosystem();
const lakes = LAKES;
// Sort apps by score descending for gallery
const sortedApps = apps.toSorted((a, b) => b.score - a.score);
// Group by status
const matureApps = sortedApps.filter((a) => a.status === 'mature');
const productionApps = sortedApps.filter((a) => a.status === 'production');
const betaApps = sortedApps.filter((a) => a.status === 'beta');
const alphaApps = sortedApps.filter((a) => a.status === 'alpha');
const tabs: { id: Tab; label: string; count?: number }[] = [
{ id: 'scene', label: 'Seenplatte' },
{ id: 'plants', label: 'Pflanzen', count: apps.length },
{ id: 'lakes', label: 'Seen', count: lakes.length },
];
</script>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div style="height: calc(100vh - 12rem); min-height: 500px; width: 100%;">
<SeenplatteScene />
<div class="observatory-page">
<!-- Tab bar -->
<div class="tab-bar">
{#each tabs as tab}
<button
type="button"
class="tab-btn"
class:active={activeTab === tab.id}
onclick={() => (activeTab = tab.id)}
>
{tab.label}
{#if tab.count}
<span class="tab-count">{tab.count}</span>
{/if}
</button>
{/each}
</div>
<!-- Tab content -->
{#if activeTab === 'scene'}
<div class="scene-container">
<SeenplatteScene />
</div>
{:else if activeTab === 'plants'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Alle Pflanzen</h2>
<p class="gallery-subtitle">
Jede App im Mana-Okosystem als Pflanze — Grosse und Art spiegeln den ManaScore wider
</p>
</div>
{#if matureApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #34d399"></span>
Mature
</h3>
<div class="gallery-scroll">
{#each matureApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
{#if productionApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #60a5fa"></span>
Production
</h3>
<div class="gallery-scroll">
{#each productionApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
{#if betaApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #fbbf24"></span>
Beta
</h3>
<div class="gallery-scroll">
{#each betaApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
{#if alphaApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #f97316"></span>
Alpha
</h3>
<div class="gallery-scroll">
{#each alphaApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
</div>
{:else if activeTab === 'lakes'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Alle Seen</h2>
<p class="gallery-subtitle">
Infrastruktur-Services als Gewasser — Klarheit und Fullstand zeigen den Systemzustand
</p>
</div>
<div class="gallery-scroll lakes-scroll">
{#each lakes as lake (lake.id)}
<LakeCard {lake} />
{/each}
</div>
</div>
{/if}
</div>
<!-- Detail panel (shared across tabs) -->
<DetailPanel app={selectedApp} onclose={() => (selectedApp = null)} />
<style>
.observatory-page {
display: flex;
flex-direction: column;
gap: 0;
}
/* Tab bar */
.tab-bar {
display: flex;
gap: 2px;
background: rgba(15, 23, 42, 0.4);
padding: 4px;
border-radius: 10px;
margin-bottom: 12px;
}
.tab-btn {
flex: 1;
padding: 8px 16px;
border: none;
background: transparent;
color: #94a3b8;
font-size: 13px;
font-weight: 500;
border-radius: 7px;
cursor: pointer;
transition:
background 0.2s,
color 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.tab-btn:hover {
color: #cbd5e1;
background: rgba(148, 163, 184, 0.08);
}
.tab-btn.active {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
font-weight: 600;
}
.tab-count {
font-size: 10px;
background: rgba(148, 163, 184, 0.15);
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
}
.tab-btn.active .tab-count {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
/* Scene tab */
.scene-container {
height: calc(100vh - 14rem);
min-height: 500px;
width: 100%;
}
/* Gallery sections */
.gallery-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.gallery-header {
margin-bottom: 4px;
}
.gallery-title {
font-size: 20px;
font-weight: 700;
color: var(--color-foreground, #f1f5f9);
margin: 0 0 4px 0;
}
.gallery-subtitle {
font-size: 13px;
color: var(--color-muted-foreground, #94a3b8);
margin: 0;
}
/* Horizontal scroll container */
.gallery-scroll {
display: flex;
gap: 14px;
overflow-x: auto;
padding-bottom: 12px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.gallery-scroll > :global(*) {
scroll-snap-align: start;
}
/* Hide scrollbar but keep functionality */
.gallery-scroll::-webkit-scrollbar {
height: 6px;
}
.gallery-scroll::-webkit-scrollbar-track {
background: rgba(148, 163, 184, 0.05);
border-radius: 3px;
}
.gallery-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.15);
border-radius: 3px;
}
.gallery-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.25);
}
/* Group headers */
.gallery-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.group-label {
font-size: 12px;
font-weight: 600;
color: var(--color-muted-foreground, #94a3b8);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
</style>