feat(observatory): add tooltips, detail panel, radar chart, and atmosphere

Phase 5 - Interactivity:
- Hover tooltip with score, status, category bars on every plant
- Click opens slide-in detail panel with 8-axis radar chart
- Panel shows all category scores, app links, ManaScore link
- Escape key and backdrop click close the panel
- Pan vs click distinction (no panel on drag)

Phase 6 - Atmosphere:
- Dynamic sky that follows real time of day (sunrise/noon/sunset/night)
- Stars with twinkling animation at night
- Sun glow during golden hour
- Birds flying across the scene (daytime)
- Dragonflies circling over lakes
- Butterflies near plants
- Fireflies at night (glowing, drifting)
- Time updates every minute

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 22:21:04 +01:00
parent 5286404129
commit 23dac3272e
8 changed files with 906 additions and 50 deletions

View file

@ -1,21 +1,36 @@
<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';
let { onSelectApp }: { onSelectApp?: (appId: string) => void } = $props();
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 selectedApp = $state<string | null>(null);
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 });
@ -25,30 +40,20 @@
const rect = svgEl?.getBoundingClientRect();
if (!rect) return;
// Mouse position as fraction of SVG
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
// Point in viewBox coordinates under mouse
const px = viewBox.x + mx * viewBox.w;
const py = viewBox.y + my * viewBox.h;
// Scale viewBox dimensions
const nw = Math.max(400, Math.min(SCENE.width * 2, viewBox.w * scaleFactor));
const nh = Math.max(225, Math.min(SCENE.height * 2, viewBox.h * scaleFactor));
// Adjust position to keep point under mouse
viewBox = {
x: px - mx * nw,
y: py - my * nh,
w: nw,
h: nh,
};
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);
@ -59,20 +64,34 @@
const rect = svgEl.getBoundingClientRect();
const dx = ((e.clientX - panStart.x) / rect.width) * viewBox.w;
const dy = ((e.clientY - panStart.y) / rect.height) * viewBox.h;
viewBox = {
...viewBox,
x: panViewBoxStart.x - dx,
y: panViewBoxStart.y - dy,
};
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(appId: string) {
selectedApp = appId;
onSelectApp?.(appId);
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() {
@ -138,7 +157,7 @@
onpointerleave={handlePointerUp}
>
<!-- Layer 1: Sky + Mountains -->
<Background />
<Background {hour} />
<!-- Layer 2: Terrain (meadows, shores) -->
<Terrain />
@ -153,9 +172,26 @@
<WaterBody {lake} />
{/each}
<!-- Layer 5: Plants (apps) sorted by y-position for depth -->
<!-- Layer 5: Ambient creatures -->
<Ambient {hour} />
<!-- Layer 6: Plants (apps) sorted by y-position for depth -->
{#each apps.toSorted((a, b) => a.position.y - b.position.y) as app (app.id)}
<PlantFactory {app} onclick={() => handleAppClick(app.id)} />
<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} />

View file

@ -0,0 +1,126 @@
<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}

View file

@ -0,0 +1,83 @@
<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}

View file

@ -1,15 +1,12 @@
<script lang="ts">
import { sky, mountains } from '../data/colors';
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>
<!-- Sky gradient -->
<defs>
<linearGradient id="sky-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={sky.dayTop} />
<stop offset="100%" stop-color={sky.dayBottom} />
</linearGradient>
<!-- Subtle haze filter -->
<filter id="mountain-haze">
<feGaussianBlur stdDeviation="1.5" />
@ -19,8 +16,8 @@
</filter>
</defs>
<!-- Sky fill -->
<rect x="0" y="0" width={SCENE.width} height={SCENE.height} fill="url(#sky-gradient)" />
<!-- Dynamic sky -->
<Sky {hour} />
<!-- Clouds (subtle, decorative) -->
<g opacity="0.35">

View file

@ -0,0 +1,332 @@
<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',
mukke: 'https://mukke.mana.how',
clock: 'https://clock.mana.how',
nutriphi: 'https://nutriphi.mana.how',
photos: 'https://photos.mana.how',
zitare: 'https://zitare.mana.how',
manacore: 'https://mana.how',
manadeck: 'https://manadeck.mana.how',
planta: 'https://planta.mana.how',
matrix: 'https://element.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">
&times;
</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>

View file

@ -0,0 +1,176 @@
<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>

View file

@ -0,0 +1,120 @@
<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>

View file

@ -1,23 +1,9 @@
<script lang="ts">
import SeenplatteScene from '$lib/components/observatory/SeenplatteScene.svelte';
let selectedApp = $state<string | null>(null);
function handleSelectApp(appId: string) {
selectedApp = appId;
}
</script>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div style="height: calc(100vh - 12rem); min-height: 500px; width: 100%;">
<SeenplatteScene onSelectApp={handleSelectApp} />
<SeenplatteScene />
</div>
{#if selectedApp}
<div class="rounded-xl bg-white/80 p-4 shadow-sm backdrop-blur-sm dark:bg-gray-800/80">
<p class="text-sm text-gray-600 dark:text-gray-300">
Selected: <strong>{selectedApp}</strong> — Detail panel coming in Phase 5
</p>
</div>
{/if}
</div>