chore: update Angular TypeScript build info cache
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import { GraphConfig, colorToCss } from './graph-settings.types';
|
||||
import { GraphDisplayOptions } from '../../components/graph-view/graph-view.component';
|
||||
import { GraphData, GraphNode } from '../../types';
|
||||
import { parseSearchQuery, queryToPredicate, SearchContext, SectionContent, TaskInfo } from '../../core/search/search-parser';
|
||||
|
||||
/**
|
||||
* Adapter to convert GraphConfig to runtime graph options
|
||||
* Applies filters, groups, display settings, and forces to the graph engine
|
||||
*/
|
||||
export class GraphRuntimeAdapter {
|
||||
|
||||
/**
|
||||
* Convert GraphConfig to GraphDisplayOptions for the graph-view component
|
||||
*/
|
||||
static configToDisplayOptions(config: GraphConfig): GraphDisplayOptions {
|
||||
return {
|
||||
showArrows: config.showArrow,
|
||||
textFadeThreshold: this.convertTextFadeMultiplier(config.textFadeMultiplier),
|
||||
nodeSize: this.convertNodeSize(config.nodeSizeMultiplier),
|
||||
linkThickness: this.convertLinkThickness(config.lineSizeMultiplier),
|
||||
chargeStrength: this.convertRepelStrength(config.repelStrength),
|
||||
linkDistance: config.linkDistance,
|
||||
centerStrength: config.centerStrength
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to graph data
|
||||
* Returns filtered nodes and edges based on config
|
||||
*/
|
||||
static applyFilters(
|
||||
graphData: GraphData,
|
||||
config: GraphConfig,
|
||||
allNotes: any[]
|
||||
): GraphData {
|
||||
let filteredNodes = [...graphData.nodes];
|
||||
let filteredEdges = [...graphData.edges];
|
||||
|
||||
// Apply search filter using Obsidian-compatible parser
|
||||
if (config.search && config.search.trim()) {
|
||||
const parsed = parseSearchQuery(config.search);
|
||||
const predicate = queryToPredicate(parsed);
|
||||
|
||||
const matchingNodeIds = new Set<string>();
|
||||
|
||||
for (const node of filteredNodes) {
|
||||
const note = allNotes.find(n => n.id === node.id);
|
||||
if (note) {
|
||||
const context = this.buildSearchContext(note);
|
||||
|
||||
if (predicate(context)) {
|
||||
matchingNodeIds.add(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredNodes = filteredNodes.filter(node => matchingNodeIds.has(node.id));
|
||||
filteredEdges = filteredEdges.filter(
|
||||
edge => matchingNodeIds.has(edge.source) && matchingNodeIds.has(edge.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter tags (if disabled, remove tag nodes)
|
||||
if (!config.showTags) {
|
||||
const tagNodeIds = new Set(
|
||||
filteredNodes.filter(node => node.label.startsWith('#')).map(n => n.id)
|
||||
);
|
||||
filteredNodes = filteredNodes.filter(node => !tagNodeIds.has(node.id));
|
||||
filteredEdges = filteredEdges.filter(
|
||||
edge => !tagNodeIds.has(edge.source) && !tagNodeIds.has(edge.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter attachments (if disabled, remove attachment nodes)
|
||||
if (!config.showAttachments) {
|
||||
const attachmentNodeIds = new Set(
|
||||
filteredNodes.filter(node =>
|
||||
/\.(pdf|png|jpg|jpeg|gif|svg|webp|mp4|mp3|wav|doc|docx|xls|xlsx)$/i.test(node.label)
|
||||
).map(n => n.id)
|
||||
);
|
||||
filteredNodes = filteredNodes.filter(node => !attachmentNodeIds.has(node.id));
|
||||
filteredEdges = filteredEdges.filter(
|
||||
edge => !attachmentNodeIds.has(edge.source) && !attachmentNodeIds.has(edge.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter unresolved (hideUnresolved = true means show only existing files)
|
||||
if (config.hideUnresolved) {
|
||||
const existingNoteIds = new Set(allNotes.map(note => note.id));
|
||||
filteredNodes = filteredNodes.filter(node => existingNoteIds.has(node.id));
|
||||
filteredEdges = filteredEdges.filter(
|
||||
edge => existingNoteIds.has(edge.source) && existingNoteIds.has(edge.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter orphans (if disabled, remove nodes with no connections)
|
||||
if (!config.showOrphans) {
|
||||
const connectedNodeIds = new Set<string>();
|
||||
filteredEdges.forEach(edge => {
|
||||
connectedNodeIds.add(edge.source);
|
||||
connectedNodeIds.add(edge.target);
|
||||
});
|
||||
filteredNodes = filteredNodes.filter(node => connectedNodeIds.has(node.id));
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: filteredNodes,
|
||||
edges: filteredEdges
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply color groups to nodes
|
||||
* Returns a map of node ID to color
|
||||
*/
|
||||
static applyColorGroups(
|
||||
nodes: GraphNode[],
|
||||
config: GraphConfig,
|
||||
allNotes: any[]
|
||||
): Map<string, string> {
|
||||
const nodeColors = new Map<string, string>();
|
||||
|
||||
if (!config.colorGroups || config.colorGroups.length === 0) {
|
||||
return nodeColors;
|
||||
}
|
||||
|
||||
// Process each color group in order
|
||||
for (const group of config.colorGroups) {
|
||||
const matchingNodes = this.queryNodes(nodes, group.query, allNotes);
|
||||
const color = colorToCss(group.color);
|
||||
|
||||
for (const node of matchingNodes) {
|
||||
// First match wins (only set if not already colored)
|
||||
if (!nodeColors.has(node.id)) {
|
||||
nodeColors.set(node.id, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodeColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query nodes based on Obsidian-style query string
|
||||
* Uses the search parser for consistent behavior
|
||||
*/
|
||||
private static queryNodes(
|
||||
nodes: GraphNode[],
|
||||
query: string,
|
||||
allNotes: any[]
|
||||
): GraphNode[] {
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = parseSearchQuery(query);
|
||||
const predicate = queryToPredicate(parsed);
|
||||
const matchingNodes: GraphNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const note = allNotes.find(n => n.id === node.id);
|
||||
if (note) {
|
||||
const context = this.buildSearchContext(note);
|
||||
|
||||
if (predicate(context)) {
|
||||
matchingNodes.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchingNodes;
|
||||
}
|
||||
|
||||
private static buildSearchContext(note: any): SearchContext {
|
||||
const content: string = note?.content || '';
|
||||
const lines = content.split('\n');
|
||||
|
||||
return {
|
||||
filePath: note?.filePath || note?.path || '',
|
||||
fileName: note?.name || '',
|
||||
fileNameWithExt: note?.name || '',
|
||||
content,
|
||||
tags: Array.isArray(note?.tags) ? note.tags : [],
|
||||
properties: note?.frontmatter || {},
|
||||
lines,
|
||||
blocks: this.extractBlocks(lines),
|
||||
sections: this.normalizeSections(note?.headings, content),
|
||||
tasks: this.extractTasks(content, note?.tasks)
|
||||
};
|
||||
}
|
||||
|
||||
private static extractBlocks(lines: string[]): string[] {
|
||||
const blocks: string[] = [];
|
||||
let current: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
if (current.length) {
|
||||
blocks.push(current.join('\n').trim());
|
||||
current = [];
|
||||
}
|
||||
} else {
|
||||
current.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length) {
|
||||
blocks.push(current.join('\n').trim());
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static normalizeSections(headings: any, content: string): SectionContent[] {
|
||||
if (Array.isArray(headings) && headings.length > 0) {
|
||||
return headings.map((item: any) => ({
|
||||
heading: item?.heading ?? item?.title ?? '',
|
||||
content: item?.content ?? '',
|
||||
level: typeof item?.level === 'number' ? item.level : (typeof item?.depth === 'number' ? item.depth : 1)
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback: treat entire document as single section
|
||||
return [{ heading: '', content, level: 1 }];
|
||||
}
|
||||
|
||||
private static extractTasks(content: string, existingTasks: any): TaskInfo[] {
|
||||
const tasks: TaskInfo[] = [];
|
||||
|
||||
if (Array.isArray(existingTasks) && existingTasks.length > 0) {
|
||||
for (const task of existingTasks) {
|
||||
const text = task?.text ?? task?.description ?? '';
|
||||
if (!text) continue;
|
||||
tasks.push({
|
||||
text,
|
||||
completed: Boolean(task?.completed ?? task?.done ?? false),
|
||||
line: typeof task?.line === 'number' ? task.line : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0 && content) {
|
||||
const lines = content.split('\n');
|
||||
const taskRegex = /^[\s>*-]*\[( |x|X)\]\s+(.*)$/;
|
||||
lines.forEach((line, index) => {
|
||||
const match = line.match(taskRegex);
|
||||
if (match) {
|
||||
tasks.push({
|
||||
text: match[2].trim(),
|
||||
completed: match[1].toLowerCase() === 'x',
|
||||
line: index + 1
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert textFadeMultiplier (-3 to 3) to threshold percentage (0 to 100)
|
||||
* Obsidian's logic: negative values fade text earlier, positive values fade later
|
||||
*/
|
||||
private static convertTextFadeMultiplier(multiplier: number): number {
|
||||
// Map -3..3 to 0..100 with 0 mapping to 50
|
||||
// -3 -> 0 (fade very early), 0 -> 50 (default), 3 -> 100 (fade very late)
|
||||
return Math.round(((multiplier + 3) / 6) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert nodeSizeMultiplier (0.25 to 3) to absolute node size
|
||||
* Base size: 5 pixels
|
||||
*/
|
||||
private static convertNodeSize(multiplier: number): number {
|
||||
const baseSize = 5;
|
||||
return baseSize * multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lineSizeMultiplier (0.25 to 3) to absolute link thickness
|
||||
* Base thickness: 1 pixel
|
||||
*/
|
||||
private static convertLinkThickness(multiplier: number): number {
|
||||
const baseThickness = 1;
|
||||
return baseThickness * multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert repelStrength (0 to 20) to d3 charge strength (negative)
|
||||
* Obsidian's repel strength maps to negative charge force
|
||||
*/
|
||||
private static convertRepelStrength(repelStrength: number): number {
|
||||
// Map 0..20 to 0..-200 (stronger repulsion = more negative)
|
||||
return -repelStrength * 10;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, Subject, debounceTime, catchError, of, tap } from 'rxjs';
|
||||
import { GraphConfig, DEFAULT_GRAPH_CONFIG, validateGraphConfig } from './graph-settings.types';
|
||||
|
||||
/** Response from graph config API */
|
||||
interface GraphConfigResponse {
|
||||
config: GraphConfig;
|
||||
rev: string;
|
||||
}
|
||||
|
||||
/** Unsubscribe function type */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/**
|
||||
* Service for managing graph settings stored in .obsidian/graph.json
|
||||
* Handles loading, saving, watching for external changes, and debouncing writes
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GraphSettingsService {
|
||||
private configSubject = new BehaviorSubject<GraphConfig>(DEFAULT_GRAPH_CONFIG);
|
||||
private saveQueue = new Subject<Partial<GraphConfig>>();
|
||||
private currentRev: string | null = null;
|
||||
private pollingInterval: any = null;
|
||||
|
||||
/** Current graph configuration as signal */
|
||||
config = signal<GraphConfig>(DEFAULT_GRAPH_CONFIG);
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
// Setup debounced save mechanism
|
||||
this.saveQueue.pipe(
|
||||
debounceTime(250)
|
||||
).subscribe(patch => {
|
||||
this.performSave(patch);
|
||||
});
|
||||
|
||||
// Load initial config
|
||||
this.load();
|
||||
|
||||
// Start polling for external changes (every 2 seconds)
|
||||
this.startWatching();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from server
|
||||
* Falls back to defaults if file doesn't exist or is invalid
|
||||
*/
|
||||
async load(): Promise<GraphConfig> {
|
||||
try {
|
||||
const response = await this.http.get<GraphConfigResponse>('/api/vault/graph')
|
||||
.pipe(
|
||||
catchError(err => {
|
||||
console.warn('Failed to load graph config, using defaults:', err);
|
||||
return of({ config: DEFAULT_GRAPH_CONFIG, rev: 'default' });
|
||||
})
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
if (response) {
|
||||
const validated = this.validateAndMerge(response.config);
|
||||
this.currentRev = response.rev;
|
||||
this.config.set(validated);
|
||||
this.configSubject.next(validated);
|
||||
return validated;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading graph config:', error);
|
||||
}
|
||||
|
||||
return DEFAULT_GRAPH_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a partial configuration update for saving
|
||||
* Actual save is debounced to avoid excessive writes
|
||||
*/
|
||||
save(patch: Partial<GraphConfig>): void {
|
||||
// Update local state immediately for reactive UI
|
||||
const updated = { ...this.config(), ...patch };
|
||||
const validated = this.validateAndMerge(updated);
|
||||
this.config.set(validated);
|
||||
this.configSubject.next(validated);
|
||||
|
||||
// Queue for debounced save
|
||||
this.saveQueue.next(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual save operation to the server
|
||||
*/
|
||||
private async performSave(patch: Partial<GraphConfig>): Promise<void> {
|
||||
try {
|
||||
const current = this.config();
|
||||
const updated = { ...current, ...patch };
|
||||
const validated = this.validateAndMerge(updated);
|
||||
|
||||
const headers: any = {};
|
||||
if (this.currentRev) {
|
||||
headers['If-Match'] = this.currentRev;
|
||||
}
|
||||
|
||||
const response = await this.http.put<{ rev: string }>(
|
||||
'/api/vault/graph',
|
||||
validated,
|
||||
{ headers }
|
||||
).pipe(
|
||||
tap(result => {
|
||||
this.currentRev = result.rev;
|
||||
}),
|
||||
catchError(err => {
|
||||
if (err.status === 409) {
|
||||
console.warn('Graph config conflict detected, reloading...');
|
||||
this.load();
|
||||
} else {
|
||||
console.error('Failed to save graph config:', err);
|
||||
}
|
||||
return of(null);
|
||||
})
|
||||
).toPromise();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving graph config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for external changes to the configuration file
|
||||
*/
|
||||
watch(callback: (config: GraphConfig) => void): Unsubscribe {
|
||||
const subscription = this.configSubject.subscribe(callback);
|
||||
return () => subscription.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for external changes
|
||||
*/
|
||||
private startWatching(): void {
|
||||
if (this.pollingInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollingInterval = setInterval(() => {
|
||||
this.checkForExternalChanges();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling for external changes
|
||||
*/
|
||||
private stopWatching(): void {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
this.pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the config file was modified externally
|
||||
*/
|
||||
private async checkForExternalChanges(): Promise<void> {
|
||||
try {
|
||||
const response = await this.http.get<GraphConfigResponse>('/api/vault/graph')
|
||||
.pipe(
|
||||
catchError(() => of(null))
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
if (response && response.rev !== this.currentRev) {
|
||||
console.log('External graph config change detected, reloading...');
|
||||
const validated = this.validateAndMerge(response.config);
|
||||
this.currentRev = response.rev;
|
||||
this.config.set(validated);
|
||||
this.configSubject.next(validated);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to default configuration
|
||||
*/
|
||||
resetToDefaults(): void {
|
||||
this.save(DEFAULT_GRAPH_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a specific section to defaults
|
||||
*/
|
||||
resetSection(section: 'filters' | 'groups' | 'display' | 'forces'): void {
|
||||
const defaults = DEFAULT_GRAPH_CONFIG;
|
||||
|
||||
switch (section) {
|
||||
case 'filters':
|
||||
this.save({
|
||||
search: defaults.search,
|
||||
showTags: defaults.showTags,
|
||||
showAttachments: defaults.showAttachments,
|
||||
hideUnresolved: defaults.hideUnresolved,
|
||||
showOrphans: defaults.showOrphans
|
||||
});
|
||||
break;
|
||||
|
||||
case 'groups':
|
||||
this.save({
|
||||
colorGroups: defaults.colorGroups
|
||||
});
|
||||
break;
|
||||
|
||||
case 'display':
|
||||
this.save({
|
||||
showArrow: defaults.showArrow,
|
||||
textFadeMultiplier: defaults.textFadeMultiplier,
|
||||
nodeSizeMultiplier: defaults.nodeSizeMultiplier,
|
||||
lineSizeMultiplier: defaults.lineSizeMultiplier
|
||||
});
|
||||
break;
|
||||
|
||||
case 'forces':
|
||||
this.save({
|
||||
centerStrength: defaults.centerStrength,
|
||||
repelStrength: defaults.repelStrength,
|
||||
linkStrength: defaults.linkStrength,
|
||||
linkDistance: defaults.linkDistance
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a collapse state
|
||||
*/
|
||||
toggleCollapse(section: 'filter' | 'color-groups' | 'display' | 'forces'): void {
|
||||
const key = `collapse-${section}` as keyof GraphConfig;
|
||||
const current = this.config()[key] as boolean;
|
||||
this.save({ [key]: !current } as Partial<GraphConfig>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and merge configuration with defaults
|
||||
* Ensures all required fields exist and values are within bounds
|
||||
*/
|
||||
private validateAndMerge(config: Partial<GraphConfig>): GraphConfig {
|
||||
const merged = {
|
||||
...DEFAULT_GRAPH_CONFIG,
|
||||
...config
|
||||
};
|
||||
|
||||
// Validate numeric bounds
|
||||
const validated = validateGraphConfig(merged);
|
||||
|
||||
// Ensure colorGroups is an array
|
||||
if (!Array.isArray(validated.colorGroups)) {
|
||||
validated.colorGroups = [];
|
||||
}
|
||||
|
||||
return merged as GraphConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.stopWatching();
|
||||
this.configSubject.complete();
|
||||
this.saveQueue.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Type definitions for Obsidian graph.json configuration
|
||||
* These types match exactly the structure used in Obsidian's .obsidian/graph.json
|
||||
*/
|
||||
|
||||
/** RGB color stored as integer (0-16777215) */
|
||||
export type RgbInt = number;
|
||||
|
||||
/** Color object matching Obsidian's format */
|
||||
export interface GraphColor {
|
||||
a: number; // Alpha channel (0-1)
|
||||
rgb: RgbInt; // RGB color as integer
|
||||
}
|
||||
|
||||
/** Color group for node coloring based on queries */
|
||||
export interface GraphColorGroup {
|
||||
query: string; // Query string (e.g., "Tag:#markdown", "tag:#note", "file:test")
|
||||
color: GraphColor; // Color to apply
|
||||
}
|
||||
|
||||
/** Complete graph configuration matching Obsidian's graph.json */
|
||||
export interface GraphConfig {
|
||||
// Filters section
|
||||
'collapse-filter': boolean;
|
||||
search: string;
|
||||
showTags: boolean;
|
||||
showAttachments: boolean;
|
||||
hideUnresolved: boolean; // Note: inverted from "Existing files only" toggle
|
||||
showOrphans: boolean;
|
||||
|
||||
// Groups section
|
||||
'collapse-color-groups': boolean;
|
||||
colorGroups: GraphColorGroup[];
|
||||
|
||||
// Display section
|
||||
'collapse-display': boolean;
|
||||
showArrow: boolean;
|
||||
textFadeMultiplier: number; // Range: -3 to 3, step 0.1
|
||||
nodeSizeMultiplier: number; // Range: 0.25 to 3, step 0.05
|
||||
lineSizeMultiplier: number; // Range: 0.25 to 3, step 0.05
|
||||
|
||||
// Forces section
|
||||
'collapse-forces': boolean;
|
||||
centerStrength: number; // Range: 0 to 2, step 0.01
|
||||
repelStrength: number; // Range: 0 to 20, step 0.5
|
||||
linkStrength: number; // Range: 0 to 2, step 0.01
|
||||
linkDistance: number; // Range: 20 to 300, step 1
|
||||
|
||||
// Preserved but not exposed in UI
|
||||
scale?: number;
|
||||
close?: boolean;
|
||||
}
|
||||
|
||||
/** Default configuration values matching Obsidian defaults */
|
||||
export const DEFAULT_GRAPH_CONFIG: GraphConfig = {
|
||||
'collapse-filter': false,
|
||||
search: '',
|
||||
showTags: false,
|
||||
showAttachments: false,
|
||||
hideUnresolved: false,
|
||||
showOrphans: true,
|
||||
|
||||
'collapse-color-groups': false,
|
||||
colorGroups: [],
|
||||
|
||||
'collapse-display': false,
|
||||
showArrow: false,
|
||||
textFadeMultiplier: 0,
|
||||
nodeSizeMultiplier: 1,
|
||||
lineSizeMultiplier: 1,
|
||||
|
||||
'collapse-forces': false,
|
||||
centerStrength: 0.5,
|
||||
repelStrength: 10,
|
||||
linkStrength: 1,
|
||||
linkDistance: 250,
|
||||
|
||||
scale: 1,
|
||||
close: false
|
||||
};
|
||||
|
||||
/** Validation bounds for numeric values */
|
||||
export const GRAPH_CONFIG_BOUNDS = {
|
||||
textFadeMultiplier: { min: -3, max: 3, step: 0.1 },
|
||||
nodeSizeMultiplier: { min: 0.25, max: 3, step: 0.05 },
|
||||
lineSizeMultiplier: { min: 0.25, max: 3, step: 0.05 },
|
||||
centerStrength: { min: 0, max: 2, step: 0.01 },
|
||||
repelStrength: { min: 0, max: 20, step: 0.5 },
|
||||
linkStrength: { min: 0, max: 2, step: 0.01 },
|
||||
linkDistance: { min: 20, max: 300, step: 1 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility functions for color conversion
|
||||
*/
|
||||
|
||||
/** Convert RGB integer to RGBA object */
|
||||
export function intToRgba(rgb: RgbInt, alpha: number = 1): { r: number; g: number; b: number; a: number } {
|
||||
return {
|
||||
r: (rgb >> 16) & 255,
|
||||
g: (rgb >> 8) & 255,
|
||||
b: rgb & 255,
|
||||
a: alpha
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert RGBA values to RGB integer */
|
||||
export function rgbaToInt(r: number, g: number, b: number): RgbInt {
|
||||
return ((r & 255) << 16) | ((g & 255) << 8) | (b & 255);
|
||||
}
|
||||
|
||||
/** Convert hex color string to RGB integer */
|
||||
export function hexToInt(hex: string): RgbInt {
|
||||
const cleaned = hex.replace('#', '');
|
||||
const r = parseInt(cleaned.substring(0, 2), 16);
|
||||
const g = parseInt(cleaned.substring(2, 4), 16);
|
||||
const b = parseInt(cleaned.substring(4, 6), 16);
|
||||
return rgbaToInt(r, g, b);
|
||||
}
|
||||
|
||||
/** Convert RGB integer to hex string */
|
||||
export function intToHex(rgb: RgbInt): string {
|
||||
const r = (rgb >> 16) & 255;
|
||||
const g = (rgb >> 8) & 255;
|
||||
const b = rgb & 255;
|
||||
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Convert GraphColor to CSS rgba string */
|
||||
export function colorToCss(color: GraphColor): string {
|
||||
const { r, g, b } = intToRgba(color.rgb, color.a);
|
||||
return `rgba(${r}, ${g}, ${b}, ${color.a})`;
|
||||
}
|
||||
|
||||
/** Create GraphColor from hex and alpha */
|
||||
export function createGraphColor(hex: string, alpha: number = 1): GraphColor {
|
||||
return {
|
||||
a: alpha,
|
||||
rgb: hexToInt(hex)
|
||||
};
|
||||
}
|
||||
|
||||
/** Clamp numeric value to bounds */
|
||||
export function clampValue(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/** Validate and clamp graph config numeric values */
|
||||
export function validateGraphConfig(config: Partial<GraphConfig>): Partial<GraphConfig> {
|
||||
const validated = { ...config };
|
||||
|
||||
if (typeof validated.textFadeMultiplier === 'number') {
|
||||
validated.textFadeMultiplier = clampValue(
|
||||
validated.textFadeMultiplier,
|
||||
GRAPH_CONFIG_BOUNDS.textFadeMultiplier.min,
|
||||
GRAPH_CONFIG_BOUNDS.textFadeMultiplier.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.nodeSizeMultiplier === 'number') {
|
||||
validated.nodeSizeMultiplier = clampValue(
|
||||
validated.nodeSizeMultiplier,
|
||||
GRAPH_CONFIG_BOUNDS.nodeSizeMultiplier.min,
|
||||
GRAPH_CONFIG_BOUNDS.nodeSizeMultiplier.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.lineSizeMultiplier === 'number') {
|
||||
validated.lineSizeMultiplier = clampValue(
|
||||
validated.lineSizeMultiplier,
|
||||
GRAPH_CONFIG_BOUNDS.lineSizeMultiplier.min,
|
||||
GRAPH_CONFIG_BOUNDS.lineSizeMultiplier.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.centerStrength === 'number') {
|
||||
validated.centerStrength = clampValue(
|
||||
validated.centerStrength,
|
||||
GRAPH_CONFIG_BOUNDS.centerStrength.min,
|
||||
GRAPH_CONFIG_BOUNDS.centerStrength.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.repelStrength === 'number') {
|
||||
validated.repelStrength = clampValue(
|
||||
validated.repelStrength,
|
||||
GRAPH_CONFIG_BOUNDS.repelStrength.min,
|
||||
GRAPH_CONFIG_BOUNDS.repelStrength.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.linkStrength === 'number') {
|
||||
validated.linkStrength = clampValue(
|
||||
validated.linkStrength,
|
||||
GRAPH_CONFIG_BOUNDS.linkStrength.min,
|
||||
GRAPH_CONFIG_BOUNDS.linkStrength.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.linkDistance === 'number') {
|
||||
validated.linkDistance = clampValue(
|
||||
validated.linkDistance,
|
||||
GRAPH_CONFIG_BOUNDS.linkDistance.min,
|
||||
GRAPH_CONFIG_BOUNDS.linkDistance.max
|
||||
);
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { GraphSettingsService } from '../graph-settings.service';
|
||||
import { GraphFiltersSectionComponent } from './sections/filters-section.component';
|
||||
import { GraphGroupsSectionComponent } from './sections/groups-section.component';
|
||||
import { GraphDisplaySectionComponent } from './sections/display-section.component';
|
||||
import { GraphForcesSectionComponent } from './sections/forces-section.component';
|
||||
import { GraphConfig } from '../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-inline-settings',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
GraphFiltersSectionComponent,
|
||||
GraphGroupsSectionComponent,
|
||||
GraphDisplaySectionComponent,
|
||||
GraphForcesSectionComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Header row for inline panel (optional small actions) -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Graph settings</h3>
|
||||
<button
|
||||
type="button"
|
||||
(click)="resetAll()"
|
||||
class="rounded-lg p-1.5 text-obs-l-text-muted hover:bg-obs-l-bg-main/70 dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-main/60"
|
||||
title="Reset all settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<section class="rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/70 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Filters</div>
|
||||
<div class="px-3 pb-3">
|
||||
<app-graph-filters-section [config]="config()" (configChange)="onConfigChange($event)"></app-graph-filters-section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Groups -->
|
||||
<section class="rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/70 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Groups</div>
|
||||
<div class="px-3 pb-3">
|
||||
<app-graph-groups-section [config]="config()" (configChange)="onConfigChange($event)"></app-graph-groups-section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display -->
|
||||
<section class="rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/70 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Display</div>
|
||||
<div class="px-3 pb-3">
|
||||
<app-graph-display-section [config]="config()" (configChange)="onConfigChange($event)" (animateRequested)="animateRequested.emit()"></app-graph-display-section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Forces -->
|
||||
<section class="rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/70 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Forces</div>
|
||||
<div class="px-3 pb-3">
|
||||
<app-graph-forces-section [config]="config()" (configChange)="onConfigChange($event)"></app-graph-forces-section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class GraphInlineSettingsComponent {
|
||||
animateRequested = output<void>();
|
||||
private settings = inject(GraphSettingsService);
|
||||
config = this.settings.config;
|
||||
|
||||
onConfigChange(patch: Partial<GraphConfig>) {
|
||||
this.settings.save(patch);
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
if (confirm('Reset all graph settings to defaults?')) {
|
||||
this.settings.resetToDefaults();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-display-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<!-- Arrows toggle -->
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().showArrow"
|
||||
(change)="onToggleChange('showArrow', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Arrows</span>
|
||||
</label>
|
||||
|
||||
<!-- Text fade threshold slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Text fade threshold</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().textFadeMultiplier, 1) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.textFadeMultiplier.min"
|
||||
[max]="bounds.textFadeMultiplier.max"
|
||||
[step]="bounds.textFadeMultiplier.step"
|
||||
[value]="config().textFadeMultiplier"
|
||||
(input)="onSliderChange('textFadeMultiplier', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.textFadeMultiplier.min }}</span>
|
||||
<span>{{ bounds.textFadeMultiplier.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node size slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Node size</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().nodeSizeMultiplier, 2) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.nodeSizeMultiplier.min"
|
||||
[max]="bounds.nodeSizeMultiplier.max"
|
||||
[step]="bounds.nodeSizeMultiplier.step"
|
||||
[value]="config().nodeSizeMultiplier"
|
||||
(input)="onSliderChange('nodeSizeMultiplier', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.nodeSizeMultiplier.min }}</span>
|
||||
<span>{{ bounds.nodeSizeMultiplier.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link thickness slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Link thickness</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().lineSizeMultiplier, 2) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.lineSizeMultiplier.min"
|
||||
[max]="bounds.lineSizeMultiplier.max"
|
||||
[step]="bounds.lineSizeMultiplier.step"
|
||||
[value]="config().lineSizeMultiplier"
|
||||
(input)="onSliderChange('lineSizeMultiplier', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.lineSizeMultiplier.min }}</span>
|
||||
<span>{{ bounds.lineSizeMultiplier.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Animate button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="animateRequested.emit()"
|
||||
class="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors shadow-sm">
|
||||
Animate
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphDisplaySectionComponent {
|
||||
config = input.required<GraphConfig>();
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
animateRequested = output<void>();
|
||||
|
||||
bounds = GRAPH_CONFIG_BOUNDS;
|
||||
|
||||
onToggleChange(key: keyof GraphConfig, event: Event): void {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
this.configChange.emit({ [key]: checked } as Partial<GraphConfig>);
|
||||
}
|
||||
|
||||
onSliderChange(key: keyof GraphConfig, event: Event): void {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||
this.configChange.emit({ [key]: value } as Partial<GraphConfig>);
|
||||
}
|
||||
|
||||
formatNumber(value: number, decimals: number): string {
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GraphConfig } from '../../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-filters-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- Search input -->
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="config().search"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
placeholder="Search files..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Toggle options -->
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().showTags"
|
||||
(change)="onToggleChange('showTags', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Tags</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().showAttachments"
|
||||
(change)="onToggleChange('showAttachments', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Attachments</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().hideUnresolved"
|
||||
(change)="onToggleChange('hideUnresolved', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Existing files only</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().showOrphans"
|
||||
(change)="onToggleChange('showOrphans', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Orphans</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class GraphFiltersSectionComponent {
|
||||
config = input.required<GraphConfig>();
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
|
||||
onSearchChange(value: string): void {
|
||||
this.configChange.emit({ search: value });
|
||||
}
|
||||
|
||||
onToggleChange(key: keyof GraphConfig, event: Event): void {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
this.configChange.emit({ [key]: checked } as Partial<GraphConfig>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-forces-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<!-- Center force slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Center force</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().centerStrength, 2) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.centerStrength.min"
|
||||
[max]="bounds.centerStrength.max"
|
||||
[step]="bounds.centerStrength.step"
|
||||
[value]="config().centerStrength"
|
||||
(input)="onSliderChange('centerStrength', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.centerStrength.min }}</span>
|
||||
<span>{{ bounds.centerStrength.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repel force slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Repel force</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().repelStrength, 1) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.repelStrength.min"
|
||||
[max]="bounds.repelStrength.max"
|
||||
[step]="bounds.repelStrength.step"
|
||||
[value]="config().repelStrength"
|
||||
(input)="onSliderChange('repelStrength', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.repelStrength.min }}</span>
|
||||
<span>{{ bounds.repelStrength.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link force slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Link force</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().linkStrength, 2) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.linkStrength.min"
|
||||
[max]="bounds.linkStrength.max"
|
||||
[step]="bounds.linkStrength.step"
|
||||
[value]="config().linkStrength"
|
||||
(input)="onSliderChange('linkStrength', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.linkStrength.min }}</span>
|
||||
<span>{{ bounds.linkStrength.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link distance slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Link distance</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().linkDistance, 0) }}px</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.linkDistance.min"
|
||||
[max]="bounds.linkDistance.max"
|
||||
[step]="bounds.linkDistance.step"
|
||||
[value]="config().linkDistance"
|
||||
(input)="onSliderChange('linkDistance', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.linkDistance.min }}</span>
|
||||
<span>{{ bounds.linkDistance.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphForcesSectionComponent {
|
||||
config = input.required<GraphConfig>();
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
|
||||
bounds = GRAPH_CONFIG_BOUNDS;
|
||||
|
||||
onSliderChange(key: keyof GraphConfig, event: Event): void {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||
this.configChange.emit({ [key]: value } as Partial<GraphConfig>);
|
||||
}
|
||||
|
||||
formatNumber(value: number, decimals: number): string {
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GraphConfig, GraphColorGroup, intToHex, createGraphColor } from '../../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-groups-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- Color groups list -->
|
||||
@if (config().colorGroups.length > 0) {
|
||||
<div class="space-y-2">
|
||||
@for (group of config().colorGroups; track $index) {
|
||||
<div class="flex items-center gap-2 p-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<!-- Color picker -->
|
||||
<input
|
||||
type="color"
|
||||
[value]="getColorHex(group)"
|
||||
(change)="onColorChange($index, $event)"
|
||||
class="w-8 h-8 rounded cursor-pointer border-0"
|
||||
title="Change color">
|
||||
|
||||
<!-- Query input -->
|
||||
<input
|
||||
type="text"
|
||||
[value]="group.query"
|
||||
(input)="onQueryChange($index, $event)"
|
||||
placeholder="tag:#example"
|
||||
class="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
|
||||
<!-- Actions -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="onDuplicate($index)"
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
title="Duplicate">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="onDelete($index)"
|
||||
class="p-1 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||
title="Delete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- New group button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="onAddGroup()"
|
||||
class="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors shadow-sm">
|
||||
New group
|
||||
</button>
|
||||
|
||||
<!-- Help text -->
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<div><strong>Examples:</strong></div>
|
||||
<div>• <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">tag:#markdown</code></div>
|
||||
<div>• <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">file:test</code></div>
|
||||
<div>• <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">path:folder</code></div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphGroupsSectionComponent {
|
||||
config = input.required<GraphConfig>();
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
|
||||
private readonly defaultColors = [
|
||||
'#E06C75', // red
|
||||
'#98C379', // green
|
||||
'#61AFEF', // blue
|
||||
'#C678DD', // purple
|
||||
'#E5C07B', // yellow
|
||||
'#56B6C2', // cyan
|
||||
'#D19A66', // orange
|
||||
];
|
||||
|
||||
getColorHex(group: GraphColorGroup): string {
|
||||
return intToHex(group.color.rgb);
|
||||
}
|
||||
|
||||
onColorChange(index: number, event: Event): void {
|
||||
const hex = (event.target as HTMLInputElement).value;
|
||||
const groups = [...this.config().colorGroups];
|
||||
groups[index] = {
|
||||
...groups[index],
|
||||
color: createGraphColor(hex, groups[index].color.a)
|
||||
};
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
|
||||
onQueryChange(index: number, event: Event): void {
|
||||
const query = (event.target as HTMLInputElement).value;
|
||||
const groups = [...this.config().colorGroups];
|
||||
groups[index] = {
|
||||
...groups[index],
|
||||
query
|
||||
};
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
|
||||
onAddGroup(): void {
|
||||
const groups = [...this.config().colorGroups];
|
||||
const colorIndex = groups.length % this.defaultColors.length;
|
||||
const newGroup: GraphColorGroup = {
|
||||
query: '',
|
||||
color: createGraphColor(this.defaultColors[colorIndex], 1)
|
||||
};
|
||||
groups.push(newGroup);
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
|
||||
onDuplicate(index: number): void {
|
||||
const groups = [...this.config().colorGroups];
|
||||
const duplicate = { ...groups[index] };
|
||||
groups.splice(index + 1, 0, duplicate);
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
|
||||
onDelete(index: number): void {
|
||||
const groups = [...this.config().colorGroups];
|
||||
groups.splice(index, 1);
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-settings-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
(click)="clicked.emit()"
|
||||
(keydown.enter)="clicked.emit()"
|
||||
(keydown.space)="clicked.emit(); $event.preventDefault()"
|
||||
class="settings-button"
|
||||
aria-label="Graph settings"
|
||||
title="Settings">
|
||||
<!-- Gear icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
`,
|
||||
styles: [`
|
||||
.settings-button {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
background-color: rgba(255, 255, 255, 0.96);
|
||||
color: #1F2937;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.35), 0 8px 10px -6px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, background-color 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settings-button:hover {
|
||||
background-color: rgba(248, 250, 252, 1);
|
||||
color: #0F172A;
|
||||
transform: rotate(90deg);
|
||||
box-shadow: 0 18px 30px -8px rgba(15, 23, 42, 0.45), 0 10px 20px -10px rgba(30, 41, 59, 0.4);
|
||||
border-color: rgba(148, 163, 184, 0.55);
|
||||
}
|
||||
|
||||
.settings-button:focus {
|
||||
outline: 2px solid #3B82F6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-button:active {
|
||||
transform: rotate(90deg) scale(0.95);
|
||||
}
|
||||
|
||||
:host-context(.dark) .settings-button {
|
||||
background-color: rgba(30, 41, 59, 0.92);
|
||||
color: #E2E8F0;
|
||||
border-color: rgba(148, 163, 184, 0.55);
|
||||
box-shadow: 0 12px 30px -8px rgba(8, 47, 73, 0.55), 0 10px 18px -10px rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
:host-context(.dark) .settings-button:hover {
|
||||
background-color: rgba(51, 65, 85, 0.95);
|
||||
color: #F8FAFC;
|
||||
border-color: rgba(226, 232, 240, 0.65);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphSettingsButtonComponent {
|
||||
clicked = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, inject, effect } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { GraphConfig } from '../graph-settings.types';
|
||||
import { GraphSettingsService } from '../graph-settings.service';
|
||||
import { GraphFiltersSectionComponent } from './sections/filters-section.component';
|
||||
import { GraphGroupsSectionComponent } from './sections/groups-section.component';
|
||||
import { GraphDisplaySectionComponent } from './sections/display-section.component';
|
||||
import { GraphForcesSectionComponent } from './sections/forces-section.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-settings-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
GraphFiltersSectionComponent,
|
||||
GraphGroupsSectionComponent,
|
||||
GraphDisplaySectionComponent,
|
||||
GraphForcesSectionComponent
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="settings-panel">
|
||||
@if (isOpen()) {
|
||||
<div class="backdrop" (click)="close.emit()"></div>
|
||||
|
||||
<div class="panel-content">
|
||||
<div class="panel-header">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Graph settings</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="onResetAll()"
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Reset all settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="close.emit()"
|
||||
(keydown.escape)="close.emit()"
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Close settings"
|
||||
title="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<section class="section">
|
||||
<button type="button" (click)="toggleSection('filter')" class="section-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
<span>Filters</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform" [class.rotate-180]="!config()['collapse-filter']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (!config()['collapse-filter']) {
|
||||
<div class="section-content">
|
||||
<app-graph-filters-section
|
||||
[config]="config()"
|
||||
(configChange)="onConfigChange($event)">
|
||||
</app-graph-filters-section>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<button type="button" (click)="toggleSection('color-groups')" class="section-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
<span>Groups</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform" [class.rotate-180]="!config()['collapse-color-groups']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (!config()['collapse-color-groups']) {
|
||||
<div class="section-content">
|
||||
<app-graph-groups-section
|
||||
[config]="config()"
|
||||
(configChange)="onConfigChange($event)">
|
||||
</app-graph-groups-section>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<button type="button" (click)="toggleSection('display')" class="section-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<span>Display</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform" [class.rotate-180]="!config()['collapse-display']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (!config()['collapse-display']) {
|
||||
<div class="section-content">
|
||||
<app-graph-display-section
|
||||
[config]="config()"
|
||||
(configChange)="onConfigChange($event)"
|
||||
(animateRequested)="animateRequested.emit()">
|
||||
</app-graph-display-section>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<button type="button" (click)="toggleSection('forces')" class="section-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Forces</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform" [class.rotate-180]="!config()['collapse-forces']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (!config()['collapse-forces']) {
|
||||
<div class="section-content">
|
||||
<app-graph-forces-section
|
||||
[config]="config()"
|
||||
(configChange)="onConfigChange($event)">
|
||||
</app-graph-forces-section>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.settings-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1990;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background-color: white;
|
||||
box-shadow: -4px 0 6px -1px rgba(0, 0, 0, 0.1), -2px 0 4px -1px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 2001;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
:host-context(.dark) .panel-content {
|
||||
background-color: #1F2937;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
}
|
||||
|
||||
:host-context(.dark) .panel-header {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host-context(.dark) .section {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #F9FAFB;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background-color: #F3F4F6;
|
||||
}
|
||||
|
||||
:host-context(.dark) .section-header {
|
||||
background-color: #374151;
|
||||
color: #F3F4F6;
|
||||
}
|
||||
|
||||
:host-context(.dark) .section-header:hover {
|
||||
background-color: #4B5563;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
:host-context(.dark) .section-content {
|
||||
background-color: #1F2937;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.panel-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphSettingsPanelComponent {
|
||||
isOpen = input.required<boolean>();
|
||||
close = output<void>();
|
||||
animateRequested = output<void>();
|
||||
|
||||
private settingsService = inject(GraphSettingsService);
|
||||
|
||||
config = this.settingsService.config;
|
||||
|
||||
constructor() {
|
||||
// Listen to Escape key globally when panel is open
|
||||
effect(() => {
|
||||
if (this.isOpen()) {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.close.emit();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
onConfigChange(patch: Partial<GraphConfig>): void {
|
||||
this.settingsService.save(patch);
|
||||
}
|
||||
|
||||
toggleSection(section: 'filter' | 'color-groups' | 'display' | 'forces'): void {
|
||||
this.settingsService.toggleCollapse(section);
|
||||
}
|
||||
|
||||
onResetAll(): void {
|
||||
if (confirm('Reset all graph settings to defaults?')) {
|
||||
this.settingsService.resetToDefaults();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user