mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(articles): bulk-import schema + plan (Phase 1)
Three new sync-tracked Dexie tables under the articles appId:
articleImportJobs — job header (counters, status, lease metadata).
articleImportItems — one row per URL in a job, state-machine driven.
articleExtractPickup — short-lived server→client handoff inbox.
URL stays plaintext on items by necessity — the server-worker reads it
without master-key access, same rationale as articles.originalUrl. The
extracted article eventually lands encrypted in the existing `articles`
table; bulk-import rows hold only pointers.
Plan: docs/plans/articles-bulk-import.md (full architecture, 7 phases,
test matrix, edge-cases). Phase 2 already shipped in 5535f2da4 (worker);
this commit lays the schema underneath it.
Originally committed as b2f4e8314, lost during a parallel reset, here
restored via cherry-pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5535f2da48
commit
7bca16dfa7
59 changed files with 785 additions and 4249 deletions
|
|
@ -1,199 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { SCENE, LAKES, RIVERS } from './data/layout';
|
||||
import { createMockEcosystem } from './data/mockData';
|
||||
import type { AppData } from './data/types';
|
||||
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';
|
||||
import Ambient from './atmosphere/Ambient.svelte';
|
||||
import PlantTooltip from './ui/PlantTooltip.svelte';
|
||||
import DetailPanel from './ui/DetailPanel.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let apps = $state(createMockEcosystem());
|
||||
let hour = $state(new Date().getHours() + new Date().getMinutes() / 60);
|
||||
|
||||
onMount(() => {
|
||||
// Update time every minute
|
||||
const interval = setInterval(() => {
|
||||
hour = new Date().getHours() + new Date().getMinutes() / 60;
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
let selectedApp = $state<AppData | null>(null);
|
||||
let hoveredApp = $state<AppData | null>(null);
|
||||
let tooltipPos = $state({ x: 0, y: 0 });
|
||||
|
||||
// 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 hasPanned = $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;
|
||||
|
||||
const mx = (e.clientX - rect.left) / rect.width;
|
||||
const my = (e.clientY - rect.top) / rect.height;
|
||||
const px = viewBox.x + mx * viewBox.w;
|
||||
const py = viewBox.y + my * viewBox.h;
|
||||
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));
|
||||
|
||||
viewBox = { x: px - mx * nw, y: py - my * nh, w: nw, h: nh };
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
isPanning = true;
|
||||
hasPanned = false;
|
||||
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;
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) hasPanned = true;
|
||||
viewBox = { ...viewBox, x: panViewBoxStart.x - dx, y: panViewBoxStart.y - dy };
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
isPanning = false;
|
||||
}
|
||||
|
||||
function handleAppClick(app: AppData) {
|
||||
if (hasPanned) return;
|
||||
selectedApp = app;
|
||||
}
|
||||
|
||||
function handleAppHover(app: AppData, e: MouseEvent) {
|
||||
hoveredApp = app;
|
||||
tooltipPos = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
function handleAppHoverMove(e: MouseEvent) {
|
||||
tooltipPos = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
function handleAppLeave() {
|
||||
hoveredApp = null;
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
selectedApp = null;
|
||||
}
|
||||
|
||||
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 overflow-hidden rounded-xl"
|
||||
style="width: 100%; height: 100%; min-height: 500px; background-color: #e0f2fe;"
|
||||
>
|
||||
<!-- 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>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox={viewBoxStr}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="width: 100%; height: 100%; display: block; cursor: {isPanning ? 'grabbing' : 'grab'};"
|
||||
onwheel={handleWheel}
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointerleave={handlePointerUp}
|
||||
>
|
||||
<!-- Layer 1: Sky + Mountains -->
|
||||
<Background {hour} />
|
||||
|
||||
<!-- 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: Ambient creatures -->
|
||||
<Ambient {hour} />
|
||||
|
||||
<!-- Layer 6: Plants (apps) sorted by y-position for depth -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
{#each apps.toSorted((a, b) => a.position.y - b.position.y) as app (app.id)}
|
||||
<g
|
||||
onmouseenter={(e) => handleAppHover(app, e)}
|
||||
onmousemove={handleAppHoverMove}
|
||||
onmouseleave={handleAppLeave}
|
||||
>
|
||||
<PlantFactory {app} onclick={() => handleAppClick(app)} />
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<!-- Tooltip (HTML overlay, positioned via mouse coordinates) -->
|
||||
{#if hoveredApp && !selectedApp}
|
||||
<PlantTooltip app={hoveredApp} x={tooltipPos.x} y={tooltipPos.y} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel (outside the SVG container for proper positioning) -->
|
||||
<DetailPanel app={selectedApp} onclose={closePanel} />
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { SCENE } from '../data/layout';
|
||||
|
||||
let { hour = 12 }: { hour?: number } = $props();
|
||||
|
||||
let isNight = $derived(hour < 6 || hour > 20.5);
|
||||
|
||||
// Bird flight paths (simple arcs)
|
||||
const birds = [
|
||||
{ startX: -50, startY: 180, endX: SCENE.width + 50, endY: 150, dur: 25, delay: 0 },
|
||||
{ startX: -30, startY: 220, endX: SCENE.width + 30, endY: 200, dur: 30, delay: 8 },
|
||||
{ startX: SCENE.width + 40, startY: 160, endX: -40, endY: 190, dur: 28, delay: 15 },
|
||||
];
|
||||
|
||||
// Firefly positions for night mode
|
||||
const fireflies = Array.from({ length: 15 }, (_, i) => ({
|
||||
cx: ((i * 137 + 80) % (SCENE.width - 200)) + 100,
|
||||
cy: 450 + ((i * 89) % 350),
|
||||
dur: 3 + (i % 4),
|
||||
delay: (i % 6) * 0.8,
|
||||
}));
|
||||
|
||||
// Dragonfly paths over lakes
|
||||
const dragonflies = [
|
||||
{ cx: 790, cy: 470, rx: 40, ry: 20, dur: 6 },
|
||||
{ cx: 340, cy: 410, rx: 25, ry: 15, dur: 5 },
|
||||
{ cx: 760, cy: 650, rx: 35, ry: 18, dur: 7 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- Birds (daytime only) -->
|
||||
{#if !isNight}
|
||||
{#each birds as bird}
|
||||
<g opacity="0.5">
|
||||
<!-- Simple V-shape bird -->
|
||||
<g>
|
||||
<animateMotion
|
||||
dur="{bird.dur}s"
|
||||
begin="{bird.delay}s"
|
||||
repeatCount="indefinite"
|
||||
path="M{bird.startX},{bird.startY} Q{SCENE.width / 2},{bird.startY -
|
||||
40} {bird.endX},{bird.endY}"
|
||||
/>
|
||||
<path
|
||||
d="M-5,0 L0,-2 L5,0"
|
||||
fill="none"
|
||||
stroke="#3A4A5A"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
<!-- Dragonflies over lakes -->
|
||||
{#each dragonflies as df}
|
||||
<g opacity="0.4">
|
||||
<circle r="1.5" fill="#60A0C0">
|
||||
<animateMotion
|
||||
dur="{df.dur}s"
|
||||
repeatCount="indefinite"
|
||||
path="M{df.cx},{df.cy} Q{df.cx + df.rx},{df.cy - df.ry} {df.cx},{df.cy -
|
||||
df.ry * 2} Q{df.cx - df.rx},{df.cy - df.ry} {df.cx},{df.cy}"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.3;0.7;0.3"
|
||||
dur="{df.dur / 2}s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Fireflies (night only) -->
|
||||
{#if isNight}
|
||||
{#each fireflies as ff}
|
||||
<circle cx={ff.cx} cy={ff.cy} r="2" fill="#E8E060">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.8;0.8;0"
|
||||
dur="{ff.dur}s"
|
||||
begin="{ff.delay}s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="cx"
|
||||
values="{ff.cx};{ff.cx + 8};{ff.cx - 5};{ff.cx}"
|
||||
dur="{ff.dur * 2}s"
|
||||
begin="{ff.delay}s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values="{ff.cy};{ff.cy - 10};{ff.cy - 5};{ff.cy}"
|
||||
dur="{ff.dur * 1.5}s"
|
||||
begin="{ff.delay}s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Butterflies near plants (daytime, subtle) -->
|
||||
{#if !isNight}
|
||||
<g opacity="0.35">
|
||||
<g>
|
||||
<animateMotion
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
path="M730,400 Q750,380 780,395 Q800,385 790,405 Q770,410 730,400"
|
||||
/>
|
||||
<path d="M0,0 L-3,-3 L0,-1 L3,-3 Z" fill="#D080C0" />
|
||||
</g>
|
||||
<g>
|
||||
<animateMotion
|
||||
dur="10s"
|
||||
begin="4s"
|
||||
repeatCount="indefinite"
|
||||
path="M350,540 Q370,520 390,535 Q380,550 360,555 Q340,550 350,540"
|
||||
/>
|
||||
<path d="M0,0 L-3,-3 L0,-1 L3,-3 Z" fill="#E0A040" />
|
||||
</g>
|
||||
</g>
|
||||
{/if}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { SCENE } from '../data/layout';
|
||||
|
||||
let { hour = new Date().getHours() + new Date().getMinutes() / 60 }: { hour?: number } = $props();
|
||||
|
||||
// Interpolate between time-of-day color presets
|
||||
const presets = [
|
||||
{ h: 0, top: '#0B1026', bottom: '#1A2444' }, // midnight
|
||||
{ h: 5, top: '#1A1B3A', bottom: '#2D3458' }, // pre-dawn
|
||||
{ h: 6.5, top: '#4A3060', bottom: '#E8956A' }, // sunrise
|
||||
{ h: 8, top: '#6B9FD4', bottom: '#E0E8F0' }, // morning
|
||||
{ h: 12, top: '#87CEEB', bottom: '#E0F0FF' }, // noon
|
||||
{ h: 17, top: '#87CEEB', bottom: '#E0F0FF' }, // afternoon
|
||||
{ h: 19, top: '#C45030', bottom: '#F0B860' }, // sunset
|
||||
{ h: 20.5, top: '#2C1654', bottom: '#4A3060' }, // dusk
|
||||
{ h: 22, top: '#0B1026', bottom: '#1A2444' }, // night
|
||||
{ h: 24, top: '#0B1026', bottom: '#1A2444' }, // midnight wrap
|
||||
];
|
||||
|
||||
function lerp(a: string, b: string, t: number): string {
|
||||
const ah = parseInt(a.slice(1), 16);
|
||||
const bh = parseInt(b.slice(1), 16);
|
||||
const r = Math.round(((ah >> 16) & 0xff) * (1 - t) + ((bh >> 16) & 0xff) * t);
|
||||
const g = Math.round(((ah >> 8) & 0xff) * (1 - t) + ((bh >> 8) & 0xff) * t);
|
||||
const bl = Math.round((ah & 0xff) * (1 - t) + (bh & 0xff) * t);
|
||||
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
function getSkyColors(h: number) {
|
||||
for (let i = 0; i < presets.length - 1; i++) {
|
||||
if (h >= presets[i].h && h < presets[i + 1].h) {
|
||||
const t = (h - presets[i].h) / (presets[i + 1].h - presets[i].h);
|
||||
return {
|
||||
top: lerp(presets[i].top, presets[i + 1].top, t),
|
||||
bottom: lerp(presets[i].bottom, presets[i + 1].bottom, t),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { top: presets[0].top, bottom: presets[0].bottom };
|
||||
}
|
||||
|
||||
let colors = $derived(getSkyColors(hour));
|
||||
let isNight = $derived(hour < 6 || hour > 20.5);
|
||||
let isDusk = $derived(hour > 18.5 && hour < 20.5);
|
||||
let gradientId = 'sky-gradient-dynamic';
|
||||
</script>
|
||||
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color={colors.top} />
|
||||
<stop offset="100%" stop-color={colors.bottom} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width={SCENE.width} height="360" fill="url(#{gradientId})" />
|
||||
|
||||
<!-- Stars (visible at night) -->
|
||||
{#if isNight}
|
||||
<g opacity={hour < 5 ? 0.7 : 0.4}>
|
||||
{#each Array(35) as _, i}
|
||||
{@const sx = (i * 137 + 42) % SCENE.width}
|
||||
{@const sy = (i * 89 + 17) % 250}
|
||||
{@const sr = 0.5 + (i % 3) * 0.4}
|
||||
<circle cx={sx} cy={sy} r={sr} fill="white">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.3;0.9;0.3"
|
||||
dur="{2 + (i % 4)}s"
|
||||
begin="{(i % 7) * 0.5}s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
{/each}
|
||||
</g>
|
||||
{/if}
|
||||
|
||||
<!-- Sun glow (during golden hours) -->
|
||||
{#if isDusk}
|
||||
<circle cx="1400" cy="260" r="80" fill="#F0A040" opacity="0.15">
|
||||
<animate attributeName="opacity" values="0.1;0.2;0.1" dur="4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="1400" cy="260" r="30" fill="#F0C860" opacity="0.3" />
|
||||
{/if}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
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
|
||||
mana: { 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)
|
||||
quotes: { x: 640, y: 600, lakeId: 'db-center' },
|
||||
music: { x: 850, y: 610, lakeId: 'db-center' },
|
||||
clock: { x: 880, y: 680, lakeId: 'db-center' },
|
||||
food: { 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' },
|
||||
plants: { x: 1115, y: 675, lakeId: 'db-right' },
|
||||
|
||||
// Around Bergsee (redis) - lightweight/cache
|
||||
traces: { x: 400, y: 385, lakeId: 'redis' },
|
||||
|
||||
// Around Stausee (minio) - storage-heavy
|
||||
cards: { x: 1180, y: 385, lakeId: 'minio' },
|
||||
questions: { x: 1370, y: 400, lakeId: 'minio' },
|
||||
};
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
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;
|
||||
previousScore?: number;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// Real ManaScore data from 2026-03-24 audits
|
||||
const APP_DEFINITIONS: AppDefinition[] = [
|
||||
{
|
||||
id: 'calendar',
|
||||
displayName: 'Calendar',
|
||||
score: 97,
|
||||
previousScore: 82,
|
||||
status: 'mature',
|
||||
categories: {
|
||||
backend: 95,
|
||||
frontend: 96,
|
||||
database: 92,
|
||||
testing: 90,
|
||||
deployment: 92,
|
||||
documentation: 98,
|
||||
security: 92,
|
||||
ux: 95,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'todo',
|
||||
displayName: 'Todo',
|
||||
score: 96,
|
||||
previousScore: 80,
|
||||
status: 'mature',
|
||||
categories: {
|
||||
backend: 94,
|
||||
frontend: 95,
|
||||
database: 88,
|
||||
testing: 90,
|
||||
deployment: 92,
|
||||
documentation: 95,
|
||||
security: 90,
|
||||
ux: 94,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
displayName: 'Contacts',
|
||||
score: 94,
|
||||
status: 'production',
|
||||
categories: {
|
||||
backend: 92,
|
||||
frontend: 90,
|
||||
database: 88,
|
||||
testing: 88,
|
||||
deployment: 90,
|
||||
documentation: 92,
|
||||
security: 85,
|
||||
ux: 85,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mana',
|
||||
displayName: 'Mana',
|
||||
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: 82,
|
||||
deployment: 75,
|
||||
documentation: 90,
|
||||
security: 85,
|
||||
ux: 82,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
displayName: 'Storage',
|
||||
score: 84,
|
||||
previousScore: 55,
|
||||
status: 'production',
|
||||
categories: {
|
||||
backend: 88,
|
||||
frontend: 84,
|
||||
database: 82,
|
||||
testing: 78,
|
||||
deployment: 65,
|
||||
documentation: 78,
|
||||
security: 78,
|
||||
ux: 75,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
displayName: 'Chat',
|
||||
score: 82,
|
||||
status: 'production',
|
||||
categories: {
|
||||
backend: 90,
|
||||
frontend: 82,
|
||||
database: 95,
|
||||
testing: 60,
|
||||
deployment: 92,
|
||||
documentation: 85,
|
||||
security: 82,
|
||||
ux: 80,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'picture',
|
||||
displayName: 'Picture',
|
||||
score: 81,
|
||||
status: 'production',
|
||||
categories: {
|
||||
backend: 90,
|
||||
frontend: 80,
|
||||
database: 92,
|
||||
testing: 55,
|
||||
deployment: 75,
|
||||
documentation: 78,
|
||||
security: 80,
|
||||
ux: 78,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'music',
|
||||
displayName: 'Music',
|
||||
score: 80,
|
||||
status: 'beta',
|
||||
categories: {
|
||||
backend: 90,
|
||||
frontend: 78,
|
||||
database: 90,
|
||||
testing: 65,
|
||||
deployment: 85,
|
||||
documentation: 80,
|
||||
security: 78,
|
||||
ux: 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'food',
|
||||
displayName: 'Food',
|
||||
score: 63,
|
||||
status: 'beta',
|
||||
categories: {
|
||||
backend: 78,
|
||||
frontend: 62,
|
||||
database: 80,
|
||||
testing: 58,
|
||||
deployment: 40,
|
||||
documentation: 85,
|
||||
security: 68,
|
||||
ux: 55,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'photos',
|
||||
displayName: 'Photos',
|
||||
score: 62,
|
||||
status: 'beta',
|
||||
categories: {
|
||||
backend: 82,
|
||||
frontend: 65,
|
||||
database: 72,
|
||||
testing: 0,
|
||||
deployment: 85,
|
||||
documentation: 78,
|
||||
security: 65,
|
||||
ux: 55,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'quotes',
|
||||
displayName: 'Quotes',
|
||||
score: 62,
|
||||
status: 'beta',
|
||||
categories: {
|
||||
backend: 72,
|
||||
frontend: 78,
|
||||
database: 75,
|
||||
testing: 0,
|
||||
deployment: 92,
|
||||
documentation: 20,
|
||||
security: 70,
|
||||
ux: 75,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
displayName: 'Context',
|
||||
score: 60,
|
||||
status: 'beta',
|
||||
categories: {
|
||||
backend: 75,
|
||||
frontend: 75,
|
||||
database: 82,
|
||||
testing: 55,
|
||||
deployment: 25,
|
||||
documentation: 85,
|
||||
security: 68,
|
||||
ux: 65,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clock',
|
||||
displayName: 'Clock',
|
||||
score: 58,
|
||||
status: 'beta',
|
||||
categories: {
|
||||
backend: 75,
|
||||
frontend: 70,
|
||||
database: 72,
|
||||
testing: 0,
|
||||
deployment: 88,
|
||||
documentation: 10,
|
||||
security: 60,
|
||||
ux: 55,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'skilltree',
|
||||
displayName: 'SkillTree',
|
||||
score: 58,
|
||||
status: 'beta',
|
||||
categories: {
|
||||
backend: 65,
|
||||
frontend: 68,
|
||||
database: 72,
|
||||
testing: 28,
|
||||
deployment: 55,
|
||||
documentation: 62,
|
||||
security: 65,
|
||||
ux: 72,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'plants',
|
||||
displayName: 'Plants',
|
||||
score: 50,
|
||||
status: 'alpha',
|
||||
categories: {
|
||||
backend: 68,
|
||||
frontend: 58,
|
||||
database: 70,
|
||||
testing: 0,
|
||||
deployment: 45,
|
||||
documentation: 62,
|
||||
security: 55,
|
||||
ux: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cards',
|
||||
displayName: 'Cards',
|
||||
score: 48,
|
||||
status: 'alpha',
|
||||
categories: {
|
||||
backend: 50,
|
||||
frontend: 65,
|
||||
database: 30,
|
||||
testing: 18,
|
||||
deployment: 80,
|
||||
documentation: 25,
|
||||
security: 55,
|
||||
ux: 68,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'questions',
|
||||
displayName: 'Questions',
|
||||
score: 48,
|
||||
status: 'alpha',
|
||||
categories: {
|
||||
backend: 88,
|
||||
frontend: 62,
|
||||
database: 78,
|
||||
testing: 0,
|
||||
deployment: 10,
|
||||
documentation: 72,
|
||||
security: 55,
|
||||
ux: 55,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'traces',
|
||||
displayName: 'Traces',
|
||||
score: 35,
|
||||
status: 'alpha',
|
||||
categories: {
|
||||
backend: 72,
|
||||
frontend: 10,
|
||||
database: 70,
|
||||
testing: 0,
|
||||
deployment: 8,
|
||||
documentation: 45,
|
||||
security: 55,
|
||||
ux: 35,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function createMockEcosystem(): AppData[] {
|
||||
return APP_DEFINITIONS.map((def) => {
|
||||
const pos = APP_POSITIONS[def.id] || { x: 800, y: 500, lakeId: 'auth' };
|
||||
const trend = def.previousScore ? def.score - def.previousScore : 0;
|
||||
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,
|
||||
lakeId: pos.lakeId,
|
||||
position: { x: pos.x, y: pos.y },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
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';
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<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}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { mountains } from '../data/colors';
|
||||
import { SCENE, MOUNTAIN_PATHS } from '../data/layout';
|
||||
import Sky from '../atmosphere/Sky.svelte';
|
||||
|
||||
let { hour = new Date().getHours() + new Date().getMinutes() / 60 }: { hour?: number } = $props();
|
||||
</script>
|
||||
|
||||
<defs>
|
||||
<!-- Subtle haze filter -->
|
||||
<filter id="mountain-haze">
|
||||
<feGaussianBlur stdDeviation="1.5" />
|
||||
</filter>
|
||||
<filter id="mountain-haze-light">
|
||||
<feGaussianBlur stdDeviation="0.8" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Dynamic sky -->
|
||||
<Sky {hour} />
|
||||
|
||||
<!-- 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>
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,359 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { AppData } from '../data/types';
|
||||
import RadarChart from './RadarChart.svelte';
|
||||
|
||||
let {
|
||||
app,
|
||||
onclose,
|
||||
}: {
|
||||
app: AppData | null;
|
||||
onclose: () => 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 appUrls: Record<string, string> = {
|
||||
calendar: 'https://calendar.mana.how',
|
||||
todo: 'https://todo.mana.how',
|
||||
contacts: 'https://contacts.mana.how',
|
||||
chat: 'https://chat.mana.how',
|
||||
storage: 'https://storage.mana.how',
|
||||
picture: 'https://picture.mana.how',
|
||||
presi: 'https://presi.mana.how',
|
||||
music: 'https://music.mana.how',
|
||||
clock: 'https://clock.mana.how',
|
||||
food: 'https://food.mana.how',
|
||||
photos: 'https://photos.mana.how',
|
||||
quotes: 'https://quotes.mana.how',
|
||||
mana: 'https://mana.how',
|
||||
cards: 'https://cards.mana.how',
|
||||
plants: 'https://plants.mana.how',
|
||||
playground: 'https://playground.mana.how',
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if app}
|
||||
<!-- Backdrop -->
|
||||
<button
|
||||
type="button"
|
||||
class="panel-backdrop"
|
||||
onclick={onclose}
|
||||
tabindex="-1"
|
||||
aria-label="Close panel"
|
||||
></button>
|
||||
|
||||
<div class="panel" class:open={!!app}>
|
||||
<!-- Header -->
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">{app.displayName}</h3>
|
||||
<span class="panel-status" style="color: {statusColors[app.status]}">
|
||||
{statusLabels[app.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-score" style="color: {scoreColor(app.score)}">
|
||||
{app.score}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score bar -->
|
||||
<div class="score-bar-container">
|
||||
<div class="score-bar" style="width: {app.score}%; background: {scoreColor(app.score)}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Radar Chart -->
|
||||
<div class="panel-section">
|
||||
<h4 class="section-label">Kategorien</h4>
|
||||
<div class="radar-container">
|
||||
<RadarChart categories={app.categories} size={200} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category scores list -->
|
||||
<div class="panel-section">
|
||||
<div class="category-list">
|
||||
{#each Object.entries(app.categories) as [key, value]}
|
||||
<div class="category-row">
|
||||
<span class="category-label">{key}</span>
|
||||
<div class="category-bar-wrap">
|
||||
<div
|
||||
class="category-bar-fill"
|
||||
style="width: {value}%; background: {scoreColor(value)}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="category-value">{value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-links">
|
||||
{#if appUrls[app.id]}
|
||||
<a href={appUrls[app.id]} target="_blank" rel="noopener" class="panel-link">
|
||||
App offnen
|
||||
</a>
|
||||
{/if}
|
||||
<a href="/manascore/{app.id}" class="panel-link secondary"> ManaScore Details </a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button type="button" class="panel-close" onclick={onclose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 45;
|
||||
width: 340px;
|
||||
max-width: 90vw;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
border-left: 1px solid rgba(148, 163, 184, 0.1);
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.panel-score {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.score-bar-container {
|
||||
height: 4px;
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.radar-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
width: 90px;
|
||||
text-transform: capitalize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-bar-wrap {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.category-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.category-value {
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
width: 24px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.panel-link:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.panel-link.secondary {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.panel-link.secondary:hover {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #94a3b8;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.panel-close:hover {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
<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 Quotes, Music, Clock, Food.',
|
||||
'db-right': 'PostgreSQL-Datenbanken fur Photos, SkillTree, Context, Plants.',
|
||||
};
|
||||
|
||||
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>
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { AppData } from '../data/types';
|
||||
|
||||
let { app, x, y }: { app: AppData; x: number; y: number } = $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 healthIcons: Record<string, string> = {
|
||||
up: '●',
|
||||
degraded: '◐',
|
||||
down: '○',
|
||||
unknown: '?',
|
||||
};
|
||||
|
||||
const healthColors: Record<string, string> = {
|
||||
up: '#34d399',
|
||||
degraded: '#fbbf24',
|
||||
down: '#ef4444',
|
||||
unknown: '#94a3b8',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="plant-tooltip" style="left: {x}px; top: {y}px;">
|
||||
<div class="tooltip-header">
|
||||
<span class="tooltip-name">{app.displayName}</span>
|
||||
<span class="tooltip-score">{app.score}</span>
|
||||
</div>
|
||||
<div class="tooltip-meta">
|
||||
<span class="tooltip-status" style="color: {statusColors[app.status]}">
|
||||
{statusLabels[app.status]}
|
||||
</span>
|
||||
<span class="tooltip-health" style="color: {healthColors[app.health]}">
|
||||
{healthIcons[app.health]}
|
||||
</span>
|
||||
{#if app.trend !== 0}
|
||||
<span class="tooltip-trend" class:positive={app.trend > 0} class:negative={app.trend < 0}>
|
||||
{app.trend > 0 ? '+' : ''}{app.trend}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tooltip-categories">
|
||||
{#each Object.entries(app.categories) as [key, value]}
|
||||
<div class="tooltip-cat-row">
|
||||
<span class="tooltip-cat-label">{key}</span>
|
||||
<div class="tooltip-cat-bar">
|
||||
<div class="tooltip-cat-fill" style="width: {value}%"></div>
|
||||
</div>
|
||||
<span class="tooltip-cat-value">{value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.plant-tooltip {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -100%) translateY(-12px);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
min-width: 180px;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.tooltip-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.tooltip-score {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tooltip-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tooltip-status {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tooltip-health {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.tooltip-trend {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tooltip-trend.positive {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.tooltip-trend.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tooltip-categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tooltip-cat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tooltip-cat-label {
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
width: 72px;
|
||||
text-transform: capitalize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tooltip-cat-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltip-cat-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.tooltip-cat-value {
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
width: 18px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { CategoryScores } from '../data/types';
|
||||
|
||||
let {
|
||||
categories,
|
||||
size = 140,
|
||||
}: {
|
||||
categories: CategoryScores;
|
||||
size?: number;
|
||||
} = $props();
|
||||
|
||||
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;
|
||||
|
||||
let center = $derived(size / 2);
|
||||
let maxR = $derived(size / 2 - 24);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Grid rings
|
||||
let rings = $derived(
|
||||
[0.25, 0.5, 0.75, 1].map((pct) => {
|
||||
const r = maxR * pct;
|
||||
const points = labels.map((_, i) => {
|
||||
const angle = (360 / labels.length) * i;
|
||||
return polarToXY(angle, r);
|
||||
});
|
||||
return points.map((p) => `${p.x},${p.y}`).join(' ');
|
||||
})
|
||||
);
|
||||
|
||||
// Data polygon
|
||||
let dataPoints = $derived(
|
||||
labels.map((l, i) => {
|
||||
const angle = (360 / labels.length) * i;
|
||||
const value = categories[l.key] / 100;
|
||||
return polarToXY(angle, maxR * value);
|
||||
})
|
||||
);
|
||||
|
||||
let dataPolygon = $derived(dataPoints.map((p) => `${p.x},${p.y}`).join(' '));
|
||||
|
||||
// Axis lines
|
||||
let axes = $derived(
|
||||
labels.map((_, i) => {
|
||||
const angle = (360 / labels.length) * i;
|
||||
return polarToXY(angle, maxR);
|
||||
})
|
||||
);
|
||||
|
||||
// Label positions (slightly outside)
|
||||
let labelPositions = $derived(
|
||||
labels.map((l, i) => {
|
||||
const angle = (360 / labels.length) * i;
|
||||
const pos = polarToXY(angle, maxR + 16);
|
||||
return { ...pos, label: l.label, value: categories[l.key] };
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 {size} {size}">
|
||||
<!-- Grid rings -->
|
||||
{#each rings as ring}
|
||||
<polygon points={ring} fill="none" stroke="rgba(148, 163, 184, 0.15)" stroke-width="0.5" />
|
||||
{/each}
|
||||
|
||||
<!-- Axis lines -->
|
||||
{#each axes as axis}
|
||||
<line
|
||||
x1={center}
|
||||
y1={center}
|
||||
x2={axis.x}
|
||||
y2={axis.y}
|
||||
stroke="rgba(148, 163, 184, 0.1)"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Data polygon -->
|
||||
<polygon
|
||||
points={dataPolygon}
|
||||
fill="rgba(59, 130, 246, 0.2)"
|
||||
stroke="rgba(59, 130, 246, 0.8)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
|
||||
<!-- Data points -->
|
||||
{#each dataPoints as point}
|
||||
<circle cx={point.x} cy={point.y} r="2.5" fill="#3b82f6" />
|
||||
{/each}
|
||||
|
||||
<!-- Labels -->
|
||||
{#each labelPositions as lp}
|
||||
<text
|
||||
x={lp.x}
|
||||
y={lp.y}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
font-size="8"
|
||||
font-family="system-ui, sans-serif"
|
||||
fill="#94a3b8"
|
||||
>
|
||||
{lp.label}
|
||||
</text>
|
||||
{/each}
|
||||
</svg>
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
<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) -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- This is an SVG hover area for tooltip display, not an interactive control -->
|
||||
<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>
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -22,6 +22,9 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
|
|||
'albumItems', // TODO: audit
|
||||
'albums', // TODO: audit
|
||||
'articleTags', // FK-only junction into globalTags (articleId, tagId). Tag names live in globalTags.
|
||||
'articleImportJobs', // Bulk-import job header (counters, status, lease metadata). Pure operational state, no user-typed content. See docs/plans/articles-bulk-import.md.
|
||||
'articleImportItems', // One row per URL in a bulk job. URL is plaintext by necessity — server-worker reads it without master-key access (same rationale as articles.originalUrl).
|
||||
'articleExtractPickup', // Short-lived server-write inbox; the client picks up the extracted payload, encrypts it into the articles table, deletes the row. Plaintext by necessity (server has no master key); empty in steady state.
|
||||
'automations', // TODO: audit
|
||||
'boardViews', // TODO: audit
|
||||
'budgets', // TODO: audit
|
||||
|
|
|
|||
|
|
@ -168,11 +168,6 @@ db.version(1).stores({
|
|||
timeWorldClocks: 'id, sortOrder, timezone',
|
||||
entryTags: 'id, entryId, tagId, [entryId+tagId]',
|
||||
|
||||
// ─── Context (appId: 'context') ───
|
||||
contextSpaces: 'id, pinned, prefix',
|
||||
documents: 'id, spaceId, type, pinned, title, [spaceId+type], updatedAt',
|
||||
documentTags: 'id, documentId, tagId, [documentId+tagId]',
|
||||
|
||||
// ─── Questions (appId: 'questions') ───
|
||||
qCollections: 'id, sortOrder, isDefault',
|
||||
questions: 'id, collectionId, status, priority, [collectionId+status]',
|
||||
|
|
@ -673,11 +668,10 @@ db.version(30).stores({
|
|||
_serverIterationExecutions: 'iterationId, missionId, executedAt',
|
||||
});
|
||||
|
||||
// v31 — Rename the legacy `spaceId` field to `contextSpaceId` on four
|
||||
// v31 — Rename the legacy `spaceId` field to `contextSpaceId` on three
|
||||
// tables that owned the term before the multi-tenancy Spaces foundation
|
||||
// arrived (v28):
|
||||
// - conversations (chat module's reference to a context-space folder)
|
||||
// - documents (context module's parent context-space)
|
||||
// - spaceMembers (memoro's members of a context-space)
|
||||
// - memoSpaces (memoro's memo ↔ context-space join)
|
||||
//
|
||||
|
|
@ -696,12 +690,11 @@ db.version(30).stores({
|
|||
db.version(31)
|
||||
.stores({
|
||||
conversations: 'id, isArchived, isPinned, contextSpaceId, templateId, updatedAt',
|
||||
documents: 'id, contextSpaceId, type, pinned, title, [contextSpaceId+type], updatedAt',
|
||||
spaceMembers: 'id, contextSpaceId, userId',
|
||||
memoSpaces: 'id, memoId, contextSpaceId',
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
const tables = ['conversations', 'documents', 'spaceMembers', 'memoSpaces'] as const;
|
||||
const tables = ['conversations', 'spaceMembers', 'memoSpaces'] as const;
|
||||
for (const name of tables) {
|
||||
await tx
|
||||
.table(name)
|
||||
|
|
@ -1404,6 +1397,39 @@ db.version(55).upgrade(async (tx) => {
|
|||
}
|
||||
});
|
||||
|
||||
// v56 — Articles Bulk-Import (docs/plans/articles-bulk-import.md Phase 1).
|
||||
// Three new tables that ride the standard sync pipeline under the
|
||||
// articles appId:
|
||||
//
|
||||
// articleImportJobs — one row per bulk-import the user kicked off.
|
||||
// Indexed on `status` for the JobsList tab filter, `[spaceId+status]`
|
||||
// for the per-Space active-job query the worker projection runs,
|
||||
// and `_updatedAtIndex` for chronological sort. Lease columns are
|
||||
// scanned via JS filter — only ~tens of running jobs per user.
|
||||
// articleImportItems — one row per URL inside a job. `[jobId+state]`
|
||||
// is the hot index: the JobDetailView range-scans pending+running
|
||||
// items per job, and the worker pulls "items in state=pending for
|
||||
// these jobIds". `idx` is plain so the in-list display order
|
||||
// scrolls without an extra sort key. `state` standalone is used by
|
||||
// the cross-job retry-failed query.
|
||||
// articleExtractPickup — short-lived inbox between server-worker
|
||||
// write and client-pickup-consumer read. `itemId` indexed so the
|
||||
// consumer can join back to the owning item row. Empty in steady
|
||||
// state; server-side GC caps it at 24 h.
|
||||
//
|
||||
// All three are plaintext (encryption registry: plaintext-allowlist).
|
||||
// `articleImportItems.url` and `articleExtractPickup.payload` ARE
|
||||
// user-typed-adjacent content but stay plaintext by necessity — the
|
||||
// server-side worker reads them without master-key access. Same
|
||||
// rationale as articles.originalUrl. Once the article is persisted,
|
||||
// the encrypted copy lives in `articles` and the item carries only an
|
||||
// articleId pointer.
|
||||
db.version(56).stores({
|
||||
articleImportJobs: 'id, status, [spaceId+status], _updatedAtIndex',
|
||||
articleImportItems: 'id, jobId, [jobId+state], state, idx',
|
||||
articleExtractPickup: 'id, itemId, _updatedAtIndex',
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -7,8 +7,19 @@
|
|||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalArticle, LocalHighlight, LocalArticleTag } from './types';
|
||||
import type {
|
||||
LocalArticle,
|
||||
LocalArticleExtractPickup,
|
||||
LocalArticleImportItem,
|
||||
LocalArticleImportJob,
|
||||
LocalArticleTag,
|
||||
LocalHighlight,
|
||||
} from './types';
|
||||
|
||||
export const articleTable = db.table<LocalArticle>('articles');
|
||||
export const articleHighlightTable = db.table<LocalHighlight>('articleHighlights');
|
||||
export const articleTagTable = db.table<LocalArticleTag>('articleTags');
|
||||
export const articleImportJobTable = db.table<LocalArticleImportJob>('articleImportJobs');
|
||||
export const articleImportItemTable = db.table<LocalArticleImportItem>('articleImportItems');
|
||||
export const articleExtractPickupTable =
|
||||
db.table<LocalArticleExtractPickup>('articleExtractPickup');
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
/**
|
||||
* Articles module — saved web articles + highlights + tag links.
|
||||
* Articles module — saved web articles + highlights + tag links + bulk-
|
||||
* import jobs.
|
||||
*
|
||||
* `articleTags` is a pure junction into globalTags (the core `tags`
|
||||
* appId). The junction itself syncs under `articles` appId with its
|
||||
* owning rows, the same pattern every other tagged module uses
|
||||
* (noteTags, eventTags, contactTags, placeTags, …).
|
||||
*
|
||||
* `articleImportJobs` + `articleImportItems` + `articleExtractPickup`
|
||||
* implement the durable bulk-import pipeline (docs/plans/articles-bulk-
|
||||
* import.md). All three sync under the articles appId so multi-device
|
||||
* progress and server-worker state-transitions ride the standard
|
||||
* sync_changes channel.
|
||||
*/
|
||||
export const articlesModuleConfig: ModuleConfig = {
|
||||
appId: 'articles',
|
||||
|
|
@ -14,5 +21,8 @@ export const articlesModuleConfig: ModuleConfig = {
|
|||
{ name: 'articles' },
|
||||
{ name: 'articleHighlights', syncName: 'highlights' },
|
||||
{ name: 'articleTags' },
|
||||
{ name: 'articleImportJobs', syncName: 'importJobs' },
|
||||
{ name: 'articleImportItems', syncName: 'importItems' },
|
||||
{ name: 'articleExtractPickup', syncName: 'extractPickup' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Articles module — Pocket-style read-it-later.
|
||||
*
|
||||
* Three Dexie tables:
|
||||
* Six Dexie tables:
|
||||
*
|
||||
* articles — saved URLs + extracted Readability content
|
||||
* (encrypted: title, excerpt, content, htmlContent,
|
||||
|
|
@ -12,6 +12,23 @@
|
|||
* articleTags — pure junction into globalTags. No user-typed
|
||||
* content lives here — tag names/colors are in
|
||||
* the global tag system (appId: 'tags').
|
||||
*
|
||||
* articleImportJobs — Bulk-Import job header. Plaintext: counters,
|
||||
* status, lease metadata. See
|
||||
* docs/plans/articles-bulk-import.md.
|
||||
* articleImportItems — One row per URL in a bulk job. URL stays
|
||||
* plaintext (server-worker reads it without
|
||||
* master-key access — same rationale as
|
||||
* articles.originalUrl). State machine:
|
||||
* pending → extracting → extracted →
|
||||
* (saved | duplicate | consent-wall | error |
|
||||
* cancelled).
|
||||
* articleExtractPickup — Server-write inbox: the worker drops the
|
||||
* extracted payload here, the client picks it
|
||||
* up, runs encryptRecord + articleTable.add,
|
||||
* then deletes the row. Plaintext by necessity
|
||||
* (server has no master key); empty in steady
|
||||
* state.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
|
@ -115,3 +132,124 @@ export interface Highlight {
|
|||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Bulk Import (docs/plans/articles-bulk-import.md) ─────
|
||||
|
||||
/**
|
||||
* Job status — drives the index list filter and the JobDetailView's
|
||||
* action bar. `running` is the only state where the worker actively
|
||||
* pulls items; `paused` lets the user stop progress without losing the
|
||||
* remaining queue, `cancelled` is a hard stop with all pending items
|
||||
* flipped to terminal `cancelled`.
|
||||
*/
|
||||
export type ArticleImportJobStatus = 'queued' | 'running' | 'paused' | 'done' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Item state machine. Server-side transitions: pending → extracting →
|
||||
* extracted (worker has dropped a pickup row). Client-side transitions:
|
||||
* extracted → saved | duplicate | consent-wall (pickup-consumer
|
||||
* applied the result). Both sides may transition to error (worker after
|
||||
* 3 retries, client if encryptRecord/add fails). cancelled is terminal
|
||||
* and only set when the parent job is cancelled before the item ran.
|
||||
*/
|
||||
export type ArticleImportItemState =
|
||||
| 'pending'
|
||||
| 'extracting'
|
||||
| 'extracted'
|
||||
| 'saved'
|
||||
| 'duplicate'
|
||||
| 'consent-wall'
|
||||
| 'error'
|
||||
| 'cancelled';
|
||||
|
||||
export interface LocalArticleImportJob extends BaseRecord {
|
||||
totalUrls: number;
|
||||
status: ArticleImportJobStatus;
|
||||
/** Worker lease — workerId of the apps/api instance that claimed the job. */
|
||||
leasedBy: string | null;
|
||||
/** ISO timestamp; lease is dead once `leasedUntil < now`. */
|
||||
leasedUntil: string | null;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
/** Counters mirror the per-item terminal states. Cache for fast list
|
||||
* rendering — truth lives in the item rows. Worker stamps these on
|
||||
* each transition. */
|
||||
savedCount: number;
|
||||
duplicateCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
export interface LocalArticleImportItem extends BaseRecord {
|
||||
jobId: string;
|
||||
/** Original position in the user-provided URL list. Drives display order. */
|
||||
idx: number;
|
||||
/** Plaintext — server worker reads it without master-key access. Same
|
||||
* rationale as articles.originalUrl / newsArticles.originalUrl. */
|
||||
url: string;
|
||||
state: ArticleImportItemState;
|
||||
/** Pointer into `articles` table once the article is persisted. */
|
||||
articleId: string | null;
|
||||
warning: 'probable_consent_wall' | null;
|
||||
/** Plaintext technical error message ("502 Bad Gateway", "timeout"). */
|
||||
error: string | null;
|
||||
attempts: number;
|
||||
lastAttemptAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server → client handoff. Lives only between worker-write and
|
||||
* pickup-consumer-read. Empty in steady state.
|
||||
*/
|
||||
export interface LocalArticleExtractPickup extends BaseRecord {
|
||||
itemId: string;
|
||||
/** The server's ExtractedArticle JSON, plaintext. Mirrors the shape
|
||||
* in articles/api.ts but lives here as a structural type so the
|
||||
* database layer doesn't import the API client. */
|
||||
payload: {
|
||||
originalUrl: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
author: string | null;
|
||||
siteName: string | null;
|
||||
wordCount: number;
|
||||
readingTimeMinutes: number;
|
||||
warning?: 'probable_consent_wall';
|
||||
};
|
||||
}
|
||||
|
||||
// Public DTOs used by views (livequery converters strip the BaseRecord
|
||||
// internals + map state to display-friendly shapes).
|
||||
|
||||
export interface ArticleImportJob {
|
||||
id: string;
|
||||
totalUrls: number;
|
||||
status: ArticleImportJobStatus;
|
||||
leasedBy: string | null;
|
||||
leasedUntil: string | null;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
savedCount: number;
|
||||
duplicateCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ArticleImportItem {
|
||||
id: string;
|
||||
jobId: string;
|
||||
idx: number;
|
||||
url: string;
|
||||
state: ArticleImportItemState;
|
||||
articleId: string | null;
|
||||
warning: 'probable_consent_wall' | null;
|
||||
error: string | null;
|
||||
attempts: number;
|
||||
lastAttemptAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,351 +0,0 @@
|
|||
<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 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, RIVERS } from '$lib/components/observatory/data/layout';
|
||||
import type { AppData } from '$lib/components/observatory/data/types';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
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);
|
||||
|
||||
// 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 },
|
||||
{ id: 'rivers', label: 'Flusse', count: rivers.length },
|
||||
{ id: 'leaderboard', label: 'Rangliste' },
|
||||
{ id: 'compare', label: 'Vergleich' },
|
||||
{ id: 'trends', label: 'Trends' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<RoutePage appId="observatory">
|
||||
<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>
|
||||
{: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>
|
||||
|
||||
<!-- Detail panel (shared across tabs) -->
|
||||
<DetailPanel app={selectedApp} onclose={() => (selectedApp = null)} />
|
||||
</RoutePage>
|
||||
|
||||
<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: hsl(var(--color-foreground));
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.gallery-subtitle {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
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: hsl(var(--color-muted-foreground));
|
||||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue