Skip to content

Commit 118ed3e

Browse files
committed
feat(web): add publication timeline visualization (task-20)
- Create PublicationTimeline component with bar/line chart views - Display works per year with decade filtering - Support citation count overlay toggle - Interactive hover for detailed year-by-year breakdown - Integrate into authors, institutions, and sources detail pages - Transform counts_by_year data to YearData format
1 parent 67334aa commit 118ed3e

File tree

5 files changed

+407
-3
lines changed

5 files changed

+407
-3
lines changed
Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
/**
2+
* Publication Timeline Visualization
3+
*
4+
* Shows publication activity over time:
5+
* - Bar or line chart of works per year
6+
* - Interactive hover for details
7+
* - Filter by decade
8+
* - Citation count overlay option
9+
*
10+
* @module components/entity-detail
11+
*/
12+
13+
import { Badge, Box, Group, Paper, SegmentedControl, Stack, Text, Title } from '@mantine/core';
14+
import { IconChartBar } from '@tabler/icons-react';
15+
import { useMemo, useState } from 'react';
16+
17+
import { ICON_SIZE } from '@/config/style-constants';
18+
19+
interface YearData {
20+
year: number;
21+
count: number;
22+
citations?: number;
23+
}
24+
25+
interface PublicationTimelineProps {
26+
/** Publication year data */
27+
yearData: YearData[];
28+
/** Entity type for color theming */
29+
entityType?: 'works' | 'authors' | 'sources' | 'institutions';
30+
}
31+
32+
type ChartView = 'bar' | 'line';
33+
34+
const DECADES = ['all', '2020s', '2010s', '2000s', '1990s', '1980s', 'earlier'] as const;
35+
type DecadeFilter = (typeof DECADES)[number];
36+
37+
/**
38+
* Filter year data by decade
39+
* @param data
40+
* @param decade
41+
*/
42+
const filterByDecade = (data: YearData[], decade: DecadeFilter): YearData[] => {
43+
if (decade === 'all') return data;
44+
45+
const decadeRanges: Record<string, [number, number]> = {
46+
'2020s': [2020, 2029],
47+
'2010s': [2010, 2019],
48+
'2000s': [2000, 2009],
49+
'1990s': [1990, 1999],
50+
'1980s': [1980, 1989],
51+
'earlier': [0, 1979],
52+
};
53+
54+
const [start, end] = decadeRanges[decade];
55+
return data.filter((d) => d.year >= start && d.year <= end);
56+
};
57+
58+
/**
59+
* Format number with K suffix for thousands
60+
* @param num
61+
*/
62+
const formatCount = (num: number): string => {
63+
if (num >= 1000) return `${(num / 1000).toFixed(1)}k`;
64+
return num.toString();
65+
};
66+
67+
/**
68+
* PublicationTimeline Component
69+
* @param root0
70+
* @param root0.yearData
71+
* @param root0.entityType
72+
*/
73+
export const PublicationTimeline: React.FC<PublicationTimelineProps> = ({
74+
yearData,
75+
entityType: _entityType = 'works',
76+
}) => {
77+
const [view, setView] = useState<ChartView>('bar');
78+
const [decadeFilter, setDecadeFilter] = useState<DecadeFilter>('all');
79+
const [showCitations, setShowCitations] = useState(false);
80+
const [hoveredYear, setHoveredYear] = useState<number | null>(null);
81+
82+
// Filter and sort data
83+
const filteredData = useMemo(() => {
84+
const filtered = filterByDecade(yearData, decadeFilter);
85+
return filtered.sort((a, b) => a.year - b.year);
86+
}, [yearData, decadeFilter]);
87+
88+
// Calculate max values for scaling
89+
const maxCount = useMemo(() => {
90+
return Math.max(...filteredData.map((d) => d.count), 1);
91+
}, [filteredData]);
92+
93+
const maxCitations = useMemo(() => {
94+
const citationData = filteredData.filter((d) => d.citations !== undefined);
95+
return Math.max(...citationData.map((d) => d.citations || 0), 1);
96+
}, [filteredData]);
97+
98+
if (yearData.length === 0) {
99+
return null;
100+
}
101+
102+
return (
103+
<Paper p="xl" radius="xl">
104+
<Stack gap="lg">
105+
{/* Header */}
106+
<Group justify="space-between" align="center">
107+
<Group gap="sm">
108+
<IconChartBar size={ICON_SIZE.XL} color="var(--mantine-color-blue-6)" />
109+
<Title order={3}>Publication Timeline</Title>
110+
</Group>
111+
<Group gap="sm">
112+
<SegmentedControl
113+
value={view}
114+
onChange={(val) => setView(val as ChartView)}
115+
data={[
116+
{ label: 'Bar', value: 'bar' },
117+
{ label: 'Line', value: 'line' },
118+
]}
119+
size="xs"
120+
/>
121+
</Group>
122+
</Group>
123+
124+
{/* Filters */}
125+
<Group justify="space-between">
126+
<SegmentedControl
127+
value={decadeFilter}
128+
onChange={(val) => setDecadeFilter(val as DecadeFilter)}
129+
data={DECADES.map((d) => ({ label: d.charAt(0).toUpperCase() + d.slice(1), value: d }))}
130+
size="xs"
131+
/>
132+
{filteredData.some((d) => d.citations !== undefined) && (
133+
<Badge
134+
variant={showCitations ? 'filled' : 'outline'}
135+
color="blue"
136+
size="sm"
137+
style={{ cursor: 'pointer' }}
138+
onClick={() => setShowCitations(!showCitations)}
139+
>
140+
{showCitations ? 'Showing' : 'Show'} Citations
141+
</Badge>
142+
)}
143+
</Group>
144+
145+
{/* Chart */}
146+
<Box
147+
style={{
148+
position: 'relative',
149+
height: '250px',
150+
padding: '20px 0',
151+
}}
152+
>
153+
{/* Y-axis labels */}
154+
<Stack gap={0} style={{ position: 'absolute', left: 0, top: 20, height: 200 }}>
155+
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => (
156+
<Text
157+
key={ratio}
158+
size="xs"
159+
c="dimmed"
160+
ta="right"
161+
style={{
162+
position: 'absolute',
163+
top: `${200 * (1 - ratio)}px`,
164+
left: 0,
165+
width: '40px',
166+
transform: 'translateY(-50%)',
167+
}}
168+
>
169+
{formatCount(Math.round(showCitations ? maxCitations * ratio : maxCount * ratio))}
170+
</Text>
171+
))}
172+
</Stack>
173+
174+
{/* Chart area */}
175+
<Box
176+
style={{
177+
position: 'absolute',
178+
left: '50px',
179+
right: '10px',
180+
top: '20px',
181+
height: '200px',
182+
borderBottom: '1px solid var(--mantine-color-gray-3)',
183+
}}
184+
>
185+
{view === 'bar' ? (
186+
// Bar chart
187+
<Group gap="xs" wrap="nowrap" style={{ height: '100%' }}>
188+
{filteredData.map((data) => {
189+
const height = showCitations && data.citations
190+
? (data.citations / maxCitations) * 100
191+
: (data.count / maxCount) * 100;
192+
193+
return (
194+
<Box
195+
key={data.year}
196+
style={{
197+
flex: 1,
198+
display: 'flex',
199+
flexDirection: 'column',
200+
alignItems: 'center',
201+
justifyContent: 'flex-end',
202+
height: '100%',
203+
position: 'relative',
204+
}}
205+
onMouseEnter={() => setHoveredYear(data.year)}
206+
onMouseLeave={() => setHoveredYear(null)}
207+
>
208+
{/* Bar */}
209+
<Box
210+
style={{
211+
width: '100%',
212+
height: `${height}%`,
213+
backgroundColor: 'var(--mantine-color-blue-6)',
214+
borderRadius: '4px 4px 0 0',
215+
transition: 'all 0.2s',
216+
opacity: hoveredYear === null || hoveredYear === data.year ? 1 : 0.3,
217+
cursor: 'pointer',
218+
}}
219+
/>
220+
221+
{/* Year label */}
222+
<Text
223+
size="xs"
224+
c="dimmed"
225+
style={{
226+
position: 'absolute',
227+
bottom: -25,
228+
transform: `rotate(-45deg)`,
229+
transformOrigin: 'top left',
230+
whiteSpace: 'nowrap',
231+
}}
232+
>
233+
{data.year}
234+
</Text>
235+
236+
{/* Tooltip */}
237+
{hoveredYear === data.year && (
238+
<Paper
239+
shadow="sm"
240+
p="xs"
241+
style={{
242+
position: 'absolute',
243+
bottom: '100%',
244+
left: '50%',
245+
transform: 'translateX(-50%)',
246+
zIndex: 10,
247+
minWidth: '100px',
248+
}}
249+
>
250+
<Stack gap={0}>
251+
<Text size="sm" fw={500}>
252+
{data.year}
253+
</Text>
254+
<Text size="xs">
255+
{data.count} {data.count === 1 ? 'work' : 'works'}
256+
</Text>
257+
{showCitations && data.citations !== undefined && (
258+
<Text size="xs" c="blue">
259+
{formatCount(data.citations)} citations
260+
</Text>
261+
)}
262+
</Stack>
263+
</Paper>
264+
)}
265+
</Box>
266+
);
267+
})}
268+
</Group>
269+
) : (
270+
// Line chart using SVG
271+
<svg
272+
width="100%"
273+
height="100%"
274+
style={{ overflow: 'visible' }}
275+
>
276+
{/* Grid lines */}
277+
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => (
278+
<line
279+
key={ratio}
280+
x1="0"
281+
y1={`${200 * (1 - ratio)}`}
282+
x2="100%"
283+
y2={`${200 * (1 - ratio)}`}
284+
stroke="var(--mantine-color-gray-2)"
285+
strokeWidth="1"
286+
/>
287+
))}
288+
289+
{/* Line */}
290+
{filteredData.length > 1 && (
291+
<polyline
292+
points={filteredData.map((data, index) => {
293+
const x = (index / (filteredData.length - 1)) * 100;
294+
const y = showCitations && data.citations
295+
? 200 - (data.citations / maxCitations) * 200
296+
: 200 - (data.count / maxCount) * 200;
297+
return `${x}% ${y}`;
298+
}).join(' ')}
299+
fill="none"
300+
stroke="var(--mantine-color-blue-6)"
301+
strokeWidth="2"
302+
/>
303+
)}
304+
305+
{/* Data points */}
306+
{filteredData.map((data, index) => {
307+
const x = (index / (filteredData.length - 1)) * 100;
308+
const y = showCitations && data.citations
309+
? 200 - (data.citations / maxCitations) * 200
310+
: 200 - (data.count / maxCount) * 200;
311+
312+
return (
313+
<g key={data.year}>
314+
<circle
315+
cx={`${x}%`}
316+
cy={y}
317+
r={hoveredYear === data.year ? 6 : 4}
318+
fill="var(--mantine-color-blue-6)"
319+
style={{ cursor: 'pointer' }}
320+
onMouseEnter={() => setHoveredYear(data.year)}
321+
onMouseLeave={() => setHoveredYear(null)}
322+
/>
323+
<text
324+
x={`${x}%`}
325+
y={215}
326+
textAnchor="middle"
327+
fontSize="10"
328+
fill="var(--mantine-color-dimmed)"
329+
transform={`rotate(-45, ${x}%, 215)`}
330+
>
331+
{data.year}
332+
</text>
333+
{hoveredYear === data.year && (
334+
<g>
335+
<rect
336+
x={`${x - 25}%`}
337+
y={y - 50}
338+
width="50%"
339+
height="40"
340+
fill="white"
341+
stroke="var(--mantine-color-gray-3)"
342+
rx="4"
343+
/>
344+
<text
345+
x={`${x}%`}
346+
y={y - 35}
347+
textAnchor="middle"
348+
fontSize="12"
349+
fontWeight="500"
350+
>
351+
{data.year}
352+
</text>
353+
<text
354+
x={`${x}%`}
355+
y={y - 20}
356+
textAnchor="middle"
357+
fontSize="10"
358+
>
359+
{data.count} works
360+
</text>
361+
</g>
362+
)}
363+
</g>
364+
);
365+
})}
366+
</svg>
367+
)}
368+
</Box>
369+
</Box>
370+
371+
{/* Summary */}
372+
<Text size="sm" c="dimmed" ta="center">
373+
Showing {filteredData.length} {filteredData.length === 1 ? 'year' : 'years'} of data
374+
{decadeFilter !== 'all' && ` (${decadeFilter})`}
375+
</Text>
376+
</Stack>
377+
</Paper>
378+
);
379+
};

apps/web/src/components/entity-detail/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ export { CollaborationNetwork } from './CollaborationNetwork';
1111
export { ErrorState } from './ErrorState';
1212
export { LoadingState } from './LoadingState';
1313
export { NavigationTrail } from './NavigationTrail';
14+
export { PublicationTimeline } from './PublicationTimeline';
1415
export { RelatedEntitiesSection } from './RelatedEntitiesSection';
1516
// Types can be imported directly from './NavigationTrail' if needed

0 commit comments

Comments
 (0)