diff --git a/.env.example b/.env.example index 8ff02ce..1479118 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 0cc1b19..a7276d5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index 0791191..ee77a24 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/db.py b/backend/app/db.py index a22f22f..bb0fd91 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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"), ] diff --git a/backend/app/main.py b/backend/app/main.py index 036986d..325d145 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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)): - since = dt.datetime.utcnow() - dt.timedelta(hours=hours) +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(), diff --git a/backend/app/markets.py b/backend/app/markets.py index dc58f99..f830211 100644 --- a/backend/app/markets.py +++ b/backend/app/markets.py @@ -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 diff --git a/backend/app/models.py b/backend/app/models.py index e6540eb..72c028a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) diff --git a/frontend/css/style.css b/frontend/css/style.css index 39bf9c3..bb454e7 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -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; diff --git a/frontend/js/app.js b/frontend/js/app.js index 17849fb..191e83f 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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 `
Likely factors (common words in this window): ${tags}
`; } +// 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 ` (${ratio.toFixed(1)}× this instrument's typical ${baselinePct.toFixed(2)}% move)`; +} + 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("") : `

No strongly-matching stories found in that window.

`; return ` -

${arrow} ${s.pct_change.toFixed(2)}% +

${arrow} ${s.pct_change.toFixed(2)}%${volatilityLabel(s.pct_change, s.baseline_volatility_pct)} at ${new Date(s.detected_at).toLocaleString()} (${s.from_price.toLocaleString()} → ${s.to_price.toLocaleString()})

${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 = `

No moves ≥ the spike threshold recorded yet.

`; return; } - listEl.innerHTML = incidents.map(econIncidentHtml).join(""); + listEl.innerHTML = currentIncidents.map((inc, idx) => econIncidentHtml(inc, idx)).join(""); } catch (e) { listEl.innerHTML = `

Could not load incident history.

`; } @@ -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` : ""} ${keywordTagsHtml(inc.top_keywords)} + +
${articlesHtml}
`; } +// ---- 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 = `

Loading…

`; + incEl.innerHTML = `

Loading…

`; + + 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 = `

Could not load comparison charts.

`; + 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 = `

Not enough price history in this window yet.

`; + 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 `
${escapeHtml(s.label)} ${nearest.pct >= 0 ? "+" : ""}${nearest.pct.toFixed(2)}%
`; + }) + .join(""); + tooltip.innerHTML = `
${new Date(t).toLocaleString()}
${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 `${escapeHtml(s.label)}`; + }) + .join(""); + container.appendChild(legend); +} + // -------------------------------------------------------------- flights -- // // Rendered as a single THREE.Points cloud (not three-globe's per-datum