mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:41:08 +02:00
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:
parent
5286404129
commit
23dac3272e
8 changed files with 906 additions and 50 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
×
|
||||
</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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue