feat(observatory): add rivers, leaderboard, compare, and trends tabs

Four new tabs on the observatory page:

- Flusse: Horizontal scroll of all 6 data flow rivers with animated
  SVG preview, from/to labels, speed and width stats
- Rangliste: Sortable table of all 20 apps with score, trend, status
  dot, and mini bar charts for all 8 categories. Click any column
  header to sort, click row for detail panel
- Vergleich: Select up to 4 apps via chip selector, see overlaid
  radar charts and side-by-side category bar comparison
- Trends: Slope chart showing score evolution with hover highlight,
  trend annotations for big movers (+29 Storage, +16 Todo, +15
  Calendar), average score summary card

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-25 09:02:42 +01:00
parent 858b7f681e
commit aeca35ee2b
5 changed files with 1103 additions and 2 deletions

View file

@ -0,0 +1,359 @@
<script lang="ts">
import type { AppData, CategoryScores } from '../data/types';
import RadarChart from './RadarChart.svelte';
let { apps }: { apps: AppData[] } = $props();
let selected = $state<string[]>([]);
const colors = ['#3b82f6', '#f59e0b', '#10b981', '#ef4444'];
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
};
function toggle(id: string) {
if (selected.includes(id)) {
selected = selected.filter((s) => s !== id);
} else if (selected.length < 4) {
selected = [...selected, id];
}
}
let selectedApps = $derived(selected.map((id) => apps.find((a) => a.id === id)!).filter(Boolean));
const categoryKeys: (keyof CategoryScores)[] = [
'backend',
'frontend',
'database',
'testing',
'deployment',
'documentation',
'security',
'ux',
];
const labels = [
{ key: 'backend', label: 'Backend' },
{ key: 'frontend', label: 'Frontend' },
{ key: 'database', label: 'Database' },
{ key: 'testing', label: 'Testing' },
{ key: 'deployment', label: 'Deploy' },
{ key: 'documentation', label: 'Docs' },
{ key: 'security', label: 'Security' },
{ key: 'ux', label: 'UX' },
] as const;
const size = 280;
let center = $derived(size / 2);
let maxR = $derived(size / 2 - 30);
function polarToXY(angle: number, radius: number) {
const rad = ((angle - 90) * Math.PI) / 180;
return { x: center + Math.cos(rad) * radius, y: center + Math.sin(rad) * radius };
}
let rings = $derived(
[0.25, 0.5, 0.75, 1].map((pct) => {
const r = maxR * pct;
return labels
.map((_, i) => {
const angle = (360 / labels.length) * i;
return polarToXY(angle, r);
})
.map((p) => `${p.x},${p.y}`)
.join(' ');
})
);
let axes = $derived(labels.map((_, i) => polarToXY((360 / labels.length) * i, maxR)));
let labelPositions = $derived(
labels.map((l, i) => {
const pos = polarToXY((360 / labels.length) * i, maxR + 20);
return { ...pos, label: l.label };
})
);
function appPolygon(app: AppData): string {
return labels
.map((l, i) => {
const angle = (360 / labels.length) * i;
const value = app.categories[l.key] / 100;
const p = polarToXY(angle, maxR * value);
return `${p.x},${p.y}`;
})
.join(' ');
}
</script>
<div class="compare-view">
<!-- App selector -->
<div class="selector">
<p class="selector-label">Apps auswahlen (max. 4):</p>
<div class="selector-chips">
{#each apps as app (app.id)}
<button
type="button"
class="chip"
class:active={selected.includes(app.id)}
style={selected.includes(app.id)
? `border-color: ${colors[selected.indexOf(app.id)]}; color: ${colors[selected.indexOf(app.id)]}`
: ''}
onclick={() => toggle(app.id)}
>
<span class="chip-dot" style="background: {statusColors[app.status]}"></span>
{app.displayName}
<span class="chip-score">{app.score}</span>
</button>
{/each}
</div>
</div>
{#if selectedApps.length >= 2}
<div class="compare-content">
<!-- Overlaid radar chart -->
<div class="radar-section">
<svg width={size} height={size} viewBox="0 0 {size} {size}">
{#each rings as ring}
<polygon
points={ring}
fill="none"
stroke="rgba(148, 163, 184, 0.12)"
stroke-width="0.5"
/>
{/each}
{#each axes as axis}
<line
x1={center}
y1={center}
x2={axis.x}
y2={axis.y}
stroke="rgba(148, 163, 184, 0.08)"
stroke-width="0.5"
/>
{/each}
{#each selectedApps as app, i (app.id)}
<polygon
points={appPolygon(app)}
fill="{colors[i]}20"
stroke={colors[i]}
stroke-width="2"
opacity="0.8"
/>
{/each}
{#each labelPositions as lp}
<text
x={lp.x}
y={lp.y}
text-anchor="middle"
dominant-baseline="central"
font-size="9"
font-family="system-ui, sans-serif"
fill="#94a3b8"
>
{lp.label}
</text>
{/each}
</svg>
<!-- Legend -->
<div class="radar-legend">
{#each selectedApps as app, i (app.id)}
<span class="legend-item">
<span class="legend-color" style="background: {colors[i]}"></span>
{app.displayName} ({app.score})
</span>
{/each}
</div>
</div>
<!-- Category comparison bars -->
<div class="bars-section">
{#each categoryKeys as cat}
<div class="bar-group">
<span class="bar-label">{cat}</span>
<div class="bar-rows">
{#each selectedApps as app, i (app.id)}
<div class="bar-row">
<div class="bar-track">
<div
class="bar-fill"
style="width: {app.categories[cat]}%; background: {colors[i]}"
></div>
</div>
<span class="bar-value">{app.categories[cat]}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="compare-placeholder">
<p>Wahle mindestens 2 Apps zum Vergleichen</p>
</div>
{/if}
</div>
<style>
.compare-view {
display: flex;
flex-direction: column;
gap: 24px;
}
.selector-label {
font-size: 12px;
color: #94a3b8;
margin: 0 0 10px 0;
}
.selector-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(15, 23, 42, 0.4);
color: #94a3b8;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.chip:hover {
border-color: rgba(148, 163, 184, 0.3);
color: #cbd5e1;
}
.chip.active {
background: rgba(59, 130, 246, 0.1);
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.chip-score {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.compare-content {
display: grid;
grid-template-columns: auto 1fr;
gap: 32px;
align-items: start;
}
@media (max-width: 768px) {
.compare-content {
grid-template-columns: 1fr;
}
}
.radar-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.radar-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #cbd5e1;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 3px;
}
.bars-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.bar-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.bar-label {
font-size: 10px;
color: #64748b;
text-transform: capitalize;
font-weight: 600;
}
.bar-rows {
display: flex;
flex-direction: column;
gap: 3px;
}
.bar-row {
display: flex;
align-items: center;
gap: 8px;
}
.bar-track {
flex: 1;
height: 6px;
background: rgba(148, 163, 184, 0.08);
border-radius: 3px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.bar-value {
font-size: 10px;
color: #94a3b8;
width: 22px;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.compare-placeholder {
text-align: center;
padding: 48px 0;
color: #64748b;
font-size: 14px;
}
</style>

View file

@ -0,0 +1,295 @@
<script lang="ts">
import type { AppData, CategoryScores } from '../data/types';
let { apps, onselect }: { apps: AppData[]; onselect: (app: AppData) => void } = $props();
type SortKey = 'score' | 'name' | 'trend' | keyof CategoryScores;
let sortKey = $state<SortKey>('score');
let sortAsc = $state(false);
const categoryKeys: (keyof CategoryScores)[] = [
'backend',
'frontend',
'database',
'testing',
'deployment',
'documentation',
'security',
'ux',
];
const shortLabels: Record<string, string> = {
backend: 'BE',
frontend: 'FE',
database: 'DB',
testing: 'Test',
deployment: 'Deploy',
documentation: 'Docs',
security: 'Sec',
ux: 'UX',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
function scoreColor(score: number): string {
if (score >= 85) return '#34d399';
if (score >= 70) return '#60a5fa';
if (score >= 50) return '#fbbf24';
if (score >= 30) return '#f97316';
return '#ef4444';
}
function toggleSort(key: SortKey) {
if (sortKey === key) {
sortAsc = !sortAsc;
} else {
sortKey = key;
sortAsc = false;
}
}
let sorted = $derived(() => {
const arr = [...apps];
arr.sort((a, b) => {
let va: number | string, vb: number | string;
if (sortKey === 'score') {
va = a.score;
vb = b.score;
} else if (sortKey === 'name') {
va = a.displayName.toLowerCase();
vb = b.displayName.toLowerCase();
} else if (sortKey === 'trend') {
va = a.trend;
vb = b.trend;
} else {
va = a.categories[sortKey];
vb = b.categories[sortKey];
}
if (va < vb) return sortAsc ? -1 : 1;
if (va > vb) return sortAsc ? 1 : -1;
return 0;
});
return arr;
});
function sortIndicator(key: SortKey): string {
if (sortKey !== key) return '';
return sortAsc ? ' ↑' : ' ↓';
}
</script>
<div class="leaderboard">
<div class="table-scroll">
<table>
<thead>
<tr>
<th class="rank-col">#</th>
<th class="name-col sortable" onclick={() => toggleSort('name')}>
App{sortIndicator('name')}
</th>
<th class="score-col sortable" onclick={() => toggleSort('score')}>
Score{sortIndicator('score')}
</th>
<th class="trend-col sortable" onclick={() => toggleSort('trend')}>
Trend{sortIndicator('trend')}
</th>
{#each categoryKeys as cat}
<th class="cat-col sortable" onclick={() => toggleSort(cat)}>
{shortLabels[cat]}{sortIndicator(cat)}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted() as app, i (app.id)}
<tr class="app-row" onclick={() => onselect(app)}>
<td class="rank">{i + 1}</td>
<td class="name-cell">
<span class="status-dot" style="background: {statusColors[app.status]}"></span>
{app.displayName}
</td>
<td class="score-cell" style="color: {scoreColor(app.score)}">
{app.score}
</td>
<td class="trend-cell">
{#if app.trend > 0}
<span class="trend-up">+{app.trend}</span>
{:else if app.trend < 0}
<span class="trend-down">{app.trend}</span>
{:else}
<span class="trend-neutral">-</span>
{/if}
</td>
{#each categoryKeys as cat}
<td class="cat-cell">
<div class="cat-bar-wrap">
<div
class="cat-bar-fill"
style="width: {app.categories[cat]}%; background: {scoreColor(
app.categories[cat]
)}"
></div>
</div>
<span class="cat-value">{app.categories[cat]}</span>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
.leaderboard {
width: 100%;
}
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
}
thead tr {
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
th {
font-size: 10px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 8px 6px;
text-align: left;
white-space: nowrap;
user-select: none;
}
th.sortable {
cursor: pointer;
}
th.sortable:hover {
color: #94a3b8;
}
.rank-col {
width: 32px;
text-align: center;
}
.name-col {
width: 120px;
}
.score-col {
width: 56px;
text-align: right;
}
.trend-col {
width: 48px;
text-align: right;
}
.cat-col {
width: 70px;
text-align: right;
}
.app-row {
border-bottom: 1px solid rgba(148, 163, 184, 0.05);
cursor: pointer;
transition: background 0.15s;
}
.app-row:hover {
background: rgba(59, 130, 246, 0.06);
}
td {
padding: 8px 6px;
font-size: 12px;
color: #cbd5e1;
}
.rank {
text-align: center;
color: #64748b;
font-weight: 600;
font-size: 11px;
}
.name-cell {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: #f1f5f9;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.score-cell {
text-align: right;
font-weight: 700;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.trend-cell {
text-align: right;
font-size: 11px;
font-weight: 600;
}
.trend-up {
color: #34d399;
}
.trend-down {
color: #ef4444;
}
.trend-neutral {
color: #475569;
}
.cat-cell {
text-align: right;
}
.cat-bar-wrap {
display: inline-block;
width: 32px;
height: 4px;
background: rgba(148, 163, 184, 0.1);
border-radius: 2px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
}
.cat-bar-fill {
height: 100%;
border-radius: 2px;
}
.cat-value {
font-size: 10px;
font-variant-numeric: tabular-nums;
color: #94a3b8;
}
</style>

View file

@ -0,0 +1,158 @@
<script lang="ts">
import type { RiverData } from '../data/types';
import { LAKES } from '../data/layout';
import { water } from '../data/colors';
let { river }: { river: RiverData } = $props();
function lakeName(id: string): string {
const lake = LAKES.find((l) => l.id === id);
return lake?.label || id;
}
let fromLabel = $derived(river.from === 'source' ? 'User Requests' : lakeName(river.from));
let toLabel = $derived(lakeName(river.to));
let speedLabel = $derived(
river.flowSpeed >= 0.8 ? 'Schnell' : river.flowSpeed >= 0.5 ? 'Mittel' : 'Ruhig'
);
let speedColor = $derived(
river.flowSpeed >= 0.8 ? '#34d399' : river.flowSpeed >= 0.5 ? '#fbbf24' : '#60a5fa'
);
</script>
<div class="river-card">
<!-- SVG preview -->
<div class="river-preview">
<svg width="240" height="60" viewBox="0 0 240 60" xmlns="http://www.w3.org/2000/svg">
<!-- River line -->
<path
d="M20,30 Q80,15 120,30 Q160,45 220,30"
fill="none"
stroke={water.river}
stroke-width={river.width * 0.8}
stroke-linecap="round"
opacity="0.7"
/>
<!-- Flow particles -->
<path
d="M20,30 Q80,15 120,30 Q160,45 220,30"
fill="none"
stroke={water.highlight}
stroke-width={river.width * 0.3}
stroke-linecap="round"
stroke-dasharray="8 16"
opacity="0.5"
>
<animate
attributeName="stroke-dashoffset"
values="24;0"
dur="{2 + (1 - river.flowSpeed) * 3}s"
repeatCount="indefinite"
/>
</path>
<!-- Endpoints -->
<circle cx="20" cy="30" r="4" fill={water.deep} />
<circle cx="220" cy="30" r="4" fill={water.deep} />
<!-- Arrow -->
<path
d="M210,26 L220,30 L210,34"
fill="none"
stroke={water.highlight}
stroke-width="1.5"
opacity="0.6"
/>
</svg>
</div>
<div class="river-info">
<div class="river-flow">
<span class="river-from">{fromLabel}</span>
<span class="river-arrow"></span>
<span class="river-to">{toLabel}</span>
</div>
<div class="river-stats">
<span class="river-stat">
<span class="stat-label">Geschwindigkeit</span>
<span class="stat-value" style="color: {speedColor}">{speedLabel}</span>
</span>
<span class="river-stat">
<span class="stat-label">Breite</span>
<span class="stat-value">{river.width}px</span>
</span>
</div>
</div>
</div>
<style>
.river-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;
}
.river-card:hover {
transform: translateY(-3px);
border-color: rgba(59, 130, 246, 0.2);
}
.river-preview {
display: flex;
justify-content: center;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
padding: 8px 0;
}
.river-info {
padding: 12px 14px;
}
.river-flow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.river-from,
.river-to {
font-size: 12px;
font-weight: 600;
color: #e2e8f0;
}
.river-arrow {
color: #60a5fa;
font-size: 14px;
}
.river-stats {
display: flex;
gap: 16px;
}
.river-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: 12px;
font-weight: 600;
color: #cbd5e1;
}
</style>

View file

@ -0,0 +1,232 @@
<script lang="ts">
import type { AppData } from '../data/types';
let { apps }: { apps: AppData[] } = $props();
// Build timeline data from apps that have trends (previousScore)
// For now we create 2 data points: "before" and "now"
const timePoints = ['Vorher', 'Aktuell'];
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
// Chart dimensions
const width = 800;
const height = 400;
const padL = 50;
const padR = 30;
const padT = 20;
const padB = 40;
const chartW = width - padL - padR;
const chartH = height - padT - padB;
// Apps sorted by current score
let sorted = $derived(apps.toSorted((a, b) => b.score - a.score));
// Generate line path for each app
function appLine(app: AppData): { path: string; startY: number; endY: number } {
const prev = app.trend !== 0 ? app.score - app.trend : app.score;
const x0 = padL;
const x1 = padL + chartW;
const y0 = padT + chartH - (prev / 100) * chartH;
const y1 = padT + chartH - (app.score / 100) * chartH;
return {
path: `M${x0},${y0} L${x1},${y1}`,
startY: y0,
endY: y1,
};
}
// Grid lines
const gridLines = [0, 25, 50, 75, 100];
let hoveredApp = $state<string | null>(null);
let gainers = $derived(sorted.filter((a) => a.trend > 0).sort((a, b) => b.trend - a.trend));
let avgScore = $derived(Math.round(sorted.reduce((s, a) => s + a.score, 0) / sorted.length));
</script>
<div class="trends-chart">
<svg
viewBox="0 0 {width} {height}"
style="width: 100%; height: auto; max-height: 450px;"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Grid -->
{#each gridLines as val}
{@const y = padT + chartH - (val / 100) * chartH}
<line
x1={padL}
y1={y}
x2={padL + chartW}
y2={y}
stroke="rgba(148, 163, 184, 0.08)"
stroke-width="0.5"
/>
<text
x={padL - 8}
{y}
text-anchor="end"
dominant-baseline="central"
font-size="9"
fill="#64748b"
>
{val}
</text>
{/each}
<!-- Time labels -->
<text x={padL} y={height - 10} text-anchor="start" font-size="10" fill="#64748b">
{timePoints[0]}
</text>
<text x={padL + chartW} y={height - 10} text-anchor="end" font-size="10" fill="#64748b">
{timePoints[1]}
</text>
<!-- Lines for each app -->
{#each sorted as app (app.id)}
{@const line = appLine(app)}
{@const isHovered = hoveredApp === app.id}
{@const color = statusColors[app.status] || '#94a3b8'}
<g opacity={hoveredApp && !isHovered ? 0.15 : 1} style="transition: opacity 0.2s;">
<!-- Line -->
<path
d={line.path}
fill="none"
stroke={color}
stroke-width={isHovered ? 3 : 1.5}
stroke-linecap="round"
/>
<!-- Start dot -->
<circle cx={padL} cy={line.startY} r={isHovered ? 4 : 2.5} fill={color} />
<!-- End dot -->
<circle cx={padL + chartW} cy={line.endY} r={isHovered ? 4 : 2.5} fill={color} />
<!-- Label at end -->
<text
x={padL + chartW + 6}
y={line.endY}
font-size={isHovered ? 11 : 9}
font-weight={isHovered ? 600 : 400}
fill={isHovered ? '#f1f5f9' : '#94a3b8'}
dominant-baseline="central"
>
{app.displayName}
{app.score}
</text>
<!-- Hover area (invisible wide line) -->
<path
d={line.path}
fill="none"
stroke="transparent"
stroke-width="12"
onmouseenter={() => (hoveredApp = app.id)}
onmouseleave={() => (hoveredApp = null)}
style="cursor: pointer;"
/>
</g>
{/each}
<!-- Trend annotation for apps with big changes -->
{#each sorted.filter((a) => Math.abs(a.trend) >= 10) as app (app.id)}
{@const line = appLine(app)}
{@const midX = padL + chartW * 0.5}
{@const midY = (line.startY + line.endY) / 2}
<text
x={midX}
y={midY - 8}
text-anchor="middle"
font-size="10"
font-weight="600"
fill={app.trend > 0 ? '#34d399' : '#ef4444'}
opacity={hoveredApp && hoveredApp !== app.id ? 0.15 : 0.7}
>
{app.trend > 0 ? '+' : ''}{app.trend}
</text>
{/each}
</svg>
<!-- Summary cards -->
<div class="trend-summary">
{#if gainers.length}
<div class="summary-card">
<span class="summary-label">Grosste Verbesserungen</span>
<div class="summary-items">
{#each gainers as app (app.id)}
<span class="summary-item gain">
{app.displayName} <strong>+{app.trend}</strong>
</span>
{/each}
</div>
</div>
{/if}
<div class="summary-card">
<span class="summary-label">Durchschnitt</span>
<span class="summary-value">{avgScore}</span>
</div>
</div>
</div>
<style>
.trends-chart {
display: flex;
flex-direction: column;
gap: 20px;
}
.trend-summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.summary-card {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.08);
border-radius: 10px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.summary-label {
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.summary-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.summary-item {
font-size: 12px;
color: #cbd5e1;
}
.summary-item.gain strong {
color: #34d399;
}
.summary-value {
font-size: 24px;
font-weight: 800;
color: #f1f5f9;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -2,17 +2,22 @@
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 RiverCard from '$lib/components/observatory/ui/RiverCard.svelte';
import Leaderboard from '$lib/components/observatory/ui/Leaderboard.svelte';
import CompareView from '$lib/components/observatory/ui/CompareView.svelte';
import TrendsChart from '$lib/components/observatory/ui/TrendsChart.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 { LAKES, RIVERS } from '$lib/components/observatory/data/layout';
import type { AppData } from '$lib/components/observatory/data/types';
type Tab = 'scene' | 'plants' | 'lakes';
type Tab = 'scene' | 'plants' | 'lakes' | 'rivers' | 'leaderboard' | 'compare' | 'trends';
let activeTab = $state<Tab>('scene');
let selectedApp = $state<AppData | null>(null);
const apps = createMockEcosystem();
const lakes = LAKES;
const rivers = RIVERS;
// Sort apps by score descending for gallery
const sortedApps = apps.toSorted((a, b) => b.score - a.score);
@ -27,6 +32,10 @@
{ id: 'scene', label: 'Seenplatte' },
{ id: 'plants', label: 'Pflanzen', count: apps.length },
{ id: 'lakes', label: 'Seen', count: lakes.length },
{ id: 'rivers', label: 'Flusse', count: rivers.length },
{ id: 'leaderboard', label: 'Rangliste' },
{ id: 'compare', label: 'Vergleich' },
{ id: 'trends', label: 'Trends' },
];
</script>
@ -133,6 +142,54 @@
{/each}
</div>
</div>
{:else if activeTab === 'rivers'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Alle Flusse</h2>
<p class="gallery-subtitle">
Datenstrome zwischen den Seen — Geschwindigkeit und Breite zeigen den Durchsatz
</p>
</div>
<div class="gallery-scroll">
{#each rivers as river (river.id)}
<RiverCard {river} />
{/each}
</div>
</div>
{:else if activeTab === 'leaderboard'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Rangliste</h2>
<p class="gallery-subtitle">
Alle Apps sortiert nach Score — klicke auf Spalten zum Sortieren, auf Zeilen fur Details
</p>
</div>
<Leaderboard apps={sortedApps} onselect={(app) => (selectedApp = app)} />
</div>
{:else if activeTab === 'compare'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Vergleich</h2>
<p class="gallery-subtitle">
Wahle bis zu 4 Apps und vergleiche ihre Starken und Schwachen direkt
</p>
</div>
<CompareView {apps} />
</div>
{:else if activeTab === 'trends'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Trends</h2>
<p class="gallery-subtitle">
Score-Entwicklung aller Apps uber die Zeit — hover uber eine Linie fur Details
</p>
</div>
<TrendsChart {apps} />
</div>
{/if}
</div>