mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 02:46:42 +02:00
feat: major update with network graphs, themes, todo extensions, and more
## New Features ### Network Graph Visualization (Contacts, Calendar, Todo) - D3.js force simulation for physics-based layout - Zoom & pan with mouse/touchpad - Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus - Filtering by tags, company/location/project, connection strength - Shared components in @manacore/shared-ui ### Central Tags API (mana-core-auth) - CRUD endpoints for tags - Schema: tags table with userId, name, color, app - Shared tag components in @manacore/shared-ui ### Custom Themes System - Theme editor with live preview and color picker - Community theme gallery - Theme sharing (public, unlisted, private) - Backend API in mana-core-auth ### Todo App Extensions - Glass-pill design for task input and items - Settings page with 20+ preferences - Task edit modal with inline editing - Statistics page with visualizations - PWA support with offline capabilities - Multiple kanban boards ### Contacts App Features - Duplicate detection - Photo upload - Batch operations - Enhanced favorites page with multiple view modes - Alphabet view improvements - Search modal ### Help System - @manacore/shared-help-content - @manacore/shared-help-ui - @manacore/shared-help-types ### Other Features - Themes page for all apps - Referral system frontend - CommandBar (global search) - Skeleton loaders - Settings page improvements ## Bug Fixes - Network graph simulation initialization - Database schema TEXT for user_id columns (Better Auth compatibility) - Various styling fixes ## Documentation - Daily report for 2025-12-10 - CI/CD deployment guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e84371aa94
commit
ee42b6cc76
381 changed files with 39284 additions and 6275 deletions
|
|
@ -3,3 +3,25 @@ export { default as ConfirmationModal } from './ConfirmationModal.svelte';
|
|||
export { default as FormModal } from './FormModal.svelte';
|
||||
export { default as AppSlider } from './AppSlider.svelte';
|
||||
export type { AppItem } from './AppSlider.types';
|
||||
|
||||
// Network Graph
|
||||
export {
|
||||
NetworkGraph,
|
||||
NetworkControls,
|
||||
stringToColor,
|
||||
getInitials,
|
||||
SIMULATION_CONFIG,
|
||||
NODE_CONFIG,
|
||||
LABEL_CONFIG,
|
||||
} from './network';
|
||||
export type {
|
||||
NetworkNode,
|
||||
NetworkLink,
|
||||
NetworkTag,
|
||||
NetworkTransform,
|
||||
NetworkGraphProps,
|
||||
NetworkControlsProps,
|
||||
NetworkGraphResponse,
|
||||
SimulationNode,
|
||||
SimulationLink,
|
||||
} from './network';
|
||||
|
|
|
|||
604
packages/shared-ui/src/organisms/network/NetworkControls.svelte
Normal file
604
packages/shared-ui/src/organisms/network/NetworkControls.svelte
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
<script lang="ts">
|
||||
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X, Focus, Keyboard } from 'lucide-svelte';
|
||||
import type { NetworkTag } from './network.types';
|
||||
|
||||
interface Props {
|
||||
searchQuery?: string;
|
||||
tags?: NetworkTag[];
|
||||
selectedTagId?: string | null;
|
||||
subtitles?: string[];
|
||||
selectedSubtitle?: string | null;
|
||||
subtitleLabel?: string;
|
||||
nodeCount?: number;
|
||||
linkCount?: number;
|
||||
nodeLabel?: string;
|
||||
linkLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
minStrength?: number;
|
||||
onSearch?: (query: string) => void;
|
||||
onTagFilter?: (tagId: string | null) => void;
|
||||
onSubtitleFilter?: (subtitle: string | null) => void;
|
||||
onStrengthFilter?: (minStrength: number) => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onResetZoom?: () => void;
|
||||
onFocusSelected?: () => void;
|
||||
onClearFilters?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
searchQuery = '',
|
||||
tags = [],
|
||||
selectedTagId = null,
|
||||
subtitles = [],
|
||||
selectedSubtitle = null,
|
||||
subtitleLabel = 'Filter',
|
||||
nodeCount = 0,
|
||||
linkCount = 0,
|
||||
nodeLabel = 'Elemente',
|
||||
linkLabel = 'Verbindungen',
|
||||
searchPlaceholder = 'Suchen...',
|
||||
minStrength = 0,
|
||||
onSearch,
|
||||
onTagFilter,
|
||||
onSubtitleFilter,
|
||||
onStrengthFilter,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetZoom,
|
||||
onFocusSelected,
|
||||
onClearFilters,
|
||||
}: Props = $props();
|
||||
|
||||
let searchInput = $state(searchQuery);
|
||||
let showFilters = $state(false);
|
||||
let showKeyboardHelp = $state(false);
|
||||
let strengthValue = $state(minStrength);
|
||||
let searchInputElement: HTMLInputElement;
|
||||
|
||||
// Sync searchInput with external searchQuery
|
||||
$effect(() => {
|
||||
searchInput = searchQuery;
|
||||
});
|
||||
|
||||
// Sync strength with external minStrength
|
||||
$effect(() => {
|
||||
strengthValue = minStrength;
|
||||
});
|
||||
|
||||
function handleSearchInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
searchInput = target.value;
|
||||
onSearch?.(target.value);
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchInput = '';
|
||||
onSearch?.('');
|
||||
}
|
||||
|
||||
function handleTagChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
onTagFilter?.(target.value || null);
|
||||
}
|
||||
|
||||
function handleSubtitleChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
onSubtitleFilter?.(target.value || null);
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
searchInput = '';
|
||||
strengthValue = 0;
|
||||
onClearFilters?.();
|
||||
}
|
||||
|
||||
function handleStrengthChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
strengthValue = parseInt(target.value, 10);
|
||||
onStrengthFilter?.(strengthValue);
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
searchInputElement?.focus();
|
||||
}
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
searchQuery || selectedTagId || selectedSubtitle || minStrength > 0
|
||||
);
|
||||
|
||||
// Keyboard shortcuts info
|
||||
const keyboardShortcuts = [
|
||||
{ key: '+/-', description: 'Zoom in/out' },
|
||||
{ key: '0', description: 'Reset zoom' },
|
||||
{ key: 'F', description: 'Fokus auf Auswahl' },
|
||||
{ key: '/', description: 'Suche fokussieren' },
|
||||
{ key: 'Esc', description: 'Auswahl aufheben' },
|
||||
];
|
||||
|
||||
// Export focus function for parent
|
||||
export { focusSearch };
|
||||
</script>
|
||||
|
||||
<div class="network-controls">
|
||||
<!-- Search bar -->
|
||||
<div class="search-container">
|
||||
<Search size={18} class="search-icon" />
|
||||
<input
|
||||
bind:this={searchInputElement}
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchInput}
|
||||
oninput={handleSearchInput}
|
||||
class="search-input"
|
||||
/>
|
||||
{#if searchInput}
|
||||
<button onclick={clearSearch} class="clear-btn" aria-label="Suche löschen">
|
||||
<X size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Filter toggle -->
|
||||
{#if tags.length > 0 || subtitles.length > 0}
|
||||
<button
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
class="control-btn"
|
||||
class:active={showFilters || hasActiveFilters}
|
||||
aria-label="Filter anzeigen"
|
||||
title="Filter"
|
||||
>
|
||||
<Filter size={18} />
|
||||
{#if hasActiveFilters}
|
||||
<span class="filter-badge"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Zoom controls -->
|
||||
<div class="zoom-controls">
|
||||
<button onclick={onZoomIn} class="control-btn" aria-label="Vergrößern" title="Vergrößern (+)">
|
||||
<ZoomIn size={18} />
|
||||
</button>
|
||||
<button
|
||||
onclick={onZoomOut}
|
||||
class="control-btn"
|
||||
aria-label="Verkleinern"
|
||||
title="Verkleinern (-)"
|
||||
>
|
||||
<ZoomOut size={18} />
|
||||
</button>
|
||||
<button
|
||||
onclick={onResetZoom}
|
||||
class="control-btn"
|
||||
aria-label="Ansicht zurücksetzen"
|
||||
title="Zurücksetzen (0)"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
<button
|
||||
onclick={onFocusSelected}
|
||||
class="control-btn"
|
||||
aria-label="Auf Auswahl fokussieren"
|
||||
title="Fokus auf Auswahl (F)"
|
||||
>
|
||||
<Focus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard help toggle -->
|
||||
<button
|
||||
onclick={() => (showKeyboardHelp = !showKeyboardHelp)}
|
||||
class="control-btn"
|
||||
class:active={showKeyboardHelp}
|
||||
aria-label="Tastaturkürzel anzeigen"
|
||||
title="Tastaturkürzel"
|
||||
>
|
||||
<Keyboard size={18} />
|
||||
</button>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<span class="stat">
|
||||
{nodeCount}
|
||||
{nodeLabel}
|
||||
</span>
|
||||
<span class="stat-divider">•</span>
|
||||
<span class="stat">
|
||||
{linkCount}
|
||||
{linkLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard shortcuts help -->
|
||||
{#if showKeyboardHelp}
|
||||
<div class="keyboard-help">
|
||||
<div class="keyboard-help-title">Tastaturkürzel</div>
|
||||
<div class="keyboard-shortcuts">
|
||||
{#each keyboardShortcuts as shortcut}
|
||||
<div class="shortcut">
|
||||
<kbd class="shortcut-key">{shortcut.key}</kbd>
|
||||
<span class="shortcut-desc">{shortcut.description}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filter panel -->
|
||||
{#if showFilters}
|
||||
<div class="filter-panel">
|
||||
<div class="filter-row">
|
||||
<!-- Tag filter -->
|
||||
{#if tags.length > 0}
|
||||
<div class="filter-group">
|
||||
<label for="tag-filter" class="filter-label">Tag</label>
|
||||
<select
|
||||
id="tag-filter"
|
||||
onchange={handleTagChange}
|
||||
value={selectedTagId || ''}
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="">Alle Tags</option>
|
||||
{#each tags as tag}
|
||||
<option value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Subtitle filter (e.g., Company, Project) -->
|
||||
{#if subtitles.length > 0}
|
||||
<div class="filter-group">
|
||||
<label for="subtitle-filter" class="filter-label">{subtitleLabel}</label>
|
||||
<select
|
||||
id="subtitle-filter"
|
||||
onchange={handleSubtitleChange}
|
||||
value={selectedSubtitle || ''}
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{#each subtitles as subtitle}
|
||||
<option value={subtitle}>
|
||||
{subtitle}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Strength filter slider -->
|
||||
<div class="filter-group strength-group">
|
||||
<label for="strength-filter" class="filter-label">
|
||||
Min. Stärke: {strengthValue}%
|
||||
</label>
|
||||
<input
|
||||
id="strength-filter"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="10"
|
||||
value={strengthValue}
|
||||
oninput={handleStrengthChange}
|
||||
class="strength-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Clear filters button -->
|
||||
{#if hasActiveFilters}
|
||||
<button onclick={clearAllFilters} class="clear-filters-btn">
|
||||
<X size={14} />
|
||||
Filter löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.network-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--card) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 9999px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.search-container :global(.search-icon) {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 2rem 0.5rem 2.5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
position: relative;
|
||||
padding: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Filter panel */
|
||||
.filter-panel {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--card) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border: 1px solid hsl(var(--destructive) / 0.2);
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
background: hsl(var(--destructive) / 0.15);
|
||||
}
|
||||
|
||||
/* Strength slider */
|
||||
.strength-group {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.strength-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: hsl(var(--muted));
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.strength-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.strength-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.strength-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Keyboard help panel */
|
||||
.keyboard-help {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--card) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.keyboard-help-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: hsl(var(--muted));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.375rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.shortcut-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.network-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats {
|
||||
justify-content: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
671
packages/shared-ui/src/organisms/network/NetworkGraph.svelte
Normal file
671
packages/shared-ui/src/organisms/network/NetworkGraph.svelte
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom';
|
||||
import { select, type Selection } from 'd3-selection';
|
||||
import 'd3-transition'; // Side-effect import for .transition() method
|
||||
import type { SimulationNode, SimulationLink, NetworkTransform } from './network.types';
|
||||
import { stringToColor, getInitials, NODE_CONFIG, LABEL_CONFIG } from './constants';
|
||||
|
||||
interface Props {
|
||||
nodes: SimulationNode[];
|
||||
links: SimulationLink[];
|
||||
selectedNodeId?: string | null;
|
||||
onNodeClick?: (node: SimulationNode) => void;
|
||||
onNodeDoubleClick?: (node: SimulationNode) => void;
|
||||
onBackgroundClick?: () => void;
|
||||
onDragStart?: (node: SimulationNode) => void;
|
||||
onDrag?: (node: SimulationNode, x: number, y: number) => void;
|
||||
onDragEnd?: (node: SimulationNode) => void;
|
||||
onFocusSearch?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
nodes,
|
||||
links,
|
||||
selectedNodeId = null,
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
onBackgroundClick,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
onDragEnd,
|
||||
onFocusSearch,
|
||||
}: Props = $props();
|
||||
|
||||
let svgElement: SVGSVGElement;
|
||||
let containerElement: HTMLDivElement;
|
||||
let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | null = null;
|
||||
let transform = $state<NetworkTransform>({ x: 0, y: 0, k: 1 });
|
||||
let draggedNode: SimulationNode | null = null;
|
||||
|
||||
// Tooltip state
|
||||
let hoveredLink = $state<SimulationLink | null>(null);
|
||||
let tooltipPosition = $state({ x: 0, y: 0 });
|
||||
|
||||
// Setup zoom behavior
|
||||
$effect(() => {
|
||||
if (svgElement) {
|
||||
zoomBehavior = zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
transform = {
|
||||
x: event.transform.x,
|
||||
y: event.transform.y,
|
||||
k: event.transform.k,
|
||||
};
|
||||
});
|
||||
|
||||
select(svgElement).call(zoomBehavior);
|
||||
}
|
||||
});
|
||||
|
||||
function handleNodeClick(node: SimulationNode) {
|
||||
onNodeClick?.(node);
|
||||
}
|
||||
|
||||
function handleBackgroundClick(event: MouseEvent) {
|
||||
if (event.target === svgElement) {
|
||||
onBackgroundClick?.();
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeDoubleClick(node: SimulationNode) {
|
||||
onNodeDoubleClick?.(node);
|
||||
}
|
||||
|
||||
function handleDragStart(event: MouseEvent, node: SimulationNode) {
|
||||
event.stopPropagation();
|
||||
draggedNode = node;
|
||||
onDragStart?.(node);
|
||||
}
|
||||
|
||||
function handleDrag(event: MouseEvent) {
|
||||
if (!draggedNode || !svgElement) return;
|
||||
|
||||
const rect = svgElement.getBoundingClientRect();
|
||||
const x = (event.clientX - rect.left - transform.x) / transform.k;
|
||||
const y = (event.clientY - rect.top - transform.y) / transform.k;
|
||||
|
||||
onDrag?.(draggedNode, x, y);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
if (draggedNode) {
|
||||
onDragEnd?.(draggedNode);
|
||||
draggedNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
if (svgElement && zoomBehavior) {
|
||||
select(svgElement).transition().duration(300).call(zoomBehavior.transform, zoomIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (svgElement && zoomBehavior) {
|
||||
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 1.3);
|
||||
}
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (svgElement && zoomBehavior) {
|
||||
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
function focusOnSelectedNode() {
|
||||
if (!selectedNodeId || !svgElement || !zoomBehavior || !containerElement) return;
|
||||
const node = nodes.find((n) => n.id === selectedNodeId);
|
||||
if (!node || node.x === undefined || node.y === undefined) return;
|
||||
|
||||
const rect = containerElement.getBoundingClientRect();
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
// Calculate transform to center on node
|
||||
const scale = 1.5;
|
||||
const x = centerX - node.x * scale;
|
||||
const y = centerY - node.y * scale;
|
||||
|
||||
select(svgElement)
|
||||
.transition()
|
||||
.duration(500)
|
||||
.call(zoomBehavior.transform, zoomIdentity.translate(x, y).scale(scale));
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
// Ignore if typing in an input
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case '+':
|
||||
case '=':
|
||||
event.preventDefault();
|
||||
zoomIn();
|
||||
break;
|
||||
case '-':
|
||||
case '_':
|
||||
event.preventDefault();
|
||||
zoomOut();
|
||||
break;
|
||||
case '0':
|
||||
event.preventDefault();
|
||||
resetZoom();
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
onBackgroundClick?.();
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
focusOnSelectedNode();
|
||||
}
|
||||
break;
|
||||
case '/':
|
||||
event.preventDefault();
|
||||
onFocusSearch?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Setup keyboard listener
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Get link coordinates
|
||||
function getLinkCoords(link: SimulationLink) {
|
||||
const source = link.source as SimulationNode;
|
||||
const target = link.target as SimulationNode;
|
||||
return {
|
||||
x1: source.x ?? 0,
|
||||
y1: source.y ?? 0,
|
||||
x2: target.x ?? 0,
|
||||
y2: target.y ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Link hover handlers
|
||||
function handleLinkMouseEnter(event: MouseEvent, link: SimulationLink) {
|
||||
hoveredLink = link;
|
||||
updateTooltipPosition(event);
|
||||
}
|
||||
|
||||
function handleLinkMouseMove(event: MouseEvent) {
|
||||
if (hoveredLink) {
|
||||
updateTooltipPosition(event);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLinkMouseLeave() {
|
||||
hoveredLink = null;
|
||||
}
|
||||
|
||||
function updateTooltipPosition(event: MouseEvent) {
|
||||
if (!containerElement) return;
|
||||
const rect = containerElement.getBoundingClientRect();
|
||||
tooltipPosition = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
};
|
||||
}
|
||||
|
||||
// Get node names for tooltip
|
||||
function getLinkNodeNames(link: SimulationLink): { source: string; target: string } {
|
||||
const source =
|
||||
typeof link.source === 'string' ? nodes.find((n) => n.id === link.source) : link.source;
|
||||
const target =
|
||||
typeof link.target === 'string' ? nodes.find((n) => n.id === link.target) : link.target;
|
||||
return {
|
||||
source: source?.name ?? 'Unknown',
|
||||
target: target?.name ?? 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if a node is connected to selected node
|
||||
function isConnectedToSelected(nodeId: string): boolean {
|
||||
if (!selectedNodeId) return false;
|
||||
if (nodeId === selectedNodeId) return true;
|
||||
|
||||
return links.some((link) => {
|
||||
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
return (
|
||||
(sourceId === selectedNodeId && targetId === nodeId) ||
|
||||
(targetId === selectedNodeId && sourceId === nodeId)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Export functions for parent component
|
||||
export { resetZoom, zoomIn, zoomOut, focusOnSelectedNode };
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerElement}
|
||||
class="network-graph-container"
|
||||
onmousemove={handleDrag}
|
||||
onmouseup={handleDragEnd}
|
||||
onmouseleave={handleDragEnd}
|
||||
role="application"
|
||||
aria-label="Network Graph"
|
||||
>
|
||||
<svg
|
||||
bind:this={svgElement}
|
||||
class="network-graph-svg"
|
||||
style="width: 100%; height: 100%;"
|
||||
onclick={handleBackgroundClick}
|
||||
>
|
||||
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
|
||||
<!-- Links -->
|
||||
<g class="links">
|
||||
{#each links as link}
|
||||
{@const coords = getLinkCoords(link)}
|
||||
{@const sourceId = typeof link.source === 'string' ? link.source : link.source.id}
|
||||
{@const targetId = typeof link.target === 'string' ? link.target : link.target.id}
|
||||
{@const isHighlighted =
|
||||
selectedNodeId && (sourceId === selectedNodeId || targetId === selectedNodeId)}
|
||||
<!-- Invisible wider line for easier hover -->
|
||||
<line
|
||||
x1={coords.x1}
|
||||
y1={coords.y1}
|
||||
x2={coords.x2}
|
||||
y2={coords.y2}
|
||||
stroke="transparent"
|
||||
stroke-width="20"
|
||||
class="link-hitbox"
|
||||
onmouseenter={(e) => handleLinkMouseEnter(e, link)}
|
||||
onmousemove={handleLinkMouseMove}
|
||||
onmouseleave={handleLinkMouseLeave}
|
||||
/>
|
||||
<!-- Visible link -->
|
||||
<line
|
||||
x1={coords.x1}
|
||||
y1={coords.y1}
|
||||
x2={coords.x2}
|
||||
y2={coords.y2}
|
||||
stroke-width={Math.max(1, link.strength / 25)}
|
||||
class="link"
|
||||
class:highlighted={isHighlighted}
|
||||
class:dimmed={selectedNodeId && !isHighlighted}
|
||||
class:hovered={hoveredLink === link}
|
||||
pointer-events="none"
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<!-- Nodes -->
|
||||
<g class="nodes">
|
||||
{#each nodes as node (node.id)}
|
||||
{@const isSelected = node.id === selectedNodeId}
|
||||
{@const isConnected = isConnectedToSelected(node.id)}
|
||||
{@const isDimmed = selectedNodeId && !isConnected}
|
||||
{@const nodeRadius = isSelected ? NODE_CONFIG.selectedRadius : NODE_CONFIG.radius}
|
||||
{@const avatarRadius = isSelected
|
||||
? NODE_CONFIG.selectedAvatarRadius
|
||||
: NODE_CONFIG.avatarRadius}
|
||||
{@const badgeOffset = isSelected
|
||||
? NODE_CONFIG.selectedBadgeOffset
|
||||
: NODE_CONFIG.badgeOffset}
|
||||
<g
|
||||
transform="translate({node.x ?? 0}, {node.y ?? 0})"
|
||||
class="node"
|
||||
class:selected={isSelected}
|
||||
class:connected={isConnected && !isSelected}
|
||||
class:dimmed={isDimmed}
|
||||
onmousedown={(e) => handleDragStart(e, node)}
|
||||
onclick={() => handleNodeClick(node)}
|
||||
ondblclick={() => handleNodeDoubleClick(node)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={node.name}
|
||||
>
|
||||
<!-- Node circle -->
|
||||
<circle r={nodeRadius} fill={stringToColor(node.name)} class="node-circle" />
|
||||
|
||||
<!-- Avatar image or initials -->
|
||||
{#if node.photoUrl}
|
||||
<clipPath id="clip-{node.id}">
|
||||
<circle r={avatarRadius} />
|
||||
</clipPath>
|
||||
<image
|
||||
href={node.photoUrl}
|
||||
x={-avatarRadius}
|
||||
y={-avatarRadius}
|
||||
width={avatarRadius * 2}
|
||||
height={avatarRadius * 2}
|
||||
clip-path="url(#clip-{node.id})"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
/>
|
||||
{:else}
|
||||
<text
|
||||
class="node-initials"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
fill="white"
|
||||
font-size={isSelected
|
||||
? LABEL_CONFIG.selectedInitialsFontSize
|
||||
: LABEL_CONFIG.initialsFontSize}
|
||||
font-weight="600"
|
||||
>
|
||||
{getInitials(node.name)}
|
||||
</text>
|
||||
{/if}
|
||||
|
||||
<!-- Favorite indicator -->
|
||||
{#if node.isFavorite}
|
||||
<circle cx={badgeOffset} cy={-badgeOffset} r="10" fill="hsl(var(--background))" />
|
||||
<text
|
||||
x={badgeOffset}
|
||||
y={-badgeOffset}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
font-size="12"
|
||||
>
|
||||
⭐
|
||||
</text>
|
||||
{/if}
|
||||
|
||||
<!-- Connection count badge -->
|
||||
{#if node.connectionCount > 0}
|
||||
<circle cx={-badgeOffset} cy={-badgeOffset} r="12" fill="hsl(var(--primary))" />
|
||||
<text
|
||||
x={-badgeOffset}
|
||||
y={-badgeOffset}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
fill="white"
|
||||
font-size="11"
|
||||
font-weight="600"
|
||||
>
|
||||
{node.connectionCount}
|
||||
</text>
|
||||
{/if}
|
||||
|
||||
<!-- Node label (counter-scaled for zoom independence) -->
|
||||
<g transform="scale({1 / transform.k})">
|
||||
<text
|
||||
y={(isSelected ? LABEL_CONFIG.selectedNameOffset : LABEL_CONFIG.nameOffset) *
|
||||
transform.k}
|
||||
class="node-label"
|
||||
text-anchor="middle"
|
||||
font-size={isSelected
|
||||
? LABEL_CONFIG.selectedNameFontSize
|
||||
: LABEL_CONFIG.nameFontSize}
|
||||
font-weight={isSelected ? '600' : '500'}
|
||||
>
|
||||
{node.name}
|
||||
</text>
|
||||
|
||||
<!-- Subtitle label (e.g., company) -->
|
||||
{#if node.subtitle}
|
||||
{@const labelOffset =
|
||||
(isSelected ? LABEL_CONFIG.selectedNameOffset : LABEL_CONFIG.nameOffset) *
|
||||
transform.k}
|
||||
<text
|
||||
y={labelOffset + LABEL_CONFIG.subtitleGap}
|
||||
class="node-subtitle"
|
||||
text-anchor="middle"
|
||||
font-size={LABEL_CONFIG.subtitleFontSize}
|
||||
>
|
||||
{node.subtitle}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if nodes.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔗</div>
|
||||
<p class="empty-title">Keine Verbindungen gefunden</p>
|
||||
<p class="empty-description">Elemente werden verbunden, wenn sie gemeinsame Tags haben.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Link tooltip -->
|
||||
{#if hoveredLink}
|
||||
{@const names = getLinkNodeNames(hoveredLink)}
|
||||
<div class="link-tooltip" style="left: {tooltipPosition.x}px; top: {tooltipPosition.y}px;">
|
||||
<div class="tooltip-header">
|
||||
<span class="tooltip-source">{names.source}</span>
|
||||
<span class="tooltip-arrow">↔</span>
|
||||
<span class="tooltip-target">{names.target}</span>
|
||||
</div>
|
||||
<div class="tooltip-strength">
|
||||
<span class="strength-label">Stärke:</span>
|
||||
<span class="strength-value">{hoveredLink.strength}%</span>
|
||||
<div class="strength-bar">
|
||||
<div class="strength-fill" style="width: {hoveredLink.strength}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tooltip-tags">
|
||||
{#each hoveredLink.sharedTags as tag}
|
||||
<span class="tooltip-tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.network-graph-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.network-graph-svg {
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.network-graph-svg:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.link {
|
||||
stroke: hsl(var(--muted-foreground) / 0.3);
|
||||
transition:
|
||||
stroke 0.2s,
|
||||
stroke-width 0.2s,
|
||||
opacity 0.2s;
|
||||
}
|
||||
|
||||
.link.highlighted {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
|
||||
.link.dimmed {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.link.hovered {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
|
||||
.link-hitbox {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Nodes */
|
||||
.node {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.node:hover .node-circle {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.node.selected .node-circle {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.node.connected .node-circle {
|
||||
stroke: hsl(var(--primary) / 0.5);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.node.dimmed {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.node-circle {
|
||||
transition:
|
||||
r 0.2s,
|
||||
stroke 0.2s,
|
||||
stroke-width 0.2s,
|
||||
filter 0.2s;
|
||||
}
|
||||
|
||||
.node-initials {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
fill: hsl(var(--foreground));
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-subtitle {
|
||||
fill: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: hsl(var(--muted-foreground));
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Link tooltip */
|
||||
.link-tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -100%) translateY(-12px);
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--popover));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.tooltip-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.tooltip-arrow {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.tooltip-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.strength-label {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.strength-value {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.strength-fill {
|
||||
height: 100%;
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 2px;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.tooltip-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.tooltip-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border: 1px solid hsl(var(--primary) / 0.2);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
</style>
|
||||
86
packages/shared-ui/src/organisms/network/constants.ts
Normal file
86
packages/shared-ui/src/organisms/network/constants.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Generate a consistent HSL color from a string
|
||||
* @param str - Input string (e.g., name)
|
||||
* @returns HSL color string
|
||||
*/
|
||||
export function stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = hash % 360;
|
||||
return `hsl(${hue}, 70%, 50%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initials from a name
|
||||
* @param name - Full name
|
||||
* @returns 1-2 character initials
|
||||
*/
|
||||
export function getInitials(name: string): string {
|
||||
const parts = name.trim().split(' ').filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 Force simulation default parameters
|
||||
*/
|
||||
export const SIMULATION_CONFIG = {
|
||||
/** Distance between linked nodes */
|
||||
linkDistance: 100,
|
||||
/** Strength of links (0-1) */
|
||||
linkStrength: 0.5,
|
||||
/** Charge strength (negative = repulsion) */
|
||||
chargeStrength: -300,
|
||||
/** Collision radius for nodes */
|
||||
collisionRadius: 50,
|
||||
/** Initial alpha for simulation */
|
||||
initialAlpha: 1,
|
||||
/** Alpha for reheating simulation */
|
||||
reheatAlpha: 0.3,
|
||||
/** Zoom scale extent */
|
||||
zoomExtent: [0.1, 4] as [number, number],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Node size configuration
|
||||
*/
|
||||
export const NODE_CONFIG = {
|
||||
/** Default node radius */
|
||||
radius: 36,
|
||||
/** Selected node radius */
|
||||
selectedRadius: 40,
|
||||
/** Avatar clip radius (slightly smaller than node) */
|
||||
avatarRadius: 34,
|
||||
/** Selected avatar clip radius */
|
||||
selectedAvatarRadius: 38,
|
||||
/** Badge offset from center */
|
||||
badgeOffset: 25,
|
||||
/** Selected badge offset */
|
||||
selectedBadgeOffset: 28,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Label configuration
|
||||
*/
|
||||
export const LABEL_CONFIG = {
|
||||
/** Font size for name label */
|
||||
nameFontSize: 18,
|
||||
/** Selected name font size */
|
||||
selectedNameFontSize: 20,
|
||||
/** Font size for subtitle label */
|
||||
subtitleFontSize: 14,
|
||||
/** Y offset for name label */
|
||||
nameOffset: 58,
|
||||
/** Selected name Y offset */
|
||||
selectedNameOffset: 62,
|
||||
/** Gap between name and subtitle */
|
||||
subtitleGap: 22,
|
||||
/** Font size for initials */
|
||||
initialsFontSize: 18,
|
||||
/** Selected initials font size */
|
||||
selectedInitialsFontSize: 20,
|
||||
} as const;
|
||||
25
packages/shared-ui/src/organisms/network/index.ts
Normal file
25
packages/shared-ui/src/organisms/network/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Components
|
||||
export { default as NetworkGraph } from './NetworkGraph.svelte';
|
||||
export { default as NetworkControls } from './NetworkControls.svelte';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
NetworkNode,
|
||||
NetworkLink,
|
||||
NetworkTag,
|
||||
NetworkTransform,
|
||||
NetworkGraphProps,
|
||||
NetworkControlsProps,
|
||||
NetworkGraphResponse,
|
||||
SimulationNode,
|
||||
SimulationLink,
|
||||
} from './network.types';
|
||||
|
||||
// Constants & Helpers
|
||||
export {
|
||||
stringToColor,
|
||||
getInitials,
|
||||
SIMULATION_CONFIG,
|
||||
NODE_CONFIG,
|
||||
LABEL_CONFIG,
|
||||
} from './constants';
|
||||
112
packages/shared-ui/src/organisms/network/network.types.ts
Normal file
112
packages/shared-ui/src/organisms/network/network.types.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { SimulationNodeDatum } from 'd3-force';
|
||||
|
||||
/**
|
||||
* Tag attached to a network node
|
||||
*/
|
||||
export interface NetworkTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base network node interface (before D3 simulation)
|
||||
*/
|
||||
export interface NetworkNode {
|
||||
id: string;
|
||||
name: string;
|
||||
photoUrl?: string | null;
|
||||
subtitle?: string | null; // e.g., Company, Project, Category
|
||||
isFavorite?: boolean;
|
||||
tags: NetworkTag[];
|
||||
connectionCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network node with D3 simulation properties
|
||||
*/
|
||||
export interface SimulationNode extends NetworkNode, SimulationNodeDatum {
|
||||
x?: number;
|
||||
y?: number;
|
||||
vx?: number;
|
||||
vy?: number;
|
||||
fx?: number | null;
|
||||
fy?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network link between nodes
|
||||
*/
|
||||
export interface NetworkLink {
|
||||
source: string;
|
||||
target: string;
|
||||
type: 'tag';
|
||||
strength: number; // 0-100, based on shared tag count
|
||||
sharedTags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Network link with D3 simulation properties
|
||||
* Note: After D3 simulation runs, source/target become SimulationNode objects
|
||||
*/
|
||||
export interface SimulationLink {
|
||||
source: string | SimulationNode;
|
||||
target: string | SimulationNode;
|
||||
type: 'tag';
|
||||
strength: number;
|
||||
sharedTags: string[];
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom/pan transform state
|
||||
*/
|
||||
export interface NetworkTransform {
|
||||
x: number;
|
||||
y: number;
|
||||
k: number; // scale factor
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for NetworkGraph component
|
||||
*/
|
||||
export interface NetworkGraphProps {
|
||||
nodes: SimulationNode[];
|
||||
links: SimulationLink[];
|
||||
selectedNodeId?: string | null;
|
||||
onNodeClick?: (node: SimulationNode) => void;
|
||||
onNodeDoubleClick?: (node: SimulationNode) => void;
|
||||
onBackgroundClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for NetworkControls component
|
||||
*/
|
||||
export interface NetworkControlsProps {
|
||||
searchQuery?: string;
|
||||
tags?: NetworkTag[];
|
||||
selectedTagId?: string | null;
|
||||
subtitles?: string[]; // e.g., companies, projects
|
||||
selectedSubtitle?: string | null;
|
||||
subtitleLabel?: string; // e.g., "Firma", "Projekt"
|
||||
nodeCount?: number;
|
||||
linkCount?: number;
|
||||
nodeLabel?: string; // e.g., "Kontakte", "Tasks"
|
||||
linkLabel?: string; // e.g., "Verbindungen"
|
||||
searchPlaceholder?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
onTagFilter?: (tagId: string | null) => void;
|
||||
onSubtitleFilter?: (subtitle: string | null) => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onResetZoom?: () => void;
|
||||
onClearFilters?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* API response structure for network graph
|
||||
*/
|
||||
export interface NetworkGraphResponse {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue