Volatility-adaptive spike detection + multi-instrument overlay charts

Spike detection now requires a move to clear an absolute floor AND (once
there's enough price history) be a configurable multiple of that
instrument's own recent typical move — MARKET_SPIKE_VOLATILITY_MULTIPLIER,
_recent_volatility() in markets.py — so a routinely volatile instrument
needs a bigger move to register than a normally-calm one, instead of one
fixed percentage for everything. Falls back to the floor alone until
there's enough history.

Economic Incident History: each incident gets two lazy-loaded overlay
charts (all tracked instruments, and just the incident's own), each
instrument normalized to % change from the window start so they're
comparable despite very different price scales/currencies — makes it easy
to see whether a move was isolated or broad, and to spot counter-reactions.
Backend: /api/markets/history now accepts an explicit start/end window
alongside the existing hours= param.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Um48tTvZDrEgDeweFyhPYC
main
Amir Alexander Abdelbaki 2026-07-20 15:21:37 +02:00
parent b8adc70092
commit 54022ad66f
9 changed files with 404 additions and 23 deletions

View File

@ -34,8 +34,18 @@ CONFLICT_POLL_MINUTES=60
ARTICLE_WINDOW_HOURS=72
# Minimum |% change| between two consecutive polls of an index/oil price
# before it's flagged as a "spike" (see /api/markets/spikes)
# before it's even eligible to be a "spike" (see /api/markets/spikes) —
# an absolute floor, applies regardless of the instrument's own volatility
MARKET_SPIKE_THRESHOLD_PCT=1.5
# Above that floor, a move must also be at least this many times the
# instrument's own recent typical move to count — i.e. "higher than the
# historical norm for THIS instrument," not just an absolute percentage
MARKET_SPIKE_VOLATILITY_MULTIPLIER=2.5
# Lookback window (days) for computing that "recent typical move" baseline
MARKET_SPIKE_HISTORY_DAYS=7
# Minimum historical observations required before the volatility check
# applies; below this (e.g. a freshly deployed instance) only the floor above applies
MARKET_SPIKE_MIN_SAMPLES=8
# How many hours before a spike's previous poll to search for candidate
# articles that might explain it
MARKET_SPIKE_LOOKBACK_HOURS=6

View File

@ -32,6 +32,15 @@ composition diagram when Wikipedia has one.
most across every headline published in that window, stopwords filtered
out — plain word-frequency counting, no AI/LLM involved (`textutil.py`).
Read it as "worth checking these articles," not an explanation.
A **"Show comparison charts"** button per incident (lazy-loaded) plots two
overlaid line charts, each instrument normalized to % change from the
window's start so wildly different price scales/currencies are
comparable: one with every tracked instrument, one with just the
incident's own — useful for checking whether a move was isolated to a
couple of correlated instruments or part of a broader swing, and for
spotting counter-reactions (something moving the opposite way at the same
time). Hover for a hairline crosshair with a per-instrument reading at
that moment.
- **Bottom drawer** — collapsible conflict/military-event log (ACLED-backed;
see below).
- **Flights toggle** — live global air traffic (OpenSky Network) as airplane
@ -158,15 +167,23 @@ into the red dots.
- `GET /api/clusters/{cluster_key}/articles` — articles behind one point
- `GET /api/articles?q=&limit=` — raw article search
- `POST /api/refresh` — force an immediate RSS poll
- `GET /api/markets/latest`, `GET /api/markets/history?symbol=^GSPC`
- `GET /api/markets/spikes?symbol=&hours=` — sudden index/oil moves
(`MARKET_SPIKE_THRESHOLD_PCT`), each paired with every article published in
a full-clock-hour window around it (`MARKET_SPIKE_LOOKBACK_HOURS` sets the
minimum lookback; the window then snaps outward to whole hours). Ranked
by relevance (keyword/country matching) but not filtered by it — timing
alone qualifies a story as a candidate. Click a ticker item for a quick
preview, or the "i" button (top-left) for the full log. Heuristic
correlation, not a verified causal link.
- `GET /api/markets/latest`, `GET /api/markets/history?symbol=^GSPC` (accepts
either `hours=` or an explicit `start=&end=` ISO-datetime window — the
latter is what the incident overlay charts use)
- `GET /api/markets/spikes?symbol=&hours=` — sudden index/oil moves, each
paired with every article published in a full-clock-hour window around it
(`MARKET_SPIKE_LOOKBACK_HOURS` sets the minimum lookback; the window then
snaps outward to whole hours). A move must clear an absolute floor
(`MARKET_SPIKE_THRESHOLD_PCT`) *and*, once there's enough price history,
be at least `MARKET_SPIKE_VOLATILITY_MULTIPLIER`× that instrument's own
recent typical move (`MARKET_SPIKE_HISTORY_DAYS` lookback,
`MARKET_SPIKE_MIN_SAMPLES` minimum observations before it applies) — so a
routinely volatile instrument needs a bigger move to register than a
normally-calm one. Candidate articles are ranked by relevance
(keyword/country matching) but not filtered by it — timing alone
qualifies a story. Click a ticker item for a quick preview, or the "i"
button (top-left) for the full log. Heuristic correlation, not a verified
causal link.
- `GET /api/markets/incidents?symbol=&hours=` — the same spikes merged
across instruments within `INCIDENT_MERGE_HOURS` (1h, `markets.py`) of
each other, each with a `top_keywords` word-frequency list. Backs the

View File

@ -14,10 +14,21 @@ class Settings(BaseSettings):
rss_poll_minutes: int = 10
market_poll_minutes: int = 15
# Minimum |% change| between two consecutive polls of the same
# instrument before we flag it as a "spike" and go looking for articles
# that might explain it.
# Absolute floor: minimum |% change| between two consecutive polls of the
# same instrument before it's even eligible to be a "spike," regardless
# of that instrument's own volatility. Prevents a very calm instrument's
# tiny normal wiggle from counting as "abnormal" just because it's small.
market_spike_threshold_pct: float = 1.5
# Above the floor, a move must also be at least this many times the
# instrument's own recent typical (mean absolute) poll-to-poll move to
# count as a spike — the "higher than historical norm" check. Skipped
# (falls back to the floor alone) until there's enough price history.
market_spike_volatility_multiplier: float = 2.5
# How far back to look when computing that "recent typical move" baseline.
market_spike_history_days: int = 7
# Minimum number of historical poll-to-poll observations required before
# the volatility check applies; below this, only the floor above applies.
market_spike_min_samples: int = 8
# How far back (and forward, capped at "now") from the previous poll to
# search for candidate-cause articles around a detected spike.
market_spike_lookback_hours: int = 6

View File

@ -25,6 +25,7 @@ class Base(DeclarativeBase):
# ddl-type-and-default) tuple here whenever a model gains a field.
_COLUMN_MIGRATIONS = [
("market_spikes", "top_keywords_json", "TEXT DEFAULT '[]'"),
("market_spikes", "baseline_volatility_pct", "REAL"),
]

View File

@ -168,13 +168,26 @@ def api_markets_latest():
@app.get("/api/markets/history")
def api_markets_history(symbol: str, hours: int = Query(168, le=24 * 30)):
def api_markets_history(
symbol: str,
hours: int = Query(168, le=24 * 30),
start: str | None = None,
end: str | None = None,
):
"""`start`/`end` (ISO datetimes) fetch an explicit window — used to plot
an incident's own timeframe. Without them, falls back to the last
`hours` from now, as before."""
if start and end:
since = dt.datetime.fromisoformat(start)
until = dt.datetime.fromisoformat(end)
else:
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
until = dt.datetime.utcnow()
session = next(get_session())
try:
rows = session.execute(
select(MarketPrice)
.where(MarketPrice.symbol == symbol, MarketPrice.recorded_at >= since)
.where(MarketPrice.symbol == symbol, MarketPrice.recorded_at >= since, MarketPrice.recorded_at <= until)
.order_by(MarketPrice.recorded_at.asc())
).scalars().all()
return [{"price": r.price, "recorded_at": r.recorded_at.isoformat()} for r in rows]
@ -208,6 +221,7 @@ def api_markets_spikes(symbol: str | None = None, hours: int = Query(168, le=24
"from_price": s.from_price,
"to_price": s.to_price,
"pct_change": s.pct_change,
"baseline_volatility_pct": s.baseline_volatility_pct,
"window_start": s.window_start.isoformat(),
"window_end": s.window_end.isoformat(),
"detected_at": s.detected_at.isoformat(),

View File

@ -1,6 +1,7 @@
import datetime as dt
import json
import logging
import statistics
from collections import Counter
import httpx
@ -145,13 +146,46 @@ def _ceil_hour(t: dt.datetime) -> dt.datetime:
return floored if floored == t else floored + dt.timedelta(hours=1)
def _recent_volatility(session: Session, symbol: str, before: dt.datetime) -> float | None:
"""This instrument's recent typical poll-to-poll move size (mean of
|% change| between consecutive polls over the lookback window), strictly
before `before` so the move being evaluated can't inflate its own
baseline. None if there isn't enough history yet to trust it."""
since = before - dt.timedelta(days=settings.market_spike_history_days)
prices = session.execute(
select(MarketPrice.price)
.where(MarketPrice.symbol == symbol, MarketPrice.recorded_at >= since, MarketPrice.recorded_at < before)
.order_by(MarketPrice.recorded_at.asc())
).scalars().all()
changes = [
abs((p2 - p1) / p1 * 100) for p1, p2 in zip(prices, prices[1:]) if p1
]
if len(changes) < settings.market_spike_min_samples:
return None
return statistics.mean(changes)
def _detect_and_record_spike(
session: Session, symbol: str, label: str, prev: MarketPrice | None, price: float, recorded_at: dt.datetime
) -> None:
if prev is None or not prev.price:
return
pct_change = (price - prev.price) / prev.price * 100
if abs(pct_change) < settings.market_spike_threshold_pct:
abs_change = abs(pct_change)
# Absolute floor: never report a move too small to matter, regardless of
# this instrument's own volatility (a near-frozen instrument's tiny
# normal wiggle shouldn't count as "abnormal" just because it's small).
if abs_change < settings.market_spike_threshold_pct:
return
# Above the floor, also require it to be unusually large *for this
# instrument* — a historical-norm check, not just an absolute cutoff.
# Falls back to the floor alone when there's not enough price history
# yet (new deployment, or an instrument added recently).
baseline = _recent_volatility(session, symbol, prev.recorded_at)
if baseline is not None and baseline > 0 and abs_change < baseline * settings.market_spike_volatility_multiplier:
return
# Snapped to whole clock hours so the log reads as clean ranges (e.g.
@ -167,6 +201,7 @@ def _detect_and_record_spike(
from_price=prev.price,
to_price=price,
pct_change=round(pct_change, 3),
baseline_volatility_pct=round(baseline, 3) if baseline is not None else None,
window_start=window_start,
window_end=window_end,
detected_at=recorded_at,
@ -174,7 +209,13 @@ def _detect_and_record_spike(
top_keywords_json=json.dumps(keywords),
)
)
log.info("Spike detected: %s %.2f%% (%d candidate articles)", symbol, pct_change, len(article_ids))
log.info(
"Spike detected: %s %.2f%% (baseline %s, %d candidate articles)",
symbol,
pct_change,
f"{baseline:.2f}%" if baseline is not None else "n/a",
len(article_ids),
)
def poll_markets(session: Session) -> int:
@ -258,6 +299,7 @@ def merge_spikes_into_incidents(spikes: list[MarketSpike]) -> list[dict]:
"from_price": s.from_price,
"to_price": s.to_price,
"pct_change": s.pct_change,
"baseline_volatility_pct": s.baseline_volatility_pct,
"detected_at": s.detected_at,
}
for s in group

View File

@ -51,6 +51,11 @@ class MarketSpike(Base):
from_price: Mapped[float] = mapped_column(Float)
to_price: Mapped[float] = mapped_column(Float)
pct_change: Mapped[float] = mapped_column(Float)
# This instrument's recent typical (mean absolute) poll-to-poll move,
# for comparison — null if there wasn't enough price history yet to
# compute one (see market_spike_min_samples). Lets the UI show "Nx this
# instrument's normal move" instead of just the raw percentage.
baseline_volatility_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
window_start: Mapped[dt.datetime] = mapped_column(DateTime)
window_end: Mapped[dt.datetime] = mapped_column(DateTime)

View File

@ -278,6 +278,43 @@ html, body {
.econ-incident .window { font-size: 11px; color: var(--c-text-muted); margin-bottom: 8px; }
.econ-incident .articles { margin-top: 8px; }
.overlay-toggle-btn {
background: var(--c-tag-bg);
border: 1px solid var(--c-panel-border);
color: var(--c-dark-soft);
border-radius: 6px;
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
margin: 4px 0 6px;
}
.overlay-toggle-btn:hover { background: #3d2260; color: #fff; }
.overlay-charts { margin: 6px 0 12px; }
.overlay-charts h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--c-text-muted); margin: 12px 0 4px; font-weight: 600; }
.overlay-charts h4:first-child { margin-top: 0; }
.overlay-chart-container { position: relative; }
.overlay-chart-svg { width: 100%; height: 200px; display: block; overflow: visible; }
.overlay-tooltip {
position: absolute;
top: 4px;
background: #1c1030;
border: 1px solid var(--c-panel-border);
border-radius: 6px;
padding: 6px 8px;
font-size: 11px;
pointer-events: none;
z-index: 2;
max-width: 220px;
}
.overlay-tooltip-row { display: flex; justify-content: space-between; gap: 10px; white-space: nowrap; }
.overlay-legend { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 6px; font-size: 10px; color: var(--c-text-muted); }
.overlay-legend-item { display: inline-flex; align-items: center; gap: 4px; }
.overlay-legend .swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
.keyword-tags { margin: 6px 0 10px; font-size: 11px; display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
.keyword-tag {
display: inline-block;

View File

@ -12,6 +12,30 @@ const NEWS_ICON_HEX = 0xff33ff;
// Must match INCIDENT_MERGE_HOURS in backend/app/markets.py — display label only.
const INCIDENT_MERGE_HOURS_LABEL = "1h";
// Categorical palette for the multi-instrument overlay charts — the
// dataviz skill's validated 8-hue set (dark-surface steps), reused as-is
// rather than forced into the site's monochrome violet/magenta theme: a
// genuine multi-series comparison chart needs real hue separation, which a
// single-hue-family theme can't provide. Order is the CVD-safety mechanism
// (see the skill's palette.md) — never reorder or cycle it per-render.
const OVERLAY_PALETTE = ["#3987e5", "#008300", "#d55181", "#c98500", "#199e70", "#d95926", "#9085e9", "#e66767"];
const OVERLAY_SHORT_LABELS = {
"^GSPC": "S&P", "^DJI": "DJI", "^IXIC": "NASDAQ", "^FTSE": "FTSE",
"^GDAXI": "DAX", "^FCHI": "CAC", "^N225": "N225", "^HSI": "HSI",
"000001.SS": "SSE", "^BSESN": "SENSEX", "IMOEX.ME": "MOEX", "^BVSP": "BOVESPA",
"CL=F": "WTI", "BZ=F": "BRENT",
};
let overlaySymbolStyle = {}; // symbol -> {color, dashed} — assigned once, stable across renders
// Beyond 8 series, colors repeat with a dashed stroke as a secondary
// channel (still per-entity, still fixed order) rather than cycling hue.
function assignOverlaySymbolStyles(symbols) {
overlaySymbolStyle = {};
symbols.forEach((sym, i) => {
overlaySymbolStyle[sym] = { color: OVERLAY_PALETTE[i % OVERLAY_PALETTE.length], dashed: i >= OVERLAY_PALETTE.length };
});
}
let config = { weather_enabled: false, conflict_enabled: false };
let clusters = []; // raw, server-side ~11km-grid clusters
let displayedClusters = []; // zoom-adaptive regrouping of `clusters`, currently on screen
@ -856,6 +880,16 @@ function keywordTagsHtml(keywords) {
return `<div class="keyword-tags"><span class="subtle">Likely factors (common words in this window):</span> ${tags}</div>`;
}
// Spikes are only reported when a move clears both an absolute floor AND
// (once there's enough price history) a multiple of this instrument's own
// recent typical move — see backend markets.py _recent_volatility. This
// renders that comparison so "why did this count as a spike" is visible.
function volatilityLabel(pctChange, baselinePct) {
if (baselinePct == null || baselinePct <= 0) return "";
const ratio = Math.abs(pctChange) / baselinePct;
return ` <span class="subtle">(${ratio.toFixed(1)}× this instrument's typical ${baselinePct.toFixed(2)}% move)</span>`;
}
async function toggleMarketDetail(symbol, idx) {
const el = document.getElementById(`market-detail-${idx}`);
const wasHidden = el.classList.contains("hidden");
@ -878,7 +912,7 @@ async function toggleMarketDetail(symbol, idx) {
? s.candidate_articles.slice(0, 5).map(articleItemHtml).join("")
: `<p class="subtle">No strongly-matching stories found in that window.</p>`;
return `
<p><span class="${dir}">${arrow} ${s.pct_change.toFixed(2)}%</span>
<p><span class="${dir}">${arrow} ${s.pct_change.toFixed(2)}%</span>${volatilityLabel(s.pct_change, s.baseline_volatility_pct)}
at ${new Date(s.detected_at).toLocaleString()}
(${s.from_price.toLocaleString()} ${s.to_price.toLocaleString()})</p>
${keywordTagsHtml(s.top_keywords)}
@ -895,6 +929,8 @@ async function toggleMarketDetail(symbol, idx) {
const econHistoryView = document.getElementById("econHistoryView");
let econHistorySymbolsLoaded = false;
let currentIncidents = [];
let allTrackedInstruments = null; // [{symbol,label}], fetched once
document.getElementById("infoBtn").onclick = () => {
econHistoryView.classList.remove("hidden");
@ -903,6 +939,25 @@ document.getElementById("infoBtn").onclick = () => {
document.getElementById("econHistorySymbolFilter").onchange = () => loadEconHistory();
// Delegated once (not per-render) so re-rendering the list doesn't stack
// duplicate listeners: expand/collapse a card's comparison charts, fetching
// them lazily on first expand only.
document.getElementById("econHistoryList").addEventListener("click", (ev) => {
const btn = ev.target.closest(".overlay-toggle-btn");
if (!btn) return;
const idx = btn.dataset.idx;
const wrap = document.getElementById(`overlay-charts-${idx}`);
if (wrap.dataset.loaded) {
wrap.classList.toggle("hidden");
btn.textContent = wrap.classList.contains("hidden") ? "📈 Show comparison charts" : "📉 Hide comparison charts";
} else {
btn.textContent = "Loading charts…";
loadIncidentOverlayCharts(currentIncidents[idx], idx).then(() => {
btn.textContent = "📉 Hide comparison charts";
});
}
});
async function loadEconHistory() {
const listEl = document.getElementById("econHistoryList");
const filterEl = document.getElementById("econHistorySymbolFilter");
@ -925,12 +980,12 @@ async function loadEconHistory() {
const url = symbol
? `${API}/markets/incidents?symbol=${encodeURIComponent(symbol)}&hours=8760`
: `${API}/markets/incidents?hours=8760`;
const incidents = await (await fetch(url)).json();
if (!incidents.length) {
currentIncidents = await (await fetch(url)).json();
if (!currentIncidents.length) {
listEl.innerHTML = `<p class="subtle">No moves ≥ the spike threshold recorded yet.</p>`;
return;
}
listEl.innerHTML = incidents.map(econIncidentHtml).join("");
listEl.innerHTML = currentIncidents.map((inc, idx) => econIncidentHtml(inc, idx)).join("");
} catch (e) {
listEl.innerHTML = `<p class="subtle">Could not load incident history.</p>`;
}
@ -939,7 +994,7 @@ async function loadEconHistory() {
// An "incident" is one or more spikes merged because they landed within an
// hour of each other (backend: markets.merge_spikes_into_incidents) — e.g.
// WTI and Brent crude spiking together shows as one incident, not two.
function econIncidentHtml(inc) {
function econIncidentHtml(inc, idx) {
const fmtHour = (iso) =>
new Date(iso).toLocaleString(undefined, {
month: "short",
@ -966,11 +1021,200 @@ function econIncidentHtml(inc) {
${inc.instruments.length > 1 ? ` · ${inc.instruments.length} instruments moved within ${INCIDENT_MERGE_HOURS_LABEL} of each other` : ""}
</div>
${keywordTagsHtml(inc.top_keywords)}
<button class="overlay-toggle-btn" data-idx="${idx}">📈 Show comparison charts</button>
<div class="overlay-charts hidden" id="overlay-charts-${idx}">
<h4>All tracked instruments, overlaid (% change from window start)</h4>
<div class="overlay-chart-container" id="overlay-all-${idx}"></div>
<h4>This incident's instruments only</h4>
<div class="overlay-chart-container" id="overlay-inc-${idx}"></div>
</div>
<div class="articles">${articlesHtml}</div>
</div>
`;
}
// ---- multi-instrument overlay charts ----
//
// Normalizes every series to % change from its first point in the window
// (raw prices span wildly different scales/currencies) and plots them on
// one shared axis so counter-reactions — one instrument up while another
// is down over the same span — are visible at a glance.
async function ensureAllTrackedInstruments() {
if (allTrackedInstruments) return allTrackedInstruments;
allTrackedInstruments = await (await fetch(`${API}/markets/latest`)).json();
assignOverlaySymbolStyles(allTrackedInstruments.map((r) => r.symbol));
return allTrackedInstruments;
}
async function fetchHistoryFor(instruments, start, end) {
const results = await Promise.all(
instruments.map(async (inst) => {
const url = `${API}/markets/history?symbol=${encodeURIComponent(inst.symbol)}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
const history = await (await fetch(url)).json();
return { symbol: inst.symbol, label: inst.label, history };
})
);
return results;
}
async function loadIncidentOverlayCharts(inc, idx) {
const wrap = document.getElementById(`overlay-charts-${idx}`);
wrap.classList.remove("hidden");
wrap.dataset.loaded = "1";
const allEl = document.getElementById(`overlay-all-${idx}`);
const incEl = document.getElementById(`overlay-inc-${idx}`);
allEl.innerHTML = `<p class="subtle">Loading…</p>`;
incEl.innerHTML = `<p class="subtle">Loading…</p>`;
try {
const instruments = await ensureAllTrackedInstruments();
const allSeries = await fetchHistoryFor(instruments, inc.window_start, inc.window_end);
buildOverlayChart(allSeries, allEl);
const incSymbols = new Set(inc.instruments.map((i) => i.symbol));
buildOverlayChart(
allSeries.filter((s) => incSymbols.has(s.symbol)),
incEl
);
} catch (e) {
allEl.innerHTML = `<p class="subtle">Could not load comparison charts.</p>`;
incEl.innerHTML = "";
}
}
function buildOverlayChart(seriesList, container) {
const width = 640;
const height = 220;
const padL = 8;
const padR = 92;
const padT = 10;
const padB = 10;
const usable = seriesList.filter((s) => s.history.length >= 2);
if (!usable.length) {
container.innerHTML = `<p class="subtle">Not enough price history in this window yet.</p>`;
return;
}
const normalized = usable.map((s) => {
const base = s.history[0].price;
return {
symbol: s.symbol,
label: s.label,
points: s.history.map((h) => ({
t: new Date(h.recorded_at).getTime(),
pct: base ? ((h.price - base) / base) * 100 : 0,
})),
};
});
const allT = normalized.flatMap((s) => s.points.map((p) => p.t));
const tMin = Math.min(...allT);
const tMax = Math.max(...allT, tMin + 1);
const allPct = normalized.flatMap((s) => s.points.map((p) => p.pct));
const pMin = Math.min(0, ...allPct);
const pMax = Math.max(0, ...allPct);
const pSpan = pMax - pMin || 1;
const xScale = (t) => padL + ((t - tMin) / (tMax - tMin)) * (width - padL - padR);
const yScale = (pct) => padT + (1 - (pct - pMin) / pSpan) * (height - padT - padB);
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.classList.add("overlay-chart-svg");
const zero = document.createElementNS(svgNS, "line");
zero.setAttribute("x1", padL);
zero.setAttribute("x2", width - padR);
zero.setAttribute("y1", yScale(0).toFixed(1));
zero.setAttribute("y2", yScale(0).toFixed(1));
zero.setAttribute("stroke", "var(--c-panel-border)");
svg.appendChild(zero);
normalized.forEach((s) => {
const style = overlaySymbolStyle[s.symbol] || { color: "#999", dashed: false };
const d = s.points.map((p, i) => `${i === 0 ? "M" : "L"}${xScale(p.t).toFixed(1)},${yScale(p.pct).toFixed(1)}`).join(" ");
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
path.setAttribute("stroke", style.color);
path.setAttribute("stroke-width", "2");
if (style.dashed) path.setAttribute("stroke-dasharray", "5,3");
svg.appendChild(path);
const last = s.points[s.points.length - 1];
const label = document.createElementNS(svgNS, "text");
label.setAttribute("x", (xScale(last.t) + 5).toFixed(1));
label.setAttribute("y", (yScale(last.pct) + 3).toFixed(1));
label.setAttribute("fill", style.color);
label.setAttribute("font-size", "9");
label.textContent = OVERLAY_SHORT_LABELS[s.symbol] || s.symbol;
svg.appendChild(label);
});
const crosshair = document.createElementNS(svgNS, "line");
crosshair.setAttribute("y1", String(padT));
crosshair.setAttribute("y2", String(height - padB));
crosshair.setAttribute("stroke", "var(--c-text-muted)");
crosshair.setAttribute("stroke-width", "1");
crosshair.setAttribute("stroke-dasharray", "2,2");
crosshair.style.display = "none";
svg.appendChild(crosshair);
container.innerHTML = "";
container.appendChild(svg);
const tooltip = document.createElement("div");
tooltip.className = "overlay-tooltip hidden";
container.appendChild(tooltip);
svg.addEventListener("mousemove", (ev) => {
const rect = svg.getBoundingClientRect();
const relX = ((ev.clientX - rect.left) / rect.width) * width;
const t = tMin + ((relX - padL) / (width - padL - padR)) * (tMax - tMin);
crosshair.setAttribute("x1", relX.toFixed(1));
crosshair.setAttribute("x2", relX.toFixed(1));
crosshair.style.display = "";
const rows = normalized
.map((s) => {
let nearest = s.points[0];
let best = Infinity;
s.points.forEach((p) => {
const d = Math.abs(p.t - t);
if (d < best) {
best = d;
nearest = p;
}
});
const style = overlaySymbolStyle[s.symbol] || { color: "#999" };
return `<div class="overlay-tooltip-row"><span style="color:${style.color}">${escapeHtml(s.label)}</span> <b>${nearest.pct >= 0 ? "+" : ""}${nearest.pct.toFixed(2)}%</b></div>`;
})
.join("");
tooltip.innerHTML = `<div class="subtle">${new Date(t).toLocaleString()}</div>${rows}`;
tooltip.classList.remove("hidden");
tooltip.style.left = `${Math.min(60, Math.max(0, (relX / width) * 100 - 20))}%`;
});
svg.addEventListener("mouseleave", () => {
crosshair.style.display = "none";
tooltip.classList.add("hidden");
});
const legend = document.createElement("div");
legend.className = "overlay-legend";
legend.innerHTML = normalized
.map((s) => {
const style = overlaySymbolStyle[s.symbol] || { color: "#999" };
return `<span class="overlay-legend-item"><span class="swatch" style="background:${style.color}"></span>${escapeHtml(s.label)}</span>`;
})
.join("");
container.appendChild(legend);
}
// -------------------------------------------------------------- flights --
//
// Rendered as a single THREE.Points cloud (not three-globe's per-datum