stocks / static /js /chart-card-element.js
Arrechenash's picture
Initial Commit
54cf8fd
class StockChartCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.chart = null;
this.candlestickSeries = null;
this.volumeSeries = null;
this.vwapSeries = null;
this.resizeObserver = null;
this.intersectionObserver = null;
this.isLoaded = false;
this.latestCandle = null;
this.latestVolume = null;
this.isExpanded = false;
this.chartGeneration = 0;
this.fetchRequestId = 0;
}
expand() {
if (this.isExpanded) {
this.collapse();
} else {
this.isExpanded = true;
this.classList.add("expanded");
this._handleEsc = (e) => {
if (e.key === "Escape") this.collapse();
};
document.addEventListener("keydown", this._handleEsc);
setTimeout(() => this.reinitializeChart?.(), 100);
}
}
collapse() {
this.isExpanded = false;
this.classList.remove("expanded");
if (this._handleEsc) {
document.removeEventListener("keydown", this._handleEsc);
this._handleEsc = null;
}
setTimeout(() => this.reinitializeChart?.(), 100);
}
static get observedAttributes() {
return [
"symbol",
"timeframe",
"source",
"show-actions",
"markers",
"context",
"signal-date",
"date-range",
"compact",
];
}
connectedCallback() {
this.render();
// Ensure LightweightCharts is loaded before initChart
if (typeof LightweightCharts === "undefined") {
console.error("LightweightCharts not loaded, waiting...");
// Wait for LightweightCharts to load
const checkAndInit = () => {
if (typeof LightweightCharts !== "undefined") {
this.initChart();
this._doPostInit();
} else {
setTimeout(checkAndInit, 50);
}
};
checkAndInit();
} else {
this.initChart();
this._doPostInit();
}
}
_doPostInit() {
// Use setTimeout to allow element to be connected to DOM before checking
// This handles the case where element is created via innerHTML before being appended
setTimeout(() => {
const isInModal = this.closest("#analyze-modal") !== null;
if (isInModal) {
this.isLoaded = true;
this.fetchData();
} else {
this.setupLazyLoading();
}
}, 0);
}
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal === newVal) return;
if (name === "timeframe" || name === "symbol") {
// Always recreate chart when timeframe changes to ensure VWAP series is correct
if (name === "timeframe" && this.isLoaded && oldVal && newVal) {
this.destroyChart();
this.initChart();
}
if (this.isLoaded) this.fetchData();
} else if (name === "markers") {
this.updateMarkers();
} else if (name === "context") {
// No direct action, used in fetchData
} else if (name === "compact") {
this.destroyChart();
this.render();
this.initChart();
if (this.isLoaded) this.fetchData();
} else if (name === "date-range") {
if (this.isLoaded) this.fetchData();
} else {
this.render();
}
}
setupLazyLoading() {
this.intersectionObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !this.isLoaded) {
this.isLoaded = true;
this.fetchData();
}
},
{ rootMargin: "200px" },
);
this.intersectionObserver.observe(this);
}
render() {
const symbol = this.getAttribute("symbol") || "--";
const timeframe =
this.getAttribute("timeframe") ||
localStorage.getItem(`tf-${symbol}`) ||
"1D";
const showActions = this.hasAttribute("show-actions");
const compact = this.hasAttribute("compact");
if (compact) {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
background: #000;
min-height: 240px;
}
.body {
padding: 0;
position: relative;
}
.chart-container {
height: 240px;
width: 100%;
background: #000;
}
.overlay {
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
pointer-events: none;
background: rgba(10, 10, 10, 0.85);
backdrop-filter: blur(4px);
padding: 3px 7px;
border-radius: 4px;
border: 1px solid #222;
font-family: system-ui, sans-serif;
font-size: 0.65rem;
color: #666;
}
.expand-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(10, 10, 10, 0.7);
border: 1px solid #222;
color: #666;
padding: 2px 5px;
font-size: 0.65rem;
border-radius: 4px;
cursor: pointer;
z-index: 20;
}
.expand-btn:hover {
background: #fff;
color: #000;
}
:host(.expanded) {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
margin: 0;
background: #000;
}
:host(.expanded) .chart-container {
height: 100vh !important;
}
</style>
<div class="body">
<button class="expand-btn" onclick="this.getRootNode().host.expand()">⛶</button>
<div class="overlay" id="ohlcv">Loading data...</div>
<div class="chart-container" id="chart"></div>
</div>
`;
return;
}
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
background: var(--bg-elevated, #0a0a0a);
border: 1px solid var(--border, #222222);
border-radius: 4px;
overflow: hidden;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
min-height: 340px;
}
.header {
padding: var(--space-sm, 8px) var(--space-md, 16px);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border, #222222);
}
.symbol {
color: var(--accent, #ffffff);
font-size: var(--font-lg, 1rem);
font-weight: 700;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.15s;
}
.symbol:hover {
opacity: 0.8;
}
.date {
font-size: var(--font-xs, 0.65rem);
color: var(--text-muted, #666666);
font-weight: 600;
}
.body {
padding: var(--space-sm, 8px);
position: relative;
}
.chart-container {
height: 240px;
width: 100%;
background: #000;
border-radius: 4px;
}
.overlay {
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
pointer-events: none;
background: rgba(10, 10, 10, 0.85);
backdrop-filter: blur(4px);
padding: 3px 7px;
border-radius: 4px;
border: 1px solid var(--border, #222222);
font-family: system-ui, sans-serif;
font-size: 0.65rem;
color: var(--text-muted, #666666);
}
.footer {
padding: var(--space-sm, 8px) var(--space-md, 16px);
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border, #222222);
}
.metrics {
display: flex;
gap: var(--space-md, 16px);
align-items: center;
}
.price {
font-weight: 600;
font-size: var(--font-md, 0.875rem);
color: var(--text, #ffffff);
}
.change {
font-weight: 600;
font-size: var(--font-sm, 0.75rem);
}
.volume {
font-weight: 600;
font-size: var(--font-xs, 0.65rem);
color: var(--text-muted, #666666);
}
.positive { color: #089981; }
.negative { color: #f23645; }
.date-left {
font-size: var(--font-xs, 0.65rem);
color: var(--text-muted, #666666);
font-weight: 600;
}
.tf-selector {
display: flex;
gap: 4px;
background: var(--bg, #000000);
padding: 2px;
border-radius: 4px;
}
.tf-btn {
background: transparent;
border: none;
color: var(--text-muted, #666666);
padding: 3px 7px;
font-size: 0.65rem;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
}
.tf-btn.active {
background: var(--accent, #ffffff);
color: #000;
}
.data-value.positive { color: #089981; }
.data-value.negative { color: #f23645; }
.badge-high { background: #f23645; color: #fff; }
.badge-low { background: #089981; color: #fff; }
.badge-bullish { background: #089981; color: #fff; }
.badge-bearish { background: #f23645; color: #fff; }
.badge-neutral { background: #666; color: #fff; }
.filing-item {
padding: 6px 0;
border-bottom: 1px solid var(--border, #222);
font-size: 0.65rem;
}
.filing-form {
font-weight: 700;
color: var(--accent, #fff);
margin-right: 6px;
}
.filing-date {
color: var(--text-muted, #888);
margin-right: 6px;
}
.expand-btn {
background: transparent;
border: none;
color: var(--text-muted, #666);
padding: 2px 6px;
font-size: 0.65rem;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
line-height: 1;
}
.expand-btn:hover {
background: var(--accent, #fff);
color: #000;
}
:host(.expanded) {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
margin: 0;
background: #000;
}
:host(.expanded) .chart-container {
height: 100vh !important;
}
</style>
<div class="header">
<span class="symbol" onclick="openAnalyzeModalFor('${symbol}')">${symbol}</span>
<div style="display: flex; align-items: center; gap: 8px;">
<button class="expand-btn" onclick="this.getRootNode().host.expand()">⛶</button>
<div class="tf-selector">
${["1D", "1Y", "1H", "24H", "30M", "15M", "5M", "1M"]
.map(
(tf) => `
<button class="tf-btn ${tf === timeframe ? "active" : ""}" data-tf="${tf}">${tf}</button>
`,
)
.join("")}
</div>
</div>
</div>
<div class="body">
<div class="overlay" id="ohlcv">Loading data...</div>
<div class="chart-container" id="chart"></div>
</div>
<div class="footer">
<div class="metrics">
<span class="date-left" id="date">--</span>
<span class="price" id="price">--</span>
<span class="change" id="change">--</span>
<span class="volume" id="volume">--</span>
</div>
${showActions ? `<div class="actions"><slot name="actions"></slot></div>` : ""}
</div>
`;
this.shadowRoot.querySelectorAll(".tf-btn").forEach((btn) => {
btn.onclick = (e) => {
e.stopPropagation();
const tf = btn.dataset.tf;
this.setAttribute("timeframe", tf);
localStorage.setItem(`tf-${symbol}`, tf);
this.updateActiveTf(tf);
};
});
}
updateActiveTf(tf) {
this.shadowRoot.querySelectorAll(".tf-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tf === tf);
});
}
destroyChart() {
this.fetchRequestId += 1;
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.chart) {
this.chart.remove();
this.chart = null;
this.candlestickSeries = null;
this.volumeSeries = null;
this.vwapSeries = null;
}
}
initChart() {
const container = this.shadowRoot.getElementById("chart");
const symbol = this.getAttribute("symbol");
const timeframe =
this.getAttribute("timeframe") ||
localStorage.getItem(`tf-${symbol}`) ||
"1D";
// Clear container and reset resize observer
container.innerHTML = "";
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this.chart = LightweightCharts.createChart(container, {
autoSize: true,
layout: {
background: { color: "#000000" },
textColor: "#666666",
fontSize: 9,
},
grid: {
vertLines: { color: "#222222" },
horzLines: { color: "#222222" },
},
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
rightPriceScale: {
borderColor: "#222222",
autoScale: true,
scaleMargins: { top: 0.1, bottom: 0.2 },
},
timeScale: {
borderColor: "#222222",
timeVisible: true,
barSpacing: 4,
timeZone: "America/New_York",
},
});
this.candlestickSeries = this.chart.addCandlestickSeries({
upColor: "#089981",
downColor: "#f23645",
borderUpColor: "#089981",
borderDownColor: "#f23645",
wickUpColor: "#089981",
wickDownColor: "#f23645",
});
this.volumeSeries = this.chart.addHistogramSeries({
color: "#089981",
priceFormat: { type: "volume" },
priceScaleId: "",
});
this.volumeSeries
.priceScale()
.applyOptions({ scaleMargins: { top: 0.75, bottom: 0 } });
// Add VWAP line series for intraday timeframes
const isIntraday = !["1D", "1Y"].includes(timeframe);
if (isIntraday) {
this.vwapSeries = this.chart.addLineSeries({
color: "#FFFF00",
lineWidth: 1,
crosshairMarkerVisible: false,
lastValueVisible: true,
lastValueColor: "#FFFF00",
priceLineVisible: true,
priceLineColor: "#FFFF00",
priceLineWidth: 1,
priceLineStyle: 2,
title: "VWAP",
});
}
this.chart.subscribeCrosshairMove((param) => {
if (param.time && param.seriesData) {
const candle = param.seriesData.get(this.candlestickSeries);
const volume = param.seriesData.get(this.volumeSeries);
this.updateOhlcvOverlay(candle, volume?.value);
} else {
this.updateOhlcvOverlay(this.latestCandle, this.latestVolume);
}
});
this.resizeObserver = new ResizeObserver((entries) => {
if (entries[0] && this.chart) {
this.chart.applyOptions({ width: entries[0].contentRect.width });
}
});
this.resizeObserver.observe(container);
this.chartGeneration += 1;
}
updateOhlcvOverlay(candle, volume) {
const ohlcvEl = this.shadowRoot.getElementById("ohlcv");
if (candle) {
const volStr = window.FinancialAPI
? FinancialAPI.formatVolume(volume)
: volume;
// Show NY time in the overlay for intraday
const timeStr =
window.FinancialAPI && candle.time && typeof candle.time === "number"
? FinancialAPI.formatNyTime(candle.time)
: "";
ohlcvEl.textContent = timeStr
? `${timeStr} | O: ${candle.open.toFixed(2)} H: ${candle.high.toFixed(2)} L: ${candle.low.toFixed(2)} C: ${candle.close.toFixed(2)} V: ${volStr}`
: `O: ${candle.open.toFixed(2)} H: ${candle.high.toFixed(2)} L: ${candle.low.toFixed(2)} C: ${candle.close.toFixed(2)} V: ${volStr}`;
}
}
reinitializeChart() {
// Destroy and recreate chart with correct dimensions
this.destroyChart();
this.initChart();
// Fetch data again
this.isLoaded = true;
this.fetchData();
}
updateMarkers() {
if (!this.candlestickSeries) return;
const markersAttr = this.getAttribute("markers");
try {
const markers = JSON.parse(markersAttr || "[]");
this.candlestickSeries.setMarkers(markers);
} catch (e) {
console.warn("Invalid markers JSON", markersAttr);
}
}
async addReverseSplitMarkers(candles, candlestickSeries) {
const symbol = this.getAttribute("symbol");
if (!symbol || !candlestickSeries) return;
const isInModal = this.closest("#analyze-modal") !== null;
const chartGeneration = this.chartGeneration;
try {
// Fetch reverse splits for this symbol (last 90 days)
// Use batch method if available (for watchlist), otherwise single fetch
let reverseSplits;
if (window.FinancialAPI && FinancialAPI.fetchReverseSplitsBatch && !isInModal) {
// Batch fetch will use cache if available
const allSplits = await FinancialAPI.fetchReverseSplitsBatch(
[symbol],
90,
);
reverseSplits = allSplits.filter(
(rs) => rs.symbol === symbol.toUpperCase(),
);
} else {
reverseSplits = await FinancialAPI.fetchReverseSplits(symbol, 90, {
forceFresh: isInModal,
});
}
if (!reverseSplits || reverseSplits.length === 0) return;
// Get existing markers from attribute
const existingMarkers = JSON.parse(this.getAttribute("markers") || "[]");
// Create markers for reverse splits - use price axis markers
const rsMarkers = reverseSplits.map((rs) => {
// Find the candle closest to the reverse split date
const rsTime = new Date(rs.ex_date + "T09:30:00").getTime() / 1000;
// Find the candle on that date
const closestCandle = candles.find((c) => {
const candleTime =
typeof c.time === "number"
? c.time
: new Date(c.time).getTime() / 1000;
return candleTime >= rsTime && candleTime < rsTime + 86400;
});
// Position at the low of the day for visibility
const price = closestCandle ? closestCandle.low : 0;
return {
time: rsTime,
position: "inBar",
color: "#FF4444",
shape: "circle",
text: `RS ${rs.ratio}`,
size: 0.5,
};
});
// Combine existing markers with reverse split markers
const allMarkers = [...existingMarkers, ...rsMarkers];
if (
chartGeneration !== this.chartGeneration ||
!this.candlestickSeries ||
candlestickSeries !== this.candlestickSeries
) {
return;
}
this.candlestickSeries.setMarkers(allMarkers);
} catch (e) {
console.warn("Error fetching reverse splits:", e);
}
}
async fetchData() {
const symbol = this.getAttribute("symbol");
const timeframe =
this.getAttribute("timeframe") ||
localStorage.getItem(`tf-${symbol}`) ||
"1D";
const context = this.getAttribute("context") || "default";
const dateRange = this.getAttribute("date-range");
if (!symbol) return;
const requestId = ++this.fetchRequestId;
// Guard: ensure chart is initialized before fetching data
// Save references now, then verify they are still current after awaits.
const candlestickSeries = this.candlestickSeries;
const volumeSeries = this.volumeSeries;
const vwapSeries = this.vwapSeries;
const chart = this.chart;
const chartGeneration = this.chartGeneration;
if (!candlestickSeries || !chart) {
console.warn("fetchData: chart not initialized, retrying in 100ms", {
hasChart: !!chart,
hasCandle: !!candlestickSeries,
symbol,
});
setTimeout(() => this.fetchData(), 100);
return;
}
const isStale = () =>
requestId !== this.fetchRequestId ||
chartGeneration !== this.chartGeneration ||
chart !== this.chart ||
candlestickSeries !== this.candlestickSeries ||
volumeSeries !== this.volumeSeries ||
vwapSeries !== this.vwapSeries;
try {
let result;
if (dateRange && context === "scanner") {
const [start, end] = dateRange.split("|");
const response = await fetch(
`/api/scan?symbols=${symbol}&timeframe=${timeframe}&start=${start}&end=${end}`,
);
const scanResult = await response.json();
const item = scanResult.results?.find((r) => r.symbol === symbol);
if (!item || !item.daily_bars?.length) {
const ohlcvEl = this.shadowRoot.getElementById("ohlcv");
if (ohlcvEl) ohlcvEl.textContent = "No data available";
return;
}
result = FinancialAPI.mapToYahooFormat(item.daily_bars);
} else {
result = await FinancialAPI.fetchChart(symbol, timeframe, context);
}
if (isStale()) return;
const { candles, volumes, vwap } = FinancialAPI.processChartData(
result,
timeframe,
);
// Filter candles by date-range if provided
let filteredCandles = candles;
let filteredVolumes = volumes;
if (dateRange && candles?.length) {
const [startStr, endStr] = dateRange.split("|");
filteredCandles = candles.filter((c) => {
const t = typeof c.time === "number" ? c.time : new Date(c.time + "T00:00:00Z").getTime() / 1000;
const start = Math.floor(new Date(startStr + "T00:00:00Z").getTime() / 1000);
const end = Math.floor(new Date(endStr + "T23:59:59Z").getTime() / 1000);
return t >= start && t <= end;
});
filteredVolumes = volumes.filter((v) => {
const t = typeof v.time === "number" ? v.time : new Date(v.time + "T00:00:00Z").getTime() / 1000;
const start = Math.floor(new Date(startStr + "T00:00:00Z").getTime() / 1000);
const end = Math.floor(new Date(endStr + "T23:59:59Z").getTime() / 1000);
return t >= start && t <= end;
});
}
// Handle empty data gracefully
if (!filteredCandles || filteredCandles.length === 0) {
const ohlcvEl = this.shadowRoot.getElementById("ohlcv");
if (ohlcvEl) ohlcvEl.textContent = "No data available";
return;
}
if (isStale()) return;
candlestickSeries.setData(filteredCandles);
volumeSeries.setData(filteredVolumes);
if (isStale()) return;
if (vwapSeries && vwap && vwap.length > 0) {
vwapSeries.setData(vwap);
}
// Set visible range to show all data
if (filteredCandles.length > 0) {
const firstTime = typeof filteredCandles[0].time === "number"
? filteredCandles[0].time
: new Date(filteredCandles[0].time).getTime() / 1000;
const lastTime = typeof filteredCandles[filteredCandles.length - 1].time === "number"
? filteredCandles[filteredCandles.length - 1].time
: new Date(filteredCandles[filteredCandles.length - 1].time).getTime() / 1000;
chart.timeScale().setVisibleRange({
from: firstTime,
to: lastTime,
});
}
// Fetch and add reverse split markers (non-blocking)
this.addReverseSplitMarkers(filteredCandles, candlestickSeries);
// Handle zoom and centering based on timeframe and signal
const signalDate = this.getAttribute("signal-date");
const barsToShow =
timeframe === "1D" ? 40 : timeframe === "1Y" ? 252 : timeframe === "24H" ? 10 : 90;
if (signalDate) {
// Find the signal bar index
const signalTime = new Date(signalDate + "T00:00:00Z").getTime() / 1000;
const signalIdx = filteredCandles.findIndex((c) => {
const candleTime =
typeof c.time === "number"
? c.time
: new Date(c.time).getTime() / 1000;
return candleTime >= signalTime && candleTime < signalTime + 86400;
});
if (signalIdx !== -1) {
// Center on signal, show barsToShow bars around it
const startIdx = Math.max(0, signalIdx - Math.floor(barsToShow / 2));
const endIdx = Math.min(
filteredCandles.length,
signalIdx + Math.ceil(barsToShow / 2) + 1,
);
if (filteredCandles[startIdx] && filteredCandles[endIdx - 1]) {
const startTime =
typeof filteredCandles[startIdx].time === "number"
? filteredCandles[startIdx].time
: new Date(filteredCandles[startIdx].time).getTime() / 1000;
const endTime =
typeof filteredCandles[endIdx - 1].time === "number"
? filteredCandles[endIdx - 1].time
: new Date(filteredCandles[endIdx - 1].time).getTime() / 1000;
chart.timeScale().setVisibleRange({
from: startTime,
to: endTime,
});
}
} else {
// Signal not in data, show last barsToShow
chart.timeScale().setVisibleRange({
from: filteredCandles[filteredCandles.length - barsToShow]?.time,
to: filteredCandles[filteredCandles.length - 1]?.time,
});
}
} else {
// Default: show last barsToShow
const startIdx = Math.max(0, filteredCandles.length - barsToShow);
chart.timeScale().setVisibleRange({
from: filteredCandles[startIdx].time,
to: filteredCandles[filteredCandles.length - 1].time,
});
}
this.updateMarkers();
if (filteredCandles.length > 0) {
this.latestCandle = filteredCandles[filteredCandles.length - 1];
this.latestVolume =
filteredVolumes.length > 0 ? filteredVolumes[filteredVolumes.length - 1].value : 0;
// Calculate change: current close vs previous candle close (standard day-over-day)
// Use the second-to-last candle from filtered data if available, otherwise use first
let change = 0;
if (filteredCandles.length >= 2) {
const prevCandle = filteredCandles[filteredCandles.length - 2];
change = ((this.latestCandle.close - prevCandle.close) / prevCandle.close) * 100;
}
const priceEl = this.shadowRoot.getElementById("price");
if (priceEl) priceEl.textContent = `$${this.latestCandle.close.toFixed(2)}`;
const changeEl = this.shadowRoot.getElementById("change");
if (changeEl) {
changeEl.textContent = `${change >= 0 ? "+" : ""}${change.toFixed(2)}%`;
changeEl.className = `change ${change >= 0 ? "positive" : "negative"}`;
}
if (this.latestVolume) {
const volEl = this.shadowRoot.getElementById("volume");
if (volEl) volEl.textContent = FinancialAPI.formatVolume(this.latestVolume);
}
const signalDate = this.getAttribute("signal-date");
const dateEl = this.shadowRoot.getElementById("date");
if (dateEl) {
if (signalDate) {
dateEl.textContent = signalDate;
} else {
const timeStr =
typeof this.latestCandle.time === "number"
? new Date(this.latestCandle.time * 1000).toLocaleString()
: this.latestCandle.time;
dateEl.textContent = timeStr;
}
}
this.updateOhlcvOverlay(this.latestCandle, this.latestVolume);
}
} catch (e) {
console.error("Fetch error:", e);
const ohlcvEl = this.shadowRoot.getElementById("ohlcv");
if (ohlcvEl) ohlcvEl.textContent = "Error loading data";
}
}
disconnectedCallback() {
if (this.intersectionObserver) this.intersectionObserver.disconnect();
this.destroyChart();
}
}
customElements.define("stock-chart-card", StockChartCard);