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
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user