feat: fullscreen chart expand + market open/close vertical markers
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled

- Click 'Expand' button to view the trend chart fullscreen
- Press Escape or click outside to close
- Fullscreen uses full viewport height for better readability
- Green dashed vertical lines at 9:30 AM ET (market open)
- Red dashed vertical lines at 4:00 PM ET (market close)
- Markers only shown on intraday and 1d windows (hidden on 7d+)
- Chart content extracted into shared function to avoid duplication
This commit is contained in:
Celes Renata
2026-04-30 22:44:00 +00:00
parent 2f2ea65fb4
commit 1f08820f11
+155 -103
View File
@@ -1,5 +1,5 @@
import { useParams, useNavigate, Link } from '@tanstack/react-router';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
useCompany,
useCompanySources,
@@ -750,6 +750,34 @@ function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, range90
const hasPrice = chartData.some((pt) => pt.price != null);
// Compute market open/close vertical markers for intraday and 1d windows
const showMarketMarkers = selectedWindow === 'intraday' || selectedWindow === '1d';
const marketMarkers: { ts: number; label: string }[] = [];
if (showMarketMarkers && chartData.length > 0) {
const minTs = chartData[0].timestamp;
const maxTs = chartData[chartData.length - 1].timestamp;
// Walk each day in the range and compute 9:30 AM ET (open) and 4:00 PM ET (close)
const dayMs = 86400_000;
const startDay = new Date(minTs);
startDay.setUTCHours(0, 0, 0, 0);
for (let d = startDay.getTime(); d <= maxTs + dayMs; d += dayMs) {
const date = new Date(d);
const dow = date.getUTCDay();
if (dow === 0 || dow === 6) continue; // skip weekends
// ET offset: EDT = UTC-4, EST = UTC-5. Approximate with -4 (summer).
// 9:30 AM ET = 13:30 UTC (EDT)
const openTs = d + 13 * 3600_000 + 30 * 60_000;
// 4:00 PM ET = 20:00 UTC (EDT)
const closeTs = d + 20 * 3600_000;
if (openTs >= minTs && openTs <= maxTs) {
marketMarkers.push({ ts: openTs, label: 'Open' });
}
if (closeTs >= minTs && closeTs <= maxTs) {
marketMarkers.push({ ts: closeTs, label: 'Close' });
}
}
}
// Available windows from the data (check both history and latest)
const allTrends = [...(trends ?? []), ...(latestTrends ?? [])];
const availableWindows = [...new Set(allTrends.filter((t) => t.entity_id === ticker).map((t) => t.window))];
@@ -761,6 +789,93 @@ function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, range90
.sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime());
const latest = latestForWindow[0] ?? (filtered.length > 0 ? filtered[filtered.length - 1] : null);
const [fullscreen, setFullscreen] = useState(false);
// Close fullscreen on Escape key
useEffect(() => {
if (!fullscreen) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setFullscreen(false); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [fullscreen]);
// Shared chart content — rendered at different sizes
const chartContent = (height: number) => (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={chartData} margin={{ top: 5, right: 20, bottom: 40, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
{marketMarkers.map((m, i) => (
<ReferenceLine
key={`market-${i}`}
x={m.ts}
stroke={m.label === 'Open' ? '#22c55e' : '#ef4444'}
strokeDasharray="4 4"
strokeWidth={1}
strokeOpacity={0.5}
label={{ value: m.label, position: 'top', fill: m.label === 'Open' ? '#22c55e' : '#ef4444', fontSize: 9 }}
/>
))}
<XAxis
dataKey="timestamp"
type="number"
domain={['dataMin', 'dataMax']}
scale="time"
tick={<ChartXTick />}
tickLine={{ stroke: '#475569' }}
tickCount={8}
/>
<YAxis
yAxisId="left"
domain={[0, 100]}
tick={{ fill: '#94a3b8', fontSize: 11 }}
tickLine={{ stroke: '#475569' }}
tickFormatter={(v) => `${v}%`}
/>
{hasPrice && (
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: '#e879f9', fontSize: 11 }}
tickLine={{ stroke: '#475569' }}
tickFormatter={(v) => `$${v}`}
domain={[
range90d.low != null ? Math.floor(range90d.low * 0.97) : 'dataMin - 2',
range90d.high != null ? Math.ceil(range90d.high * 1.03) : 'dataMax + 2',
]}
/>
)}
<Tooltip content={TrendTooltip} />
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: 12 }} />
{hasPrice && range90d.high != null && (
<ReferenceLine
yAxisId="right"
y={range90d.high}
stroke="#22c55e"
strokeDasharray="6 3"
strokeWidth={1.5}
label={{ value: `90d High $${range90d.high.toFixed(2)}`, position: 'insideTopRight', fill: '#22c55e', fontSize: 10 }}
/>
)}
{hasPrice && range90d.low != null && (
<ReferenceLine
yAxisId="right"
y={range90d.low}
stroke="#ef4444"
strokeDasharray="6 3"
strokeWidth={1.5}
label={{ value: `90d Low $${range90d.low.toFixed(2)}`, position: 'insideBottomRight', fill: '#ef4444', fontSize: 10 }}
/>
)}
<Line yAxisId="left" type="monotone" dataKey="strength" name="Trend Strength" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3, fill: '#3b82f6' }} activeDot={{ r: 5 }} />
<Line yAxisId="left" type="monotone" dataKey="confidence" name="Confidence" stroke="#10b981" strokeWidth={2} dot={{ r: 3, fill: '#10b981' }} activeDot={{ r: 5 }} />
<Line yAxisId="left" type="monotone" dataKey="contradiction" name="Contradiction" stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="5 5" dot={{ r: 2, fill: '#f59e0b' }} />
{hasPrice && (
<Line yAxisId="right" type="monotone" dataKey="price" name="Price" stroke="#e879f9" strokeWidth={2} dot={{ r: 3, fill: '#e879f9' }} activeDot={{ r: 5 }} connectNulls />
)}
</LineChart>
</ResponsiveContainer>
);
return (
<div className="space-y-4">
{/* Window selector */}
@@ -789,110 +904,47 @@ function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, range90
<>
{/* Trend Strength & Confidence Chart */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">
Trend Strength & Confidence {ticker} / {selectedWindow}
</h2>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData} margin={{ top: 5, right: 20, bottom: 40, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="timestamp"
type="number"
domain={['dataMin', 'dataMax']}
scale="time"
tick={<ChartXTick />}
tickLine={{ stroke: '#475569' }}
tickCount={8}
/>
<YAxis
yAxisId="left"
domain={[0, 100]}
tick={{ fill: '#94a3b8', fontSize: 11 }}
tickLine={{ stroke: '#475569' }}
tickFormatter={(v) => `${v}%`}
/>
{hasPrice && (
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: '#e879f9', fontSize: 11 }}
tickLine={{ stroke: '#475569' }}
tickFormatter={(v) => `$${v}`}
domain={[
range90d.low != null ? Math.floor(range90d.low * 0.97) : 'dataMin - 2',
range90d.high != null ? Math.ceil(range90d.high * 1.03) : 'dataMax + 2',
]}
/>
)}
<Tooltip content={TrendTooltip} />
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: 12 }} />
{hasPrice && range90d.high != null && (
<ReferenceLine
yAxisId="right"
y={range90d.high}
stroke="#22c55e"
strokeDasharray="6 3"
strokeWidth={1.5}
label={{ value: `90d High $${range90d.high.toFixed(2)}`, position: 'insideTopRight', fill: '#22c55e', fontSize: 10 }}
/>
)}
{hasPrice && range90d.low != null && (
<ReferenceLine
yAxisId="right"
y={range90d.low}
stroke="#ef4444"
strokeDasharray="6 3"
strokeWidth={1.5}
label={{ value: `90d Low $${range90d.low.toFixed(2)}`, position: 'insideBottomRight', fill: '#ef4444', fontSize: 10 }}
/>
)}
<Line
yAxisId="left"
type="monotone"
dataKey="strength"
name="Trend Strength"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3, fill: '#3b82f6' }}
activeDot={{ r: 5 }}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="confidence"
name="Confidence"
stroke="#10b981"
strokeWidth={2}
dot={{ r: 3, fill: '#10b981' }}
activeDot={{ r: 5 }}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="contradiction"
name="Contradiction"
stroke="#f59e0b"
strokeWidth={1.5}
strokeDasharray="5 5"
dot={{ r: 2, fill: '#f59e0b' }}
/>
{hasPrice && (
<Line
yAxisId="right"
type="monotone"
dataKey="price"
name="Price"
stroke="#e879f9"
strokeWidth={2}
dot={{ r: 3, fill: '#e879f9' }}
activeDot={{ r: 5 }}
connectNulls
/>
)}
</LineChart>
</ResponsiveContainer>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-gray-400">
Trend Strength & Confidence {ticker} / {selectedWindow}
</h2>
<button
onClick={() => setFullscreen(true)}
className="rounded-md border border-surface-700 px-2 py-1 text-xs text-gray-400 hover:bg-surface-800 hover:text-gray-200"
title="Expand chart"
>
Expand
</button>
</div>
{chartContent(280)}
</Card>
{/* Fullscreen overlay */}
{fullscreen && (
<div
className="fixed inset-0 z-50 flex flex-col bg-surface-950/95 p-6"
onClick={(e) => { if (e.target === e.currentTarget) setFullscreen(false); }}
role="dialog"
aria-label="Expanded chart"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-200">
Trend Strength & Confidence {ticker} / {selectedWindow}
</h2>
<button
onClick={() => setFullscreen(false)}
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800 hover:text-gray-200"
>
Close
</button>
</div>
<div className="flex-1 min-h-0">
{chartContent(Math.max(400, window.innerHeight - 160))}
</div>
</div>
)}
{/* Direction Timeline */}
{/* Direction Timeline */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">