Skip to content

Commit 3f2d162

Browse files
committed
feat: enhance ConstellationTimeline with organic layout and improved interactions
- Refactored layout calculations to support a constellation-like organic positioning of nodes. - Updated `computeIndexToOffset` to return 2D positions for nodes, enhancing visual representation. - Adjusted baseline positioning from 80% to 70% for better layout aesthetics. - Improved `clampTransform` to allow for dynamic horizontal centering during zoom interactions. - Enhanced `useTimelineInteractions` to utilize new layout configurations for better user experience. - Updated rendering logic to reflect changes in node positioning and interaction handling.
1 parent 38bb3a8 commit 3f2d162

File tree

5 files changed

+384
-156
lines changed

5 files changed

+384
-156
lines changed

web/src/components/timeline/ConstellationTimeline.tsx

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ControlsBar } from './molecules/ControlsBar';
66
import { DetailPanel } from './molecules/DetailPanel';
77
import { Tooltip } from './molecules/Tooltip';
88
import type { Transform, LayoutPoint } from './types';
9-
import { createLayoutConfig, computeIndexToOffset } from './engine/layout';
9+
import { createLayoutConfig, computeIndexToOffset, type LayoutConfig } from './engine/layout';
1010
import { easeInOutQuad, clampTransform } from './engine/transform';
1111
import { renderTimeline } from './engine/renderer';
1212
import { useTimelineInteractions } from './hooks/useTimelineInteractions';
@@ -47,6 +47,7 @@ export const ConstellationTimeline: React.FC<{ height?: number; query?: string;
4747
const animate = (ts:number)=>{
4848
const p = Math.min(1, (ts - startTime)/duration);
4949
const v = startOffsetX + (desiredOffsetX - startOffsetX) * easeInOutQuad(p);
50+
// Temporarily set offsetX for animation, but allow clampTransform to handle centering during zoom
5051
transformRef.current = { ...transformRef.current, offsetX: v };
5152
setTick(vv=>vv+1);
5253
if (p < 1) animRafRef.current = requestAnimationFrame(animate);
@@ -110,17 +111,20 @@ export const ConstellationTimeline: React.FC<{ height?: number; query?: string;
110111
// Update animation time for meteorite
111112
animationTimeRef.current = performance.now();
112113

113-
// Horizontal time axis (baseline at 80% = 20% do fundo), vertical branches to avoid collisions per same year
114+
// Constellation layout: organic 2D positioning
114115
const layoutConfig = createLayoutConfig(dataset.nodes, canvas.clientWidth, height, branchSpacing);
115-
const indexToOffset = computeIndexToOffset(dataset.nodes, layoutConfig);
116+
const indexToPosition = computeIndexToOffset(dataset.nodes, layoutConfig); // Returns Map<number, {x, y}>
117+
118+
// Don't force center on render - only during zoom interactions
119+
// This allows centerOnYear animation to work smoothly
116120

117121
renderTimeline({
118122
ctx,
119123
canvasWidth: canvas.clientWidth,
120124
height,
121125
transform: transformRef.current,
122126
layoutConfig,
123-
indexToOffset,
127+
indexToPosition,
124128
nodes: dataset.nodes,
125129
edges: dataset.edges,
126130
query,
@@ -174,15 +178,18 @@ export const ConstellationTimeline: React.FC<{ height?: number; query?: string;
174178

175179
// Recompute layout with new dimensions
176180
const layoutConfig = createLayoutConfig(dataset.nodes, canvas.clientWidth, height, branchSpacing);
177-
const indexToOffset = computeIndexToOffset(dataset.nodes, layoutConfig);
181+
const indexToPosition = computeIndexToOffset(dataset.nodes, layoutConfig);
178182

179-
// Re-clamp transform to new bounds (baseline always at 80% = 20% do fundo)
183+
// Re-clamp transform to new bounds (baseline always at 70% = 30% do fundo)
184+
// Force center horizontally on resize to maintain view
180185
transformRef.current = clampTransform(
181186
transformRef.current,
182187
canvas,
183188
height,
184189
dataset.nodes,
185-
branchSpacing
190+
branchSpacing,
191+
layoutConfig,
192+
true // Force center on resize
186193
);
187194

188195
renderTimeline({
@@ -191,7 +198,7 @@ export const ConstellationTimeline: React.FC<{ height?: number; query?: string;
191198
height,
192199
transform: transformRef.current,
193200
layoutConfig,
194-
indexToOffset,
201+
indexToPosition: indexToPosition,
195202
nodes: dataset.nodes,
196203
edges: dataset.edges,
197204
query,
@@ -212,7 +219,17 @@ export const ConstellationTimeline: React.FC<{ height?: number; query?: string;
212219
};
213220
}, [height, query, mode, selectedIndex, showConstellations, branchSpacing]);
214221

215-
// Use interactions hook
222+
// Compute layout config for interactions (needs canvas width, computed in render)
223+
const [currentLayoutConfig, setCurrentLayoutConfig] = useState<LayoutConfig | null>(null);
224+
225+
// Update layout config when dimensions change
226+
useEffect(() => {
227+
if (ref.current) {
228+
const config = createLayoutConfig(dataset.nodes, ref.current.clientWidth, height, branchSpacing);
229+
setCurrentLayoutConfig(config);
230+
}
231+
}, [height, branchSpacing]);
232+
216233
useTimelineInteractions({
217234
canvasRef: ref,
218235
containerRef,
@@ -232,6 +249,7 @@ export const ConstellationTimeline: React.FC<{ height?: number; query?: string;
232249
setDeepLink,
233250
centerOnYear,
234251
hover,
252+
layoutConfig: currentLayoutConfig || createLayoutConfig(dataset.nodes, 800, height, branchSpacing), // Fallback
235253
});
236254

237255
return (
@@ -242,7 +260,7 @@ export const ConstellationTimeline: React.FC<{ height?: number; query?: string;
242260
tabIndex={0}
243261
aria-label="Constellation timeline canvas"
244262
>
245-
<canvas ref={ref} style={{ width: '100%', height }} />
263+
<canvas ref={ref} style={{ width: '100%', height, cursor: 'grab' }} />
246264
<ControlsBar
247265
onPrevYear={() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }))}
248266
onNextYear={() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))}

web/src/components/timeline/engine/layout.ts

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export function createLayoutConfig(
1919
const years = nodes.map(n => yearOf(n.date)).filter(Boolean);
2020
const minYear = Math.min(...years);
2121
const maxYear = Math.max(...years);
22-
// Baseline em 80% da altura = 20% do fundo acima das barras de controle
23-
const baselineY = height * 0.8;
22+
// Baseline em 70% da altura = 30% do fundo acima das barras de controle (mais alto)
23+
const baselineY = height * 0.7;
2424
return { canvasWidth, height, baselineY, branchSpacing, minYear, maxYear };
2525
}
2626

@@ -29,22 +29,89 @@ export function xScaleYear(year: number, config: LayoutConfig): number {
2929
return 40 + t * (config.canvasWidth - 80);
3030
}
3131

32-
export function computeIndexToOffset(nodes: Node[], config: LayoutConfig): Map<number, number> {
32+
// Constellation-like layout: organic positioning instead of linear stacking
33+
export function computeIndexToOffset(nodes: Node[], config: LayoutConfig): Map<number, { x: number; y: number }> {
34+
const baselineY = config.baselineY;
35+
const indexToPos = new Map<number, { x: number; y: number }>();
36+
37+
// Group nodes by year for temporal clustering
3338
const yearToIndices = new Map<number, number[]>();
3439
nodes.forEach((n, i) => {
3540
const y = yearOf(n.date);
3641
if (!yearToIndices.has(y)) yearToIndices.set(y, []);
3742
yearToIndices.get(y)!.push(i);
3843
});
3944

40-
const indexToOffset = new Map<number, number>();
41-
yearToIndices.forEach((indices) => {
42-
indices.sort((a, b) => a - b);
43-
indices.forEach((idx, k) => {
44-
const level = k + 1;
45-
indexToOffset.set(idx, -level * config.branchSpacing);
46-
});
45+
// Create tag-based clusters for thematic grouping
46+
const tagClusters = new Map<string, number[]>();
47+
nodes.forEach((n, i) => {
48+
const tags = n.tags || [];
49+
if (tags.length === 0) {
50+
// Untagged nodes go to a default cluster
51+
const defaultTag = 'untagged';
52+
if (!tagClusters.has(defaultTag)) tagClusters.set(defaultTag, []);
53+
tagClusters.get(defaultTag)!.push(i);
54+
} else {
55+
// Add to all relevant tag clusters
56+
tags.forEach(tag => {
57+
if (!tagClusters.has(tag)) tagClusters.set(tag, []);
58+
tagClusters.get(tag)!.push(i);
59+
});
60+
}
61+
});
62+
63+
// Calculate constellation positions (organic, non-linear distribution)
64+
yearToIndices.forEach((indices, year) => {
65+
const baseX = xScaleYear(year, config);
66+
67+
if (indices.length === 1) {
68+
// Single node: position with slight horizontal variation for organic feel
69+
const idx = indices[0];
70+
const seed = idx * 137.508;
71+
const organicX = Math.sin(seed) * config.branchSpacing * 0.2;
72+
indexToPos.set(idx, {
73+
x: baseX + organicX,
74+
y: baselineY - config.branchSpacing * 1.2
75+
});
76+
} else {
77+
// Multiple nodes: create organic constellation pattern
78+
// Use spiral/Fibonacci-like distribution for natural clustering
79+
indices.forEach((idx, k) => {
80+
// Golden angle spiral for organic distribution
81+
const goldenAngle = 2.399963229728653; // ~137.508° in radians
82+
const angle = k * goldenAngle;
83+
const spiralFactor = Math.sqrt(k + 1); // Spiral expansion
84+
const radius = config.branchSpacing * (1.0 + spiralFactor * 0.8);
85+
86+
// More horizontal spread for constellation feel (not just vertical stacking)
87+
const offsetX = Math.cos(angle) * radius * 0.8; // Wider horizontal spread
88+
const offsetY = -radius * 1.1; // Always upward but varied
89+
90+
// Add organic variation based on node properties (tags, index)
91+
const node = nodes[idx];
92+
const tagSeed = (node.tags?.join('') || '').split('').reduce((acc, char) => acc + char.charCodeAt(0), idx);
93+
const organicX = offsetX + Math.sin(tagSeed * 0.01) * radius * 0.25;
94+
const organicY = offsetY + Math.cos(tagSeed * 0.015) * radius * 0.2;
95+
96+
indexToPos.set(idx, {
97+
x: baseX + organicX,
98+
y: baselineY + organicY
99+
});
100+
});
101+
}
102+
});
103+
104+
return indexToPos;
105+
}
106+
107+
// Legacy function for backward compatibility (returns vertical offset only)
108+
export function computeIndexToVerticalOffset(nodes: Node[], config: LayoutConfig): Map<number, number> {
109+
const posMap = computeIndexToOffset(nodes, config);
110+
const offsetMap = new Map<number, number>();
111+
posMap.forEach((pos, idx) => {
112+
// Extract just the Y offset from baseline
113+
offsetMap.set(idx, pos.y - config.baselineY);
47114
});
48-
return indexToOffset;
115+
return offsetMap;
49116
}
50117

0 commit comments

Comments
 (0)