mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
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:
parent
858b7f681e
commit
aeca35ee2b
5 changed files with 1103 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue