up the blokcing tasks
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import { Injectable, signal, inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
export interface HotkeyBinding {
|
||||
key: string;
|
||||
modifiers?: ('ctrl' | 'alt' | 'shift' | 'meta')[];
|
||||
description: string;
|
||||
action: string;
|
||||
category: 'navigation' | 'view' | 'selection' | 'action' | 'general';
|
||||
}
|
||||
|
||||
export interface AnalyticsEvent {
|
||||
name: string;
|
||||
category: string;
|
||||
properties?: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AccessibilityState {
|
||||
highContrast: boolean;
|
||||
reducedMotion: boolean;
|
||||
screenReaderMode: boolean;
|
||||
focusIndicatorsEnabled: boolean;
|
||||
announcements: string[];
|
||||
}
|
||||
|
||||
export const GRAPH_HOTKEYS: HotkeyBinding[] = [
|
||||
// Navigation
|
||||
{ key: 'ArrowUp', description: 'Pan up', action: 'pan-up', category: 'navigation' },
|
||||
{ key: 'ArrowDown', description: 'Pan down', action: 'pan-down', category: 'navigation' },
|
||||
{ key: 'ArrowLeft', description: 'Pan left', action: 'pan-left', category: 'navigation' },
|
||||
{ key: 'ArrowRight', description: 'Pan right', action: 'pan-right', category: 'navigation' },
|
||||
{ key: 'Tab', description: 'Navigate to next node', action: 'next-node', category: 'navigation' },
|
||||
{ key: 'Tab', modifiers: ['shift'], description: 'Navigate to previous node', action: 'prev-node', category: 'navigation' },
|
||||
|
||||
// View controls
|
||||
{ key: '+', description: 'Zoom in', action: 'zoom-in', category: 'view' },
|
||||
{ key: '-', description: 'Zoom out', action: 'zoom-out', category: 'view' },
|
||||
{ key: '0', description: 'Fit to view', action: 'fit-view', category: 'view' },
|
||||
{ key: 'r', description: 'Reset view', action: 'reset-view', category: 'view' },
|
||||
{ key: 'l', description: 'Layered layout', action: 'layout-layered', category: 'view' },
|
||||
{ key: 'c', description: 'Radial layout', action: 'layout-radial', category: 'view' },
|
||||
{ key: 'g', description: 'Canvas view', action: 'view-canvas', category: 'view' },
|
||||
{ key: 'h', description: 'Hierarchy view', action: 'view-hierarchy', category: 'view' },
|
||||
{ key: 't', description: 'Table view', action: 'view-table', category: 'view' },
|
||||
|
||||
// Selection
|
||||
{ key: 'Enter', description: 'Select focused node', action: 'select-node', category: 'selection' },
|
||||
{ key: ' ', description: 'Select focused node', action: 'select-node', category: 'selection' },
|
||||
{ key: 'Escape', description: 'Clear selection', action: 'clear-selection', category: 'selection' },
|
||||
|
||||
// Actions
|
||||
{ key: 'f', description: 'Focus search', action: 'focus-search', category: 'action' },
|
||||
{ key: 'e', description: 'Export graph', action: 'export', category: 'action' },
|
||||
{ key: '.', description: 'Open node menu', action: 'node-menu', category: 'action' },
|
||||
{ key: 'x', description: 'Create exception', action: 'create-exception', category: 'action' },
|
||||
|
||||
// General
|
||||
{ key: '?', description: 'Show keyboard shortcuts', action: 'show-help', category: 'general' },
|
||||
{ key: 'Escape', description: 'Close dialogs', action: 'close-dialogs', category: 'general' },
|
||||
];
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class GraphAccessibilityService {
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly isBrowser = isPlatformBrowser(this.platformId);
|
||||
|
||||
// Accessibility state
|
||||
readonly accessibilityState = signal<AccessibilityState>({
|
||||
highContrast: false,
|
||||
reducedMotion: false,
|
||||
screenReaderMode: false,
|
||||
focusIndicatorsEnabled: true,
|
||||
announcements: [],
|
||||
});
|
||||
|
||||
// Analytics buffer
|
||||
private readonly analyticsBuffer: AnalyticsEvent[] = [];
|
||||
private readonly maxBufferSize = 100;
|
||||
private flushInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Hotkey help visibility
|
||||
readonly showHotkeyHelp = signal(false);
|
||||
|
||||
constructor() {
|
||||
if (this.isBrowser) {
|
||||
this.initializeAccessibilityState();
|
||||
this.startAnalyticsFlush();
|
||||
}
|
||||
}
|
||||
|
||||
private initializeAccessibilityState(): void {
|
||||
// Check for reduced motion preference
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// Check for high contrast preference
|
||||
const highContrast = window.matchMedia('(prefers-contrast: more)').matches;
|
||||
|
||||
this.accessibilityState.set({
|
||||
...this.accessibilityState(),
|
||||
reducedMotion,
|
||||
highContrast,
|
||||
});
|
||||
|
||||
// Listen for preference changes
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
|
||||
this.accessibilityState.set({
|
||||
...this.accessibilityState(),
|
||||
reducedMotion: e.matches,
|
||||
});
|
||||
});
|
||||
|
||||
window.matchMedia('(prefers-contrast: more)').addEventListener('change', (e) => {
|
||||
this.accessibilityState.set({
|
||||
...this.accessibilityState(),
|
||||
highContrast: e.matches,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private startAnalyticsFlush(): void {
|
||||
// Flush analytics every 30 seconds
|
||||
this.flushInterval = setInterval(() => {
|
||||
this.flushAnalytics();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Accessibility features
|
||||
setHighContrast(enabled: boolean): void {
|
||||
this.accessibilityState.set({
|
||||
...this.accessibilityState(),
|
||||
highContrast: enabled,
|
||||
});
|
||||
this.trackEvent('accessibility_setting_changed', 'accessibility', { setting: 'highContrast', value: enabled });
|
||||
}
|
||||
|
||||
setReducedMotion(enabled: boolean): void {
|
||||
this.accessibilityState.set({
|
||||
...this.accessibilityState(),
|
||||
reducedMotion: enabled,
|
||||
});
|
||||
this.trackEvent('accessibility_setting_changed', 'accessibility', { setting: 'reducedMotion', value: enabled });
|
||||
}
|
||||
|
||||
setScreenReaderMode(enabled: boolean): void {
|
||||
this.accessibilityState.set({
|
||||
...this.accessibilityState(),
|
||||
screenReaderMode: enabled,
|
||||
});
|
||||
this.trackEvent('accessibility_setting_changed', 'accessibility', { setting: 'screenReaderMode', value: enabled });
|
||||
}
|
||||
|
||||
setFocusIndicators(enabled: boolean): void {
|
||||
this.accessibilityState.set({
|
||||
...this.accessibilityState(),
|
||||
focusIndicatorsEnabled: enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// Screen reader announcements
|
||||
announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
|
||||
if (!this.isBrowser) return;
|
||||
|
||||
// Add to announcements list for live region
|
||||
const current = this.accessibilityState();
|
||||
this.accessibilityState.set({
|
||||
...current,
|
||||
announcements: [...current.announcements.slice(-4), message],
|
||||
});
|
||||
|
||||
// Also create a live region announcement if needed
|
||||
this.createLiveRegionAnnouncement(message, priority);
|
||||
}
|
||||
|
||||
private createLiveRegionAnnouncement(message: string, priority: 'polite' | 'assertive'): void {
|
||||
const existingRegion = document.getElementById('graph-live-region');
|
||||
if (existingRegion) {
|
||||
existingRegion.textContent = message;
|
||||
return;
|
||||
}
|
||||
|
||||
const region = document.createElement('div');
|
||||
region.id = 'graph-live-region';
|
||||
region.setAttribute('role', 'status');
|
||||
region.setAttribute('aria-live', priority);
|
||||
region.setAttribute('aria-atomic', 'true');
|
||||
region.className = 'sr-only';
|
||||
region.style.cssText = 'position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;';
|
||||
region.textContent = message;
|
||||
|
||||
document.body.appendChild(region);
|
||||
|
||||
// Clear after announcement
|
||||
setTimeout(() => {
|
||||
region.textContent = '';
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
clearAnnouncements(): void {
|
||||
this.accessibilityState.set({
|
||||
...this.accessibilityState(),
|
||||
announcements: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Hotkey management
|
||||
getHotkeys(): HotkeyBinding[] {
|
||||
return GRAPH_HOTKEYS;
|
||||
}
|
||||
|
||||
getHotkeysByCategory(category: HotkeyBinding['category']): HotkeyBinding[] {
|
||||
return GRAPH_HOTKEYS.filter(h => h.category === category);
|
||||
}
|
||||
|
||||
formatHotkey(binding: HotkeyBinding): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (binding.modifiers?.includes('ctrl')) parts.push('Ctrl');
|
||||
if (binding.modifiers?.includes('alt')) parts.push('Alt');
|
||||
if (binding.modifiers?.includes('shift')) parts.push('Shift');
|
||||
if (binding.modifiers?.includes('meta')) parts.push('Cmd');
|
||||
|
||||
parts.push(this.formatKey(binding.key));
|
||||
|
||||
return parts.join(' + ');
|
||||
}
|
||||
|
||||
private formatKey(key: string): string {
|
||||
const keyMap: Record<string, string> = {
|
||||
'ArrowUp': '↑',
|
||||
'ArrowDown': '↓',
|
||||
'ArrowLeft': '←',
|
||||
'ArrowRight': '→',
|
||||
'Enter': '⏎',
|
||||
'Escape': 'Esc',
|
||||
'Tab': 'Tab',
|
||||
' ': 'Space',
|
||||
};
|
||||
return keyMap[key] || key.toUpperCase();
|
||||
}
|
||||
|
||||
toggleHotkeyHelp(): void {
|
||||
this.showHotkeyHelp.set(!this.showHotkeyHelp());
|
||||
this.trackEvent('hotkey_help_toggled', 'ui', { visible: this.showHotkeyHelp() });
|
||||
}
|
||||
|
||||
// Analytics
|
||||
trackEvent(name: string, category: string, properties?: Record<string, unknown>): void {
|
||||
const event: AnalyticsEvent = {
|
||||
name,
|
||||
category,
|
||||
properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.analyticsBuffer.push(event);
|
||||
|
||||
// Flush if buffer is full
|
||||
if (this.analyticsBuffer.length >= this.maxBufferSize) {
|
||||
this.flushAnalytics();
|
||||
}
|
||||
}
|
||||
|
||||
trackNodeSelection(nodeId: string, nodeType: string): void {
|
||||
this.trackEvent('node_selected', 'graph', { nodeId, nodeType });
|
||||
}
|
||||
|
||||
trackLayoutChange(layout: string): void {
|
||||
this.trackEvent('layout_changed', 'graph', { layout });
|
||||
}
|
||||
|
||||
trackZoom(level: number, method: 'button' | 'scroll' | 'keyboard'): void {
|
||||
this.trackEvent('zoom_changed', 'graph', { level, method });
|
||||
}
|
||||
|
||||
trackOverlayToggle(overlay: string, enabled: boolean): void {
|
||||
this.trackEvent('overlay_toggled', 'graph', { overlay, enabled });
|
||||
}
|
||||
|
||||
trackFilterChange(filters: Record<string, unknown>): void {
|
||||
this.trackEvent('filter_changed', 'graph', filters);
|
||||
}
|
||||
|
||||
trackExport(format: string): void {
|
||||
this.trackEvent('graph_exported', 'graph', { format });
|
||||
}
|
||||
|
||||
trackPerformance(metric: string, value: number): void {
|
||||
this.trackEvent('performance_metric', 'performance', { metric, value });
|
||||
}
|
||||
|
||||
private flushAnalytics(): void {
|
||||
if (this.analyticsBuffer.length === 0) return;
|
||||
|
||||
// In production, would send to analytics endpoint
|
||||
// For now, just log to console in development
|
||||
if (typeof window !== 'undefined' && (window as unknown as { __DEV__?: boolean }).__DEV__) {
|
||||
console.log('[Analytics]', this.analyticsBuffer.length, 'events', this.analyticsBuffer);
|
||||
}
|
||||
|
||||
// Clear buffer
|
||||
this.analyticsBuffer.length = 0;
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
destroy(): void {
|
||||
if (this.flushInterval) {
|
||||
clearInterval(this.flushInterval);
|
||||
}
|
||||
this.flushAnalytics();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,11 +32,21 @@
|
||||
<div class="graph-explorer__toolbar">
|
||||
<!-- View Toggle -->
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle__btn"
|
||||
[class.view-toggle__btn--active]="viewMode() === 'canvas'"
|
||||
(click)="setViewMode('canvas')"
|
||||
title="Interactive graph canvas (G)"
|
||||
>
|
||||
Canvas
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle__btn"
|
||||
[class.view-toggle__btn--active]="viewMode() === 'hierarchy'"
|
||||
(click)="setViewMode('hierarchy')"
|
||||
title="Layered hierarchy view (H)"
|
||||
>
|
||||
Hierarchy
|
||||
</button>
|
||||
@@ -45,8 +55,9 @@
|
||||
class="view-toggle__btn"
|
||||
[class.view-toggle__btn--active]="viewMode() === 'flat'"
|
||||
(click)="setViewMode('flat')"
|
||||
title="Flat table view (T)"
|
||||
>
|
||||
Flat List
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -94,6 +105,30 @@
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="graph-explorer__content" *ngIf="!loading()">
|
||||
<!-- Canvas View -->
|
||||
<div class="canvas-view" *ngIf="viewMode() === 'canvas'">
|
||||
<div class="canvas-view__main">
|
||||
<app-graph-canvas
|
||||
[nodes]="canvasNodes()"
|
||||
[edges]="canvasEdges()"
|
||||
[selectedNodeId]="selectedNodeId()"
|
||||
(nodeSelected)="selectNode($event)"
|
||||
(canvasClicked)="clearSelection()"
|
||||
></app-graph-canvas>
|
||||
</div>
|
||||
<div class="canvas-view__sidebar">
|
||||
<app-graph-overlays
|
||||
[nodeIds]="nodeIds()"
|
||||
[selectedNodeId]="selectedNodeId()"
|
||||
(overlayStateChange)="onOverlayStateChange($event)"
|
||||
(simulationModeChange)="onSimulationModeChange($event)"
|
||||
(pathViewChange)="onPathViewChange($event)"
|
||||
(timeTravelChange)="onTimeTravelChange($event)"
|
||||
(showDiffRequest)="onShowDiffRequest($event)"
|
||||
></app-graph-overlays>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hierarchy View -->
|
||||
<div class="hierarchy-view" *ngIf="viewMode() === 'hierarchy'">
|
||||
<!-- Assets Layer -->
|
||||
|
||||
@@ -177,6 +177,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Canvas View
|
||||
.canvas-view {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.canvas-view__main {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.canvas-view__sidebar {
|
||||
max-height: 700px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.canvas-view {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.canvas-view__sidebar {
|
||||
order: -1;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Hierarchy View
|
||||
.hierarchy-view {
|
||||
display: flex;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
MockAuthService,
|
||||
StellaOpsScopes,
|
||||
} from '../../core/auth';
|
||||
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
|
||||
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
|
||||
|
||||
export interface GraphNode {
|
||||
readonly id: string;
|
||||
@@ -75,12 +78,12 @@ const MOCK_EDGES: GraphEdge[] = [
|
||||
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
|
||||
];
|
||||
|
||||
type ViewMode = 'hierarchy' | 'flat';
|
||||
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
|
||||
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
||||
templateUrl: './graph-explorer.component.html',
|
||||
styleUrls: ['./graph-explorer.component.scss'],
|
||||
@@ -106,7 +109,7 @@ export class GraphExplorerComponent implements OnInit {
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
readonly viewMode = signal<ViewMode>('hierarchy');
|
||||
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
|
||||
|
||||
// Data
|
||||
readonly nodes = signal<GraphNode[]>([]);
|
||||
@@ -126,6 +129,15 @@ export class GraphExplorerComponent implements OnInit {
|
||||
readonly showAssets = signal(true);
|
||||
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
|
||||
|
||||
// Overlay state
|
||||
readonly overlayState = signal<GraphOverlayState | null>(null);
|
||||
readonly simulationMode = signal(false);
|
||||
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
|
||||
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
|
||||
|
||||
// Computed: node IDs for overlay component
|
||||
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
|
||||
|
||||
// Computed: filtered nodes
|
||||
readonly filteredNodes = computed(() => {
|
||||
let items = [...this.nodes()];
|
||||
@@ -145,6 +157,32 @@ export class GraphExplorerComponent implements OnInit {
|
||||
return items;
|
||||
});
|
||||
|
||||
// Computed: canvas nodes (filtered for canvas view)
|
||||
readonly canvasNodes = computed<CanvasNode[]>(() => {
|
||||
return this.filteredNodes().map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
name: n.name,
|
||||
purl: n.purl,
|
||||
version: n.version,
|
||||
severity: n.severity,
|
||||
vulnCount: n.vulnCount,
|
||||
hasException: n.hasException,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: canvas edges (filtered based on visible nodes)
|
||||
readonly canvasEdges = computed<CanvasEdge[]>(() => {
|
||||
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
|
||||
return this.edges()
|
||||
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: assets
|
||||
readonly assets = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'asset');
|
||||
@@ -402,6 +440,34 @@ export class GraphExplorerComponent implements OnInit {
|
||||
|
||||
trackByNode = (_: number, item: GraphNode) => item.id;
|
||||
|
||||
// Overlay handlers
|
||||
onOverlayStateChange(state: GraphOverlayState): void {
|
||||
this.overlayState.set(state);
|
||||
}
|
||||
|
||||
onSimulationModeChange(enabled: boolean): void {
|
||||
this.simulationMode.set(enabled);
|
||||
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
|
||||
}
|
||||
|
||||
onPathViewChange(state: { enabled: boolean; type: string }): void {
|
||||
this.pathViewState.set(state);
|
||||
if (state.enabled) {
|
||||
this.showMessage(`Path view enabled: ${state.type}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
|
||||
this.timeTravelState.set(state);
|
||||
if (state.enabled && state.snapshot !== 'current') {
|
||||
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onShowDiffRequest(snapshot: string): void {
|
||||
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
|
||||
}
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
Output,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { GraphAccessibilityService, HotkeyBinding } from './graph-accessibility.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-hotkey-help',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
@if (visible) {
|
||||
<div class="hotkey-help-backdrop" (click)="close.emit()"></div>
|
||||
<div
|
||||
class="hotkey-help"
|
||||
role="dialog"
|
||||
aria-labelledby="hotkey-help-title"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="hotkey-help__header">
|
||||
<h2 id="hotkey-help-title" class="hotkey-help__title">Keyboard Shortcuts</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="hotkey-help__close"
|
||||
(click)="close.emit()"
|
||||
aria-label="Close keyboard shortcuts"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hotkey-help__content">
|
||||
<!-- Navigation -->
|
||||
<section class="hotkey-section">
|
||||
<h3 class="hotkey-section__title">Navigation</h3>
|
||||
<div class="hotkey-list">
|
||||
@for (hotkey of navigationHotkeys(); track hotkey.action) {
|
||||
<div class="hotkey-item">
|
||||
<kbd class="hotkey-key">{{ formatHotkey(hotkey) }}</kbd>
|
||||
<span class="hotkey-description">{{ hotkey.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- View -->
|
||||
<section class="hotkey-section">
|
||||
<h3 class="hotkey-section__title">View Controls</h3>
|
||||
<div class="hotkey-list">
|
||||
@for (hotkey of viewHotkeys(); track hotkey.action) {
|
||||
<div class="hotkey-item">
|
||||
<kbd class="hotkey-key">{{ formatHotkey(hotkey) }}</kbd>
|
||||
<span class="hotkey-description">{{ hotkey.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Selection -->
|
||||
<section class="hotkey-section">
|
||||
<h3 class="hotkey-section__title">Selection</h3>
|
||||
<div class="hotkey-list">
|
||||
@for (hotkey of selectionHotkeys(); track hotkey.action) {
|
||||
<div class="hotkey-item">
|
||||
<kbd class="hotkey-key">{{ formatHotkey(hotkey) }}</kbd>
|
||||
<span class="hotkey-description">{{ hotkey.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<section class="hotkey-section">
|
||||
<h3 class="hotkey-section__title">Actions</h3>
|
||||
<div class="hotkey-list">
|
||||
@for (hotkey of actionHotkeys(); track hotkey.action) {
|
||||
<div class="hotkey-item">
|
||||
<kbd class="hotkey-key">{{ formatHotkey(hotkey) }}</kbd>
|
||||
<span class="hotkey-description">{{ hotkey.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="hotkey-help__footer">
|
||||
<p class="hotkey-help__hint">Press <kbd>?</kbd> to toggle this dialog</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.hotkey-help-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.hotkey-help {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 90%;
|
||||
max-width: 640px;
|
||||
max-height: 80vh;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||||
z-index: 201;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hotkey-help__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.hotkey-help__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.hotkey-help__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #4f46e5;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hotkey-help__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.hotkey-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hotkey-section__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.hotkey-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.hotkey-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
.hotkey-key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 24px;
|
||||
padding: 0 0.5rem;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.6875rem;
|
||||
color: #475569;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.hotkey-description {
|
||||
font-size: 0.8125rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.hotkey-help__footer {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.hotkey-help__hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
padding: 0 0.375rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.625rem;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hotkey-help__content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GraphHotkeyHelpComponent {
|
||||
private readonly accessibilityService = inject(GraphAccessibilityService);
|
||||
|
||||
@Input() visible = false;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
readonly navigationHotkeys = computed(() =>
|
||||
this.accessibilityService.getHotkeysByCategory('navigation')
|
||||
);
|
||||
|
||||
readonly viewHotkeys = computed(() =>
|
||||
this.accessibilityService.getHotkeysByCategory('view')
|
||||
);
|
||||
|
||||
readonly selectionHotkeys = computed(() =>
|
||||
this.accessibilityService.getHotkeysByCategory('selection')
|
||||
);
|
||||
|
||||
readonly actionHotkeys = computed(() =>
|
||||
this.accessibilityService.getHotkeysByCategory('action')
|
||||
);
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (this.visible) {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
|
||||
formatHotkey(binding: HotkeyBinding): string {
|
||||
return this.accessibilityService.formatHotkey(binding);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
8
src/Web/StellaOps.Web/src/app/features/graph/index.ts
Normal file
8
src/Web/StellaOps.Web/src/app/features/graph/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Graph Explorer feature module exports
|
||||
export * from './graph-explorer.component';
|
||||
export * from './graph-canvas.component';
|
||||
export * from './graph-overlays.component';
|
||||
export * from './graph-filters.component';
|
||||
export * from './graph-side-panels.component';
|
||||
export * from './graph-accessibility.service';
|
||||
export * from './graph-hotkey-help.component';
|
||||
Reference in New Issue
Block a user