constellation-analyzer/src/stores/timelineStore.ts
Jan-Henrik Bruhn 28f8224284 feat: add timeline system for multi-state constellation analysis
Implements a comprehensive timeline system that enables documents to contain
multiple constellation states with branching timelines. This allows users to
create different versions of their analysis for temporal evolution, alternative
scenarios, or what-if analysis.

Core Features:
- Timeline store managing multiple states per document with branching structure
- Visual timeline panel with React Flow-based state graph visualization
- State management: create, switch, rename, duplicate (parallel/series), delete
- Per-state undo/redo history (max 50 actions per state)
- Context menu for timeline node operations
- Collapsible timeline panel (always visible, moved toolbar to panel header)

Architecture Changes:
- Document structure: removed top-level graph field, states now only in timeline
- Global types: nodeTypes and edgeTypes are now global per document, not per state
- State graphs: only contain nodes and edges, types inherited from document
- Persistence: full timeline serialization/deserialization with all states
- History system: converted from document-level to per-state independent stacks

Timeline Components:
- TimelineView: main timeline visualization with state nodes and edges
- BottomPanel: collapsible container with timeline controls in header
- StateNode: custom node component showing state info and active indicator
- CreateStateDialog: dialog for creating new timeline states
- RenameStateDialog: dialog for renaming existing states
- Context menu: right-click operations (rename, duplicate parallel/series, delete)

Document Management:
- Documents always have timeline (initialized with root state on creation)
- Timeline persisted with document in localStorage
- Export/import includes complete timeline with all states
- Migration support for legacy single-state documents

Store Updates:
- timelineStore: manages timelines, states, and timeline operations
- historyStore: per-state history with independent undo/redo stacks
- workspaceStore: saves/loads timeline data, handles global types
- panelStore: added timeline panel visibility state
- useActiveDocument: syncs timeline state with graph editor

Context Menu Improvements:
- Smart viewport edge detection to prevent overflow
- Click-outside detection for React Flow panes
- Consistent styling across application

Files Added:
- src/types/timeline.ts - Timeline type definitions
- src/stores/timelineStore.ts - Timeline state management
- src/components/Timeline/TimelineView.tsx - Main timeline component
- src/components/Timeline/BottomPanel.tsx - Timeline panel container
- src/components/Timeline/StateNode.tsx - State node visualization
- src/components/Timeline/CreateStateDialog.tsx - State creation dialog
- src/components/Timeline/RenameStateDialog.tsx - State rename dialog

Files Removed:
- src/stores/persistence/middleware.ts - Obsolete persistence middleware

Documentation:
- Added comprehensive timeline feature documentation
- Implementation checklists and quick reference guides
- Temporal analysis concepts and UX guidelines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:00:34 +02:00

568 lines
16 KiB
TypeScript

import { create } from "zustand";
import type {
Timeline,
ConstellationState,
StateId,
TimelineActions,
} from "../types/timeline";
import type { Actor, Relation } from "../types";
import type { SerializedActor, SerializedRelation } from "./persistence/types";
import { useGraphStore } from "./graphStore";
import { useWorkspaceStore } from "./workspaceStore";
import { useToastStore } from "./toastStore";
/**
* Timeline Store
*
* Manages multiple constellation states within a document.
* Each document can have its own timeline with branching states.
*/
interface TimelineStore {
// Map of documentId -> Timeline
timelines: Map<string, Timeline>;
// Currently active document's timeline
activeDocumentId: string | null;
}
// Generate unique state ID
function generateStateId(): StateId {
return `state_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
export const useTimelineStore = create<TimelineStore & TimelineActions>(
(set, get) => ({
timelines: new Map(),
activeDocumentId: null,
initializeTimeline: (
documentId: string,
initialGraph: ConstellationState["graph"],
) => {
const state = get();
// Don't re-initialize if already exists
if (state.timelines.has(documentId)) {
console.warn(`Timeline already initialized for document ${documentId}`);
return;
}
const rootStateId = generateStateId();
const now = new Date().toISOString();
const rootState: ConstellationState = {
id: rootStateId,
label: "Initial State",
parentStateId: undefined,
graph: JSON.parse(JSON.stringify(initialGraph)), // Deep copy
createdAt: now,
updatedAt: now,
};
const timeline: Timeline = {
states: new Map([[rootStateId, rootState]]),
currentStateId: rootStateId,
rootStateId: rootStateId,
};
set((state) => {
const newTimelines = new Map(state.timelines);
newTimelines.set(documentId, timeline);
return {
timelines: newTimelines,
activeDocumentId: documentId,
};
});
console.log(`Timeline initialized for document ${documentId}`);
},
loadTimeline: (documentId: string, timeline: Timeline) => {
set((state) => {
const newTimelines = new Map(state.timelines);
// Convert plain objects back to Maps if needed
const statesMap =
timeline.states instanceof Map
? timeline.states
: new Map(
Object.entries(timeline.states) as [
string,
ConstellationState,
][],
);
newTimelines.set(documentId, {
...timeline,
states: statesMap,
});
return {
timelines: newTimelines,
activeDocumentId: documentId,
};
});
},
createState: (
label: string,
description?: string,
cloneFromCurrent: boolean = true,
) => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) {
console.error("No active document");
useToastStore.getState().showToast("No active document", "error");
return "";
}
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) {
console.error("No timeline for active document");
useToastStore.getState().showToast("Timeline not initialized", "error");
return "";
}
const newStateId = generateStateId();
const now = new Date().toISOString();
// Get graph to clone (nodes and edges only, types are global)
let graphToClone: ConstellationState["graph"];
if (cloneFromCurrent) {
// Clone from current graph state (nodes and edges only)
const graphStore = useGraphStore.getState();
graphToClone = {
nodes: graphStore.nodes as unknown as SerializedActor[],
edges: graphStore.edges as unknown as SerializedRelation[],
};
} else {
// Empty graph
graphToClone = {
nodes: [],
edges: [],
};
}
const newState: ConstellationState = {
id: newStateId,
label,
description,
parentStateId: timeline.currentStateId, // Branch from current
graph: JSON.parse(JSON.stringify(graphToClone)), // Deep copy
createdAt: now,
updatedAt: now,
};
set((state) => {
const newTimelines = new Map(state.timelines);
const timeline = newTimelines.get(activeDocumentId)!;
const newStates = new Map(timeline.states);
newStates.set(newStateId, newState);
newTimelines.set(activeDocumentId, {
...timeline,
states: newStates,
currentStateId: newStateId, // Switch to new state
});
return { timelines: newTimelines };
});
// Load new state's graph into graph store
// Types come from the document and are already in the graph store
useGraphStore.setState({
nodes: newState.graph.nodes,
edges: newState.graph.edges,
// nodeTypes and edgeTypes remain unchanged (they're global per document)
});
// Mark document as dirty
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId);
useToastStore.getState().showToast(`State "${label}" created`, "success");
return newStateId;
},
switchToState: (stateId: StateId) => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) {
console.error("No active document");
return;
}
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) {
console.error("No timeline for active document");
return;
}
const targetState = timeline.states.get(stateId);
if (!targetState) {
console.error(`State ${stateId} not found`);
useToastStore.getState().showToast("State not found", "error");
return;
}
// Save current graph state to current state before switching (nodes and edges only)
const currentState = timeline.states.get(timeline.currentStateId);
if (currentState) {
const graphStore = useGraphStore.getState();
currentState.graph = {
nodes: graphStore.nodes as unknown as SerializedActor[],
edges: graphStore.edges as unknown as SerializedRelation[],
};
currentState.updatedAt = new Date().toISOString();
}
// Switch to target state
set((state) => {
const newTimelines = new Map(state.timelines);
const timeline = newTimelines.get(activeDocumentId)!;
newTimelines.set(activeDocumentId, {
...timeline,
currentStateId: stateId,
});
return { timelines: newTimelines };
});
// Load target state's graph (nodes and edges only, types are global)
useGraphStore.setState({
nodes: targetState.graph.nodes as unknown as Actor[],
edges: targetState.graph.edges as unknown as Relation[],
// nodeTypes and edgeTypes remain unchanged (they're global per document)
});
},
updateState: (
stateId: StateId,
updates: Partial<
Pick<ConstellationState, "label" | "description" | "metadata">
>,
) => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) return;
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) return;
const stateToUpdate = timeline.states.get(stateId);
if (!stateToUpdate) {
console.error(`State ${stateId} not found`);
return;
}
set((state) => {
const newTimelines = new Map(state.timelines);
const timeline = newTimelines.get(activeDocumentId)!;
const newStates = new Map(timeline.states);
const updatedState = {
...stateToUpdate,
...updates,
metadata: updates.metadata
? { ...stateToUpdate.metadata, ...updates.metadata }
: stateToUpdate.metadata,
updatedAt: new Date().toISOString(),
};
newStates.set(stateId, updatedState);
newTimelines.set(activeDocumentId, {
...timeline,
states: newStates,
});
return { timelines: newTimelines };
});
// Mark document as dirty
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId);
},
deleteState: (stateId: StateId) => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) return false;
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) return false;
// Can't delete root state
if (stateId === timeline.rootStateId) {
useToastStore.getState().showToast("Cannot delete root state", "error");
return false;
}
// Can't delete current state
if (stateId === timeline.currentStateId) {
useToastStore
.getState()
.showToast(
"Cannot delete current state. Switch to another state first.",
"error",
);
return false;
}
// Check if state has children
const children = get().getChildStates(stateId);
if (children.length > 0) {
const confirmed = window.confirm(
`This state has ${children.length} child state(s). Delete anyway? Children will be orphaned.`,
);
if (!confirmed) return false;
}
const stateToDelete = timeline.states.get(stateId);
const stateName = stateToDelete?.label || "Unknown";
set((state) => {
const newTimelines = new Map(state.timelines);
const timeline = newTimelines.get(activeDocumentId)!;
const newStates = new Map(timeline.states);
newStates.delete(stateId);
newTimelines.set(activeDocumentId, {
...timeline,
states: newStates,
});
return { timelines: newTimelines };
});
// Mark document as dirty
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId);
useToastStore
.getState()
.showToast(`State "${stateName}" deleted`, "info");
return true;
},
duplicateState: (stateId: StateId, newLabel?: string) => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) {
console.error("No active document");
return "";
}
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) {
console.error("No timeline for active document");
return "";
}
const stateToDuplicate = timeline.states.get(stateId);
if (!stateToDuplicate) {
console.error(`State ${stateId} not found`);
useToastStore.getState().showToast("State not found", "error");
return "";
}
const newStateId = generateStateId();
const now = new Date().toISOString();
const label = newLabel || `${stateToDuplicate.label} (Copy)`;
const duplicatedState: ConstellationState = {
...stateToDuplicate,
id: newStateId,
label,
parentStateId: stateToDuplicate.parentStateId, // Same parent as original (parallel)
graph: JSON.parse(JSON.stringify(stateToDuplicate.graph)), // Deep copy
createdAt: now,
updatedAt: now,
};
set((state) => {
const newTimelines = new Map(state.timelines);
const timeline = newTimelines.get(activeDocumentId)!;
const newStates = new Map(timeline.states);
newStates.set(newStateId, duplicatedState);
newTimelines.set(activeDocumentId, {
...timeline,
states: newStates,
});
return { timelines: newTimelines };
});
// Mark document as dirty
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId);
useToastStore
.getState()
.showToast(`State "${label}" created`, "success");
return newStateId;
},
duplicateStateAsChild: (stateId: StateId, newLabel?: string) => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) {
console.error("No active document");
return "";
}
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) {
console.error("No timeline for active document");
return "";
}
const stateToDuplicate = timeline.states.get(stateId);
if (!stateToDuplicate) {
console.error(`State ${stateId} not found`);
useToastStore.getState().showToast("State not found", "error");
return "";
}
const newStateId = generateStateId();
const now = new Date().toISOString();
const label = newLabel || `${stateToDuplicate.label} (Copy)`;
const duplicatedState: ConstellationState = {
...stateToDuplicate,
id: newStateId,
label,
parentStateId: stateId, // Original state becomes parent (series)
graph: JSON.parse(JSON.stringify(stateToDuplicate.graph)), // Deep copy
createdAt: now,
updatedAt: now,
};
set((state) => {
const newTimelines = new Map(state.timelines);
const timeline = newTimelines.get(activeDocumentId)!;
const newStates = new Map(timeline.states);
newStates.set(newStateId, duplicatedState);
newTimelines.set(activeDocumentId, {
...timeline,
states: newStates,
});
return { timelines: newTimelines };
});
// Mark document as dirty
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId);
useToastStore
.getState()
.showToast(`State "${label}" created`, "success");
return newStateId;
},
getState: (stateId: StateId) => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) return null;
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) return null;
return timeline.states.get(stateId) || null;
},
getChildStates: (stateId: StateId) => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) return [];
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) return [];
const children: ConstellationState[] = [];
timeline.states.forEach((state) => {
if (state.parentStateId === stateId) {
children.push(state);
}
});
return children;
},
getAllStates: () => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) return [];
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) return [];
return Array.from(timeline.states.values());
},
saveCurrentGraph: (graph: ConstellationState["graph"]) => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) return;
const timeline = state.timelines.get(activeDocumentId);
if (!timeline) return;
const currentState = timeline.states.get(timeline.currentStateId);
if (!currentState) return;
set((state) => {
const newTimelines = new Map(state.timelines);
const timeline = newTimelines.get(activeDocumentId)!;
const newStates = new Map(timeline.states);
const updatedState = {
...currentState,
graph: JSON.parse(JSON.stringify(graph)), // Deep copy
updatedAt: new Date().toISOString(),
};
newStates.set(timeline.currentStateId, updatedState);
newTimelines.set(activeDocumentId, {
...timeline,
states: newStates,
});
return { timelines: newTimelines };
});
},
clearTimeline: () => {
const state = get();
const { activeDocumentId } = state;
if (!activeDocumentId) return;
set((state) => {
const newTimelines = new Map(state.timelines);
newTimelines.delete(activeDocumentId);
return { timelines: newTimelines };
});
},
}),
);