Spaces:
Running
Running
| import { Component, ElementRef, OnDestroy, OnInit, AfterViewInit, ViewChild } from '@angular/core'; | |
| import { CommonModule } from '@angular/common'; | |
| import { HttpClientModule } from '@angular/common/http'; | |
| import { DashboardService } from './dashboard.service'; | |
| import { take } from 'rxjs/operators'; | |
| import { lastValueFrom } from 'rxjs'; | |
| type IndexItem = { code: string; price: number; chg: number; miniPath?: string; isUp?: boolean }; | |
| type Company = { sym: string; ltp: number; chg: number; high: number; low: number }; | |
| type Sector = { name: string; picks: string[] }; | |
| type MarketCard = { title: string; value: string; chg: string; dir: 'up' | 'down' | 'neutral' }; | |
| type GlobalIndex = { id: string; name: string; country: string; region: string; price: number; change: number; changePct: number; sparkline: number[]; miniPath?: string; isUp?: boolean }; | |
| ({ | |
| selector: 'app-dashboard', | |
| standalone: true, | |
| imports: [CommonModule, HttpClientModule], // IndexCardComponent], | |
| templateUrl: './dashboard.component.html', | |
| styleUrls: ['./dashboard.component.scss'], | |
| }) | |
| export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy { | |
| // Demo data (used as fallback until live data loads) | |
| indices: IndexItem[] = [ | |
| { code: 'NIFTY 50', price: 24480.55, chg: +0.35 }, | |
| { code: 'BANK NIFTY', price: 51880.20, chg: -0.12 }, | |
| { code: 'SENSEX', price: 80820.40, chg: +0.28 }, | |
| { code: 'NIFTY MIDCAP', price: 53000.10, chg: +0.45 }, | |
| { code: 'NIFTY SMALLCAP', price: 17350.60, chg: -0.18 }, | |
| ]; | |
| // Market overview cards (populated live when API responds) | |
| // initialize placeholders to em dash so UI doesn't show raw 0 or empty strings | |
| marketCards: MarketCard[] = [ | |
| { title: 'Gold', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'Silver', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'Crude Oil (Brent)', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'Crude Oil (WTI)', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'Natural Gas', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'USD/INR', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'EUR/USD', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'GBP/USD', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'Bitcoin', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'Ethereum', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'S&P 500', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'NASDAQ', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'DAX', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'Nikkei', value: 'β', chg: 'β', dir: 'neutral' }, | |
| { title: 'Copper', value: 'β', chg: 'β', dir: 'neutral' } | |
| ]; | |
| // expose duplicated list for seamless loop in template | |
| get marketCardsRepeat(): MarketCard[] { | |
| return [...this.marketCards, ...this.marketCards]; | |
| } | |
| companiesByIndex: Record<string, Company[]> = { | |
| 'NIFTY 50': [ | |
| { sym: 'RELIANCE', ltp: 2950.30, chg: +0.85, high: 2972.0, low: 2920.0 }, | |
| { sym: 'TCS', ltp: 4175.90, chg: -0.34, high: 4210.0, low: 4152.0 }, | |
| { sym: 'INFY', ltp: 1622.40, chg: +0.42, high: 1631.5, low: 1605.2 }, | |
| { sym: 'HDFCBANK', ltp: 1548.10, chg: +0.12, high: 1556.0, low: 1531.5 }, | |
| { sym: 'ICICIBANK', ltp: 1244.65, chg: -0.25, high: 1255.0, low: 1238.0 }, | |
| ], | |
| 'BANK NIFTY': [ | |
| { sym: 'SBIN', ltp: 884.30, chg: +0.70, high: 892.0, low: 876.2 }, | |
| { sym: 'AXISBANK', ltp: 1204.70, chg: -0.31, high: 1219.0, low: 1198.0 }, | |
| { sym: 'KOTAKBANK', ltp: 1744.50, chg: +0.15, high: 1752.0, low: 1732.0 }, | |
| { sym: 'PNB', ltp: 119.80, chg: +1.25, high: 121.3, low: 118.4 }, | |
| { sym: 'BANKBARODA', ltp: 278.20, chg: -0.44, high: 281.8, low: 276.5 }, | |
| ], | |
| 'SENSEX': [ | |
| { sym: 'LT', ltp: 3822.40, chg: +0.34, high: 3839.0, low: 3788.5 }, | |
| { sym: 'ASIANPAINT', ltp: 3221.60, chg: -0.28, high: 3245.0, low: 3200.2 }, | |
| { sym: 'ITC', ltp: 496.70, chg: +0.48, high: 499.5, low: 492.6 }, | |
| { sym: 'HCLTECH', ltp: 1822.10, chg: +0.22, high: 1833.0, low: 1808.0 }, | |
| { sym: 'BHARTIARTL', ltp: 1412.55, chg: -0.12, high: 1423.0, low: 1407.0 }, | |
| ], | |
| 'NIFTY MIDCAP': [ | |
| { sym: 'TATAELXSI', ltp: 9175.00, chg: +0.90, high: 9250.0, low: 9080.0 }, | |
| { sym: 'AUBANK', ltp: 658.50, chg: -0.35, high: 666.0, low: 652.0 }, | |
| { sym: 'INDHOTEL', ltp: 692.10, chg: +0.55, high: 699.0, low: 684.5 }, | |
| { sym: 'TVSMOTOR', ltp: 2088.20, chg: +0.20, high: 2105.0, low: 2068.0 }, | |
| { sym: 'ABBOTINDIA', ltp: 28200.00, chg: -0.40, high: 28450.0, low: 28000.0 }, | |
| ], | |
| 'NIFTY SMALLCAP': [ | |
| { sym: 'TANLA', ltp: 1115.10, chg: +1.12, high: 1134.0, low: 1101.0 }, | |
| { sym: 'MAPMYINDIA', ltp: 2050.30, chg: -0.50, high: 2076.0, low: 2036.0 }, | |
| { sym: 'KEI', ltp: 4965.00, chg: +0.44, high: 5012.0, low: 4920.0 }, | |
| { sym: 'PNCINFRA', ltp: 475.75, chg: +0.18, high: 482.0, low: 470.0 }, | |
| { sym: 'TARC', ltp: 128.60, chg: -0.22, high: 131.0, low: 127.8 }, | |
| ], | |
| }; | |
| // Create demo US companies per index so Companies table and sectors show US firms when 'US' selected | |
| private createUSCompaniesByIndex(): Record<string, Company[]> { | |
| return { | |
| 'S&P 500': [ | |
| { sym: 'AAPL', ltp: 175.32, chg: +1.2, high: 176.5, low: 172.5 }, | |
| { sym: 'MSFT', ltp: 345.10, chg: -0.5, high: 348.0, low: 340.2 }, | |
| { sym: 'AMZN', ltp: 140.25, chg: +0.8, high: 142.0, low: 138.0 }, | |
| { sym: 'GOOGL', ltp: 128.70, chg: +0.4, high: 130.0, low: 127.0 }, | |
| { sym: 'META', ltp: 310.45, chg: -0.9, high: 316.0, low: 308.1 }, | |
| ], | |
| 'Dow Jones': [ | |
| { sym: 'MSFT', ltp: 345.10, chg: -0.5, high: 348.0, low: 340.2 }, | |
| { sym: 'UNH', ltp: 480.22, chg: +0.3, high: 482.0, low: 475.0 }, | |
| { sym: 'V', ltp: 230.12, chg: +0.6, high: 231.5, low: 228.0 }, | |
| { sym: 'JPM', ltp: 140.50, chg: -0.2, high: 142.0, low: 139.0 }, | |
| { sym: 'GS', ltp: 360.75, chg: +0.1, high: 362.0, low: 358.0 }, | |
| ], | |
| 'Nasdaq Composite': [ | |
| { sym: 'AAPL', ltp: 175.32, chg: +1.2, high: 176.5, low: 172.5 }, | |
| { sym: 'MSFT', ltp: 345.10, chg: -0.5, high: 348.0, low: 340.2 }, | |
| { sym: 'TSLA', ltp: 210.44, chg: +2.5, high: 215.0, low: 205.5 }, | |
| { sym: 'NVDA', ltp: 420.55, chg: +3.1, high: 430.2, low: 410.0 }, | |
| { sym: 'ADBE', ltp: 485.60, chg: -0.6, high: 490.0, low: 482.0 }, | |
| ], | |
| 'S&P MidCap 400': [ | |
| { sym: 'ODFL', ltp: 150.12, chg: +0.9, high: 152.0, low: 148.0 }, | |
| { sym: 'LVS', ltp: 80.34, chg: -0.4, high: 82.0, low: 79.0 }, | |
| { sym: 'UAL', ltp: 52.10, chg: +1.5, high: 53.0, low: 50.5 }, | |
| { sym: 'NWL', ltp: 30.22, chg: -0.2, high: 31.0, low: 29.5 }, | |
| { sym: 'EXPD', ltp: 115.44, chg: +0.7, high: 117.0, low: 114.0 }, | |
| ], | |
| 'S&P SmallCap 600': [ | |
| { sym: 'AEO', ltp: 25.12, chg: +0.5, high: 25.8, low: 24.5 }, | |
| { sym: 'CNC', ltp: 45.22, chg: -0.3, high: 46.0, low: 44.0 }, | |
| { sym: 'FLEX', ltp: 12.34, chg: +2.0, high: 12.8, low: 11.9 }, | |
| { sym: 'BOKF', ltp: 55.66, chg: -1.1, high: 57.0, low: 55.0 }, | |
| { sym: 'PRXL', ltp: 8.90, chg: +0.2, high: 9.2, low: 8.5 }, | |
| ] | |
| }; | |
| } | |
| // Create demo India companies per index so Companies table and sectors show India firms when 'India' selected | |
| private createIndiaCompaniesByIndex(): Record<string, Company[]> { | |
| return { | |
| 'NIFTY 50': [ | |
| { sym: 'RELIANCE', ltp: 2950.30, chg: 0.85, high: 2972.0, low: 2920.0 }, | |
| { sym: 'TCS', ltp: 4175.90, chg: -0.34, high: 4210.0, low: 4152.0 }, | |
| { sym: 'INFY', ltp: 1622.40, chg: 0.42, high: 1631.5, low: 1605.2 }, | |
| { sym: 'HDFCBANK', ltp: 1548.10, chg: 0.12, high: 1556.0, low: 1531.5 }, | |
| { sym: 'ICICIBANK', ltp: 1244.65, chg: -0.25, high: 1255.0, low: 1238.0 }, | |
| ], | |
| 'BANK NIFTY': [ | |
| { sym: 'SBIN', ltp: 884.30, chg: 0.70, high: 892.0, low: 876.2 }, | |
| { sym: 'AXISBANK', ltp: 1204.70, chg: -0.31, high: 1219.0, low: 1198.0 }, | |
| { sym: 'KOTAKBANK', ltp: 1744.50, chg: 0.15, high: 1752.0, low: 1732.0 }, | |
| { sym: 'PNB', ltp: 119.80, chg: 1.25, high: 121.3, low: 118.4 }, | |
| { sym: 'BANKBARODA', ltp: 278.20, chg: -0.44, high: 281.8, low: 276.5 }, | |
| ], | |
| 'SENSEX': [ | |
| { sym: 'LT', ltp: 3822.40, chg: 0.34, high: 3839.0, low: 3788.5 }, | |
| { sym: 'ASIANPAINT', ltp: 3221.60, chg: -0.28, high: 3245.0, low: 3200.2 }, | |
| { sym: 'ITC', ltp: 496.70, chg: 0.48, high: 499.5, low: 492.6 }, | |
| { sym: 'HCLTECH', ltp: 1822.10, chg: 0.22, high: 1833.0, low: 1808.0 }, | |
| { sym: 'BHARTIARTL', ltp: 1412.55, chg: -0.12, high: 1423.0, low: 1407.0 }, | |
| ], | |
| 'NIFTY MIDCAP': [ | |
| { sym: 'TATAELXSI', ltp: 9175.00, chg: 0.90, high: 9250.0, low: 9080.0 }, | |
| { sym: 'AUBANK', ltp: 658.50, chg: -0.35, high: 666.0, low: 652.0 }, | |
| { sym: 'INDHOTEL', ltp: 692.10, chg: 0.55, high: 699.0, low: 684.5 }, | |
| { sym: 'TVSMOTOR', ltp: 2088.20, chg: 0.20, high: 2105.0, low: 2068.0 }, | |
| { sym: 'ABBOTINDIA', ltp: 28200.00, chg: -0.40, high: 28450.0, low: 28000.0 }, | |
| ], | |
| 'NIFTY SMALLCAP': [ | |
| { sym: 'TANLA', ltp: 1115.10, chg: 1.12, high: 1134.0, low: 1101.0 }, | |
| { sym: 'MAPMYINDIA', ltp: 2050.30, chg: -0.50, high: 2076.0, low: 2036.0 }, | |
| { sym: 'KEI', ltp: 4965.00, chg: 0.44, high: 5012.0, low: 4920.0 }, | |
| { sym: 'PNCINFRA', ltp: 475.75, chg: 0.18, high: 482.0, low: 470.0 }, | |
| { sym: 'TARC', ltp: 128.60, chg: -0.22, high: 131.0, low: 127.8 }, | |
| ], | |
| }; | |
| } | |
| // Create sector picks for US so sector grid shows US companies when 'US' selected | |
| private createUSSectors(): Sector[] { | |
| return [ | |
| { name: 'Technology', picks: ['AAPL', 'MSFT', 'GOOGL', 'NVDA'] }, | |
| { name: 'Financials', picks: ['JPM', 'GS', 'BAC', 'C'] }, | |
| { name: 'Consumer Discretionary', picks: ['AMZN', 'TSLA', 'MCD'] }, | |
| { name: 'Healthcare', picks: ['UNH', 'JNJ', 'PFE'] }, | |
| { name: 'Industrials', picks: ['UNP', 'HON', 'CAT'] }, | |
| ]; | |
| } | |
| sectors: Sector[] = [ | |
| { name: 'IT & Software', picks: ['TCS', 'INFY', 'HCLTECH', 'TECHM'] }, | |
| { name: 'Banking & Finance', picks: ['HDFCBANK', 'ICICIBANK', 'SBIN', 'AXISBANK'] }, | |
| { name: 'Energy & Materials', picks: ['RELIANCE', 'ONGC', 'TATASTEEL', 'COALINDIA'] }, | |
| { name: 'Auto', picks: ['TATAMOTORS', 'MARUTI', 'M&M', 'TVSMOTOR'] }, | |
| { name: 'FMCG', picks: ['ITC', 'HINDUNILVR', 'NESTLEIND', 'DABAR'] }, | |
| { name: 'Telecom', picks: ['BHARTIARTL', 'VODAFONEIDE', 'TATACOMM', 'INDUSTOWER'] }, | |
| ]; | |
| get indexCodes(): string[] { | |
| return Object.keys(this.companiesByIndex); | |
| } | |
| activeIndex: string = this.indexCodes[0]; | |
| // currently selected company symbol for chart highlighting | |
| selectedCompany: string | null = null; | |
| gainers: Company[] = []; | |
| losers: Company[] = []; | |
| // New global indices lists | |
| globalIndices: GlobalIndex[] = []; | |
| indiaIndices: GlobalIndex[] = []; | |
| // All indices cache (includes India and others) | |
| private allGlobalIndices: GlobalIndex[] = []; | |
| // Country filter | |
| // default countries shown immediately (will be replaced by live data when available) | |
| countries: string[] = ['India', 'US', 'Uk', 'German', 'Sweden', 'Russia']; | |
| selectedCountry = 'India'; | |
| // common alias map so UI labels like 'US' or 'Uk' match backend country names | |
| private countryAliases: Record<string, string[]> = { | |
| 'us': ['united states', 'usa', 'us', 'u.s.a', 'u.s.'], | |
| 'uk': ['united kingdom', 'britain', 'uk', 'u.k.'], | |
| 'german': ['germany', 'german'], | |
| 'russia': ['russia', 'russian federation'], | |
| 'india': ['india'], | |
| 'sweden': ['sweden'] | |
| }; | |
| // demo list for India indices | |
| private defaultIndiaIndexNames: string[] = [ | |
| 'Nifty 50', | |
| 'Nifty Next 50', | |
| 'Nifty 100', | |
| 'Nifty 500', | |
| 'Nifty Bank', | |
| 'Nifty IT', | |
| 'Nifty Pharma', | |
| 'Nifty FMCG', | |
| 'Nifty Midcap 100', | |
| 'Nifty Smallcap 100' | |
| ]; | |
| // avoid repeated fetches in quick succession | |
| private globalFetched = false; | |
| // last updated timestamp shown in the header | |
| lastUpdated = 'β'; | |
| // chart symbol and timer handle | |
| chartSymbol = 'RELIANCE'; | |
| private tickHandle?: number; | |
| public quotesIntervalHandle?: number; | |
| ('chartCanvas', { static: false }) | |
| private canvasRef?: ElementRef<HTMLCanvasElement>; | |
| ('chartContainer', { static: false }) | |
| private chartContainerRef?: ElementRef<HTMLDivElement>; | |
| ('tickerTrack', { static: false }) | |
| private tickerTrackRef?: ElementRef<HTMLDivElement>; | |
| private marqueeAnimationId?: number; | |
| private marqueeResizeObserver?: ResizeObserver | null = null; | |
| private marqueeMutationObserver?: MutationObserver | null = null; | |
| private marqueeMeasureTimeout?: any; | |
| private lastDurationSec?: number | null = null; | |
| // priority lists of important indices per country (used to order and pick indices from live payload) | |
| private importantIndicesMap: Record<string, string[]> = { | |
| 'india': [ | |
| 'Nifty 50', 'Nifty Bank', 'SENSEX', 'Nifty Next 50', 'Nifty 100', 'Nifty 500', 'Nifty IT', 'Nifty Pharma', 'Nifty FMCG', 'Nifty Midcap 100', 'Nifty Smallcap 100' | |
| ], | |
| 'us': ['S&P 500', 'Dow Jones', 'Nasdaq Composite', 'S&P MidCap 400', 'S&P SmallCap 600'], | |
| 'uk': ['FTSE 100', 'FTSE 250', 'FTSE 350'], | |
| 'german': ['DAX', 'MDAX', 'TecDAX'], | |
| 'sweden': ['OMX Stockholm 30'], | |
| 'russia': ['MOEX Russia', 'RTS Index'] | |
| }; | |
| private uniqueByName(list: GlobalIndex[]): GlobalIndex[] { | |
| const seen = new Set<string>(); | |
| const out: GlobalIndex[] = []; | |
| for (const it of list) { | |
| const name = (it.name || '').toString().trim(); | |
| const key = name.toLowerCase(); | |
| if (!key) continue; | |
| if (seen.has(key)) continue; | |
| seen.add(key); | |
| out.push(it); | |
| } | |
| return out; | |
| } | |
| // Build a prioritized list of indices for a country based on importantIndicesMap and available live entries | |
| private buildCountryIndices(countryKey: string): GlobalIndex[] { | |
| if (!countryKey) return []; | |
| const key = countryKey.toLowerCase().trim(); | |
| const important = this.importantIndicesMap[key] || []; | |
| // gather live matches (case-insensitive name match) | |
| const live = (this.allGlobalIndices || []).filter(g => this.countryMatches(key, (g.country || '').toLowerCase())); | |
| const byNameMap = new Map<string, GlobalIndex>(); | |
| // normalize and index live by name | |
| live.forEach(g => { | |
| const n = (g.name || '').toString().trim(); | |
| if (n) byNameMap.set(n.toLowerCase(), g); | |
| }); | |
| const result: GlobalIndex[] = []; | |
| // pick in priority order | |
| for (const name of important) { | |
| const found = byNameMap.get(name.toLowerCase()); | |
| if (found) result.push(found); | |
| } | |
| // append any other live indices for the country that were not in the important list (unique) | |
| live.forEach(g => { | |
| const n = (g.name || '').toString().trim().toLowerCase(); | |
| if (!result.some(r => ((r.name || '').toLowerCase() === n))) result.push(g); | |
| }); | |
| return this.uniqueByName(result).map(g => { | |
| // ensure miniPath computed | |
| g.miniPath = this.sparkPath(g.sparkline || []); | |
| g.isUp = (g.changePct || 0) >= 0; | |
| return g; | |
| }); | |
| } | |
| constructor(private host: ElementRef<HTMLElement>, private market: DashboardService) { } | |
| ngOnInit(): void { | |
| // Initialize mini sparklines and clock | |
| this.indices.forEach((idx) => this.refreshIndexSpark(idx)); | |
| this.setClock(); | |
| this.updateGainersLosers(); | |
| // Load cached market overview so values appear immediately on reload if available | |
| try { | |
| const raw = localStorage.getItem('marketCardsCache'); | |
| if (raw) { | |
| const cached = JSON.parse(raw) as MarketCard[]; | |
| if (Array.isArray(cached) && cached.length) this.marketCards = cached; | |
| } | |
| } catch { | |
| // ignore cache errors | |
| } | |
| // --- Load cached companies/quotes so Companies table shows immediately after reload --- | |
| try { | |
| const rawComp = localStorage.getItem('companiesCacheV1'); | |
| if (rawComp) { | |
| const parsed = JSON.parse(rawComp) as any; | |
| if (parsed && parsed.companiesByIndex) { | |
| this.companiesByIndex = parsed.companiesByIndex; | |
| if (parsed.lastUpdated) this.lastUpdated = parsed.lastUpdated; | |
| this.updateGainersLosers(); | |
| // ensure activeIndex valid | |
| if (!this.indexCodes.includes(this.activeIndex)) { | |
| this.activeIndex = this.indexCodes[0]; | |
| } | |
| } | |
| } | |
| } catch { | |
| // ignore | |
| } | |
| // Fetch live data once on init (and keep demo simulation running as fallback) | |
| this.fetchLiveMarkets(); | |
| // fetch market overview cards independently so they display even if /getcompanies fails | |
| this.fetchMarketCards(); | |
| this.fetchGlobalIndices(); | |
| // Ensure demo India indices present initially if backend doesn't provide them | |
| if (this.selectedCountry && this.selectedCountry.toLowerCase().trim() === 'india' && (!this.indiaIndices || this.indiaIndices.length === 0)) { | |
| this.populateDefaultIndiaIndices(); | |
| } | |
| // Start ticks every 3s | |
| this.tickHandle = window.setInterval(() => { | |
| this.tickIndices(); | |
| this.tickCompanies(); | |
| this.updateGainersLosers(); | |
| this.setClock(); | |
| }, 3000); | |
| // Start polling live quotes every 30s | |
| this.quotesIntervalHandle = window.setInterval(() => { | |
| this.fetchLiveQuotesForActiveIndex(); | |
| }, 30000); | |
| // ensure selectedCompany defaults to first company in active index (from cache or demo) | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| // draw initial chart for selected company (try live intraday) | |
| this.fetchAndDrawChart(first.sym, first.ltp).catch(() => { /* ignore */ }); | |
| } | |
| } | |
| ngAfterViewInit(): void { | |
| // Initial chart | |
| const initial = this.companiesByIndex[this.activeIndex][0]; | |
| this.chartSymbol = initial?.sym ?? 'β'; | |
| // draw SVG style area chart in container | |
| this.renderAreaChart(initial?.sym ?? 'β', initial?.ltp ?? 100); | |
| // setup ticker marquee after view init | |
| this.setupMarquee(); | |
| } | |
| ngOnDestroy(): void { | |
| if (this.tickHandle) { | |
| clearInterval(this.tickHandle); | |
| } | |
| if (this.quotesIntervalHandle) { | |
| clearInterval(this.quotesIntervalHandle); | |
| } | |
| if (this.marqueeAnimationId) { | |
| cancelAnimationFrame(this.marqueeAnimationId); | |
| this.marqueeAnimationId = undefined; | |
| } | |
| if (this.marqueeResizeObserver) { | |
| try { this.marqueeResizeObserver.disconnect(); } catch (e) { /* ignore */ } | |
| this.marqueeResizeObserver = null; | |
| } | |
| if (this.marqueeMutationObserver) { | |
| try { this.marqueeMutationObserver.disconnect(); } catch (e) { /* ignore */ } | |
| this.marqueeMutationObserver = null; | |
| } | |
| if (this.marqueeMeasureTimeout) { | |
| clearTimeout(this.marqueeMeasureTimeout); | |
| this.marqueeMeasureTimeout = undefined; | |
| } | |
| } | |
| // Loading state shown by template overlay while quotes request running | |
| quotesLoading: boolean = false; | |
| // localStorage key for persisted companies/quotes cache | |
| private readonly companiesCacheKey = 'companiesCacheV1'; | |
| // Called after view init to set animation duration based on content width so the marquee flows evenly | |
| private setupMarquee(): void { | |
| const el = this.tickerTrackRef?.nativeElement as HTMLDivElement | undefined; | |
| if (!el) return; | |
| // measure content width and container width; duplicated items => distance is half scrollWidth | |
| const measureOnce = () => { | |
| try { | |
| const totalWidth = el.scrollWidth || 0; // width of duplicated content | |
| const distance = Math.max(1, totalWidth / 2); | |
| const pxPerSecond = 120; // control speed | |
| const durationSec = Math.max(6, distance / pxPerSecond); | |
| // avoid changing CSS var if duration unchanged (prevents animation restart) | |
| if (this.lastDurationSec == null || Math.abs((this.lastDurationSec || 0) - durationSec) > 0.5) { | |
| el.style.setProperty('--marquee-duration', `${Math.round(durationSec)}s`); | |
| this.lastDurationSec = durationSec; | |
| } | |
| } catch (e) { | |
| // ignore | |
| } | |
| }; | |
| const scheduleMeasure = () => { | |
| if (this.marqueeMeasureTimeout) clearTimeout(this.marqueeMeasureTimeout); | |
| this.marqueeMeasureTimeout = setTimeout(() => { | |
| measureOnce(); | |
| this.marqueeMeasureTimeout = undefined; | |
| }, 120); | |
| }; | |
| // initial measurement | |
| scheduleMeasure(); | |
| // observe size changes and DOM changes, but debounce actual measurement | |
| try { | |
| this.marqueeResizeObserver = new ResizeObserver(() => scheduleMeasure()); | |
| this.marqueeResizeObserver.observe(el); | |
| if (el.parentElement) this.marqueeResizeObserver.observe(el.parentElement); | |
| } catch (e) { | |
| this.marqueeResizeObserver = null; | |
| } | |
| try { | |
| this.marqueeMutationObserver = new MutationObserver(() => scheduleMeasure()); | |
| this.marqueeMutationObserver.observe(el, { childList: true, subtree: true }); | |
| } catch (e) { | |
| this.marqueeMutationObserver = null; | |
| } | |
| } | |
| private fetchGlobalIndices(): void { | |
| // avoid duplicate fetch | |
| if (this.globalFetched) return; | |
| this.globalFetched = true; | |
| this.market.getGlobalIndices().pipe(take(1)).subscribe({ | |
| next: (resp: any) => { | |
| try { | |
| console.log('fetchGlobalIndices resp:', resp); | |
| const data = resp?.data || resp; | |
| if (!Array.isArray(data)) return; | |
| // capture marketCards from response early so we can use it when deriving indices | |
| const respMarketCards = (resp as any)?.marketCards; | |
| // map all returned indices | |
| const mapped = data.map(this.mapToGlobalIndex); | |
| // If backend returned no mapped indices but provided marketCards, derive a few common indices from marketCards | |
| if ((!mapped || mapped.length === 0) && Array.isArray(respMarketCards) && respMarketCards.length) { | |
| const indexNames = new Set(['S&P 500', 'NASDAQ', 'DAX', 'Nikkei', 'Nifty 50', 'Nifty Bank', 'SENSEX']); | |
| const derived: GlobalIndex[] = []; | |
| respMarketCards.forEach((mc: any) => { | |
| if (indexNames.has(mc.title)) { | |
| const price = Number(mc.price) || (typeof mc.display === 'number' ? mc.display : NaN); | |
| const pct = mc.chgPct ?? (mc.chg && price ? (Number(mc.chg) / (price - Number(mc.chg))) * 100 : 0); | |
| const gi: GlobalIndex = { | |
| id: (mc.title || '').replace(/\s+/g, '_'), | |
| name: mc.title, | |
| country: mc.title === 'S&P 500' || mc.title === 'NASDAQ' ? 'United States' : mc.title === 'DAX' ? 'Germany' : mc.title === 'Nikkei' ? 'Japan' : 'India', | |
| region: mc.title === 'S&P 500' || mc.title === 'NASDAQ' ? 'US' : mc.title === 'DAX' ? 'German' : mc.title === 'Nikkei' ? 'Japan' : 'India', | |
| price: Number(isNaN(price) ? 0 : price), | |
| change: Number(mc.chg ?? 0), | |
| changePct: Number(pct || 0), | |
| sparkline: this.series(Number(isNaN(price) ? Math.round(Number(mc.price) || 1000) : price), 26) | |
| } as GlobalIndex; | |
| gi.miniPath = this.sparkPath(gi.sparkline || []); | |
| gi.isUp = (gi.changePct || 0) >= 0; | |
| derived.push(gi); | |
| } | |
| }); | |
| // merge derived into mapped so rest of flow continues | |
| if (derived.length) { | |
| mapped.push(...derived); | |
| } | |
| } | |
| // compute mini sparklines paths | |
| mapped.forEach((g) => { | |
| g.miniPath = this.sparkPath(g.sparkline || []); | |
| g.isUp = (g.change || 0) >= 0; | |
| }); | |
| // merge into cache (dedupe by id) | |
| const byId = new Map<string, GlobalIndex>(); | |
| [...this.allGlobalIndices, ...mapped].forEach(i => byId.set(i.id, i)); | |
| this.allGlobalIndices = Array.from(byId.values()); | |
| // populate countries list for filter β merge live values but keep defaults if none | |
| const uniq = Array.from(new Set(this.allGlobalIndices.map((g) => g.country).filter(Boolean))); | |
| // always include common defaults so UI shows tabs even when backend data missing | |
| const defaultCountries = ['India', 'US', 'Uk', 'German', 'Sweden', 'Russia']; | |
| if (uniq.length) { | |
| // Filter live countries: exclude those that are aliases of our defaults to avoid duplicates | |
| const aliasesMap: Record<string, string[]> = this.countryAliases; | |
| const isAliasOfDefault = (countryName: string) => { | |
| if (!countryName) return false; | |
| const lname = countryName.toLowerCase(); | |
| // check against every alias set; if any alias contains the live name, consider it a match | |
| for (const key of Object.keys(aliasesMap)) { | |
| const aliases = aliasesMap[key] || []; | |
| for (const a of aliases) { | |
| if (lname.includes(a)) return true; | |
| } | |
| } | |
| return false; | |
| }; | |
| const rest = uniq | |
| .filter(c => c.toLowerCase() !== 'india') | |
| .filter(c => !isAliasOfDefault(c)) | |
| .sort(); | |
| // merge defaults with any truly extra live countries | |
| // filter out any unwanted country labels (e.g., Russia, Japan) | |
| const hide = new Set(['russia', 'japan']); | |
| const merged = [...defaultCountries, ...rest].filter(c => !hide.has((c || '').toLowerCase())); | |
| this.countries = merged; | |
| } else { | |
| this.countries = [...defaultCountries]; | |
| } | |
| // replace marketCards with live market overview if available in response root | |
| if (Array.isArray(respMarketCards) && respMarketCards.length) { | |
| this.marketCards = respMarketCards.map((m: any) => { | |
| const display = (m.display ?? m.price); | |
| const priceNum = Number(m.price ?? display ?? NaN); | |
| // prefer explicit percent from backend, otherwise compute from point change and previous price | |
| let pct = m.chgPct; | |
| if (pct === undefined || pct === null || Number.isNaN(Number(pct))) { | |
| const chgPoint = Number(m.chg || 0); | |
| const prev = priceNum - chgPoint; | |
| pct = (prev && !Number.isNaN(prev)) ? (chgPoint / prev) * 100 : 0; | |
| } | |
| const priceStr = (display === null || display === undefined) ? 'β' : (typeof display === 'number' ? display.toLocaleString(undefined, { maximumFractionDigits: 2 }) : String(display)); | |
| const chgNum = Number(pct || 0); | |
| return { | |
| title: m.title, | |
| value: priceStr, | |
| // show percent, not raw point change | |
| chg: (chgNum >= 0 ? '+' : '') + chgNum.toFixed(2) + '%', | |
| dir: chgNum > 0 ? 'up' : chgNum < 0 ? 'down' : 'neutral' | |
| } as MarketCard; | |
| }); | |
| console.log('marketCards set from resp.marketCards:', this.marketCards); | |
| } else { | |
| // fallback: derive a simple market overview from some well-known indices/currencies returned in "data" | |
| const pickTitles = new Set(['Gold', 'Silver', 'Crude Oil (Brent)', 'Crude Oil (WTI)', 'Natural Gas', 'USD/INR', 'EUR/USD', 'GBP/USD', 'Bitcoin', 'Ethereum', 'S&P 500', 'NASDAQ', 'DAX', 'Nikkei']); | |
| const derived: MarketCard[] = []; | |
| // if backend provided indices with matching names, map them | |
| mapped.forEach((m: any) => { | |
| if (pickTitles.has(m.name)) { | |
| const priceStr = (m.price === null || m.price === undefined) ? 'β' : (typeof m.price === 'number' ? m.price.toLocaleString(undefined, { maximumFractionDigits: 2 }) : String(m.price)); | |
| const chgNum = Number(m.change ?? m.changePct ?? 0); | |
| derived.push({ | |
| title: m.name, | |
| value: priceStr, | |
| chg: (chgNum >= 0 ? '+' : '') + chgNum.toFixed(2) + '%', | |
| dir: chgNum > 0 ? 'up' : chgNum < 0 ? 'down' : 'neutral' | |
| }); | |
| } | |
| }); | |
| // if still empty, try to pick first few indices | |
| if (derived.length === 0) { | |
| mapped.slice(0, 12).forEach((m: any) => { | |
| const priceStr = (m.price === null || m.price === undefined) ? 'β' : (typeof m.price === 'number' ? m.price.toLocaleString(undefined, { maximumFractionDigits: 2 }) : String(m.price)); | |
| const chgNum = Number(m.change ?? m.changePct ?? 0); | |
| derived.push({ | |
| title: m.name, | |
| value: priceStr, | |
| chg: (chgNum >= 0 ? '+' : '') + chgNum.toFixed(2) + '%', | |
| dir: chgNum > 0 ? 'up' : chgNum < 0 ? 'down' : 'neutral' | |
| }); | |
| }); | |
| } | |
| if (derived.length) { | |
| this.marketCards = derived; | |
| console.log('marketCards derived from indices:', this.marketCards); | |
| } | |
| } | |
| // apply current selection so UI updates immediately with live data | |
| this.applySelection(this.selectedCountry || 'India'); | |
| } catch (e) { | |
| console.error('Error processing global indices', e); | |
| } | |
| }, | |
| error: (err: any) => { | |
| console.warn('Failed to fetch global indices', err); | |
| } | |
| }); | |
| } | |
| private mapToGlobalIndex(d: any): GlobalIndex { | |
| return { | |
| id: d.id || `${(d.name || '').replace(/\s+/g, '_')}_${Math.random().toString(36).slice(2, 8)}`, | |
| name: d.name, | |
| country: d.country, | |
| region: d.region, | |
| price: d.price, | |
| change: d.change, | |
| changePct: d.changePct, | |
| sparkline: d.sparkline || [], | |
| } as GlobalIndex; | |
| } | |
| // Create demo US indices list with required names so UI shows S&P 500, Dow Jones, Nasdaq, S&P MidCap 400 and S&P SmallCap 600 when 'US' selected | |
| private createUSIndices(): GlobalIndex[] { | |
| const names = [ | |
| 'S&P 500', | |
| 'Dow Jones', | |
| 'Nasdaq Composite', | |
| 'S&P MidCap 400', | |
| 'S&P SmallCap 600' | |
| ]; | |
| return names.map((name, i) => { | |
| const base = Math.round(3000 + i * 200 + (Math.random() - 0.5) * 5000); | |
| const change = Number(((Math.random() - 0.5) * 1.5).toFixed(2)); | |
| const changePct = Number(((change / Math.max(1, base)) * 100).toFixed(2)); | |
| const spark = this.series(base, 26); | |
| const gi: GlobalIndex = { | |
| id: `${name.replace(/\s+/g, '_')}_${i}`, | |
| name, | |
| country: 'United States', | |
| region: 'US', | |
| price: base, | |
| change, | |
| changePct, | |
| sparkline: spark, | |
| } as GlobalIndex; | |
| gi.miniPath = this.sparkPath(gi.sparkline || []); | |
| gi.isUp = (gi.change || 0) >= 0; | |
| return gi; | |
| }); | |
| } | |
| // Create demo UK indices (FTSE family) so UI shows FTSE 100, FTSE 250, FTSE 350, FTSE Small Cap when 'Uk' selected | |
| private createUKIndices(): GlobalIndex[] { | |
| const names = [ | |
| 'FTSE 100', | |
| 'FTSE 250', | |
| 'FTSE 350', | |
| 'FTSE Small Cap' | |
| ]; | |
| return names.map((name, i) => { | |
| const base = Math.round(6000 + i * 100 + (Math.random() - 0.5) * 400); | |
| const change = Number(((Math.random() - 0.5) * 1.2).toFixed(2)); | |
| const changePct = Number(((change / Math.max(1, base)) * 100).toFixed(2)); | |
| const spark = this.series(base, 26); | |
| const gi: GlobalIndex = { | |
| id: `${name.replace(/\s+/g, '_')}_${i}`, | |
| name, | |
| country: 'United Kingdom', | |
| region: 'UK', | |
| price: base, | |
| change, | |
| changePct, | |
| sparkline: spark, | |
| } as GlobalIndex; | |
| gi.miniPath = this.sparkPath(gi.sparkline || []); | |
| gi.isUp = (gi.change || 0) >= 0; | |
| return gi; | |
| }); | |
| } | |
| // Create demo German indices (DAX family) so UI shows DAX, MDAX, TecDAX, SDAX when 'German' selected | |
| private createGermanIndices(): GlobalIndex[] { | |
| const names = [ | |
| 'DAX', | |
| 'MDAX', | |
| 'TecDAX', | |
| 'SDAX' | |
| ]; | |
| return names.map((name, i) => { | |
| const base = Math.round(10000 + i * 200 + (Math.random() - 0.5) * 800); | |
| const change = Number(((Math.random() - 0.5) * 1.2).toFixed(2)); | |
| const changePct = Number(((change / Math.max(1, base)) * 100).toFixed(2)); | |
| const spark = this.series(base, 26); | |
| const gi: GlobalIndex = { | |
| id: `${name.replace(/\s+/g, '_')}_${i}`, | |
| name, | |
| country: 'Germany', | |
| region: 'German', | |
| price: base, | |
| change, | |
| changePct, | |
| sparkline: spark, | |
| } as GlobalIndex; | |
| gi.miniPath = this.sparkPath(gi.sparkline || []); | |
| gi.isUp = (gi.change || 0) >= 0; | |
| return gi; | |
| }); | |
| } | |
| // Create demo Sweden indices so UI shows OMXS30 and STOXX Sweden when 'Sweden' selected | |
| private createSwedenIndices(): GlobalIndex[] { | |
| const names = [ | |
| 'OMX Stockholm 30', | |
| 'STOXX Sweden' | |
| ]; | |
| return names.map((name, i) => { | |
| const base = Math.round(1500 + i * 50 + (Math.random() - 0.5) * 200); | |
| const change = Number(((Math.random() - 0.5) * 1.0).toFixed(2)); | |
| const changePct = Number(((change / Math.max(1, base)) * 100).toFixed(2)); | |
| const spark = this.series(base, 26); | |
| const gi: GlobalIndex = { | |
| id: `${name.replace(/\s+/g, '_')}_${i}`, | |
| name, | |
| country: 'Sweden', | |
| region: 'Sweden', | |
| price: base, | |
| change, | |
| changePct, | |
| sparkline: spark, | |
| } as GlobalIndex; | |
| gi.miniPath = this.sparkPath(gi.sparkline || []); | |
| gi.isUp = (gi.change || 0) >= 0; | |
| return gi; | |
| }); | |
| } | |
| // Create demo Russia indices so UI shows MOEX Russia and RTS Index when 'Russia' selected | |
| private createRussiaIndices(): GlobalIndex[] { | |
| const names = ['MOEX Russia', 'RTS Index']; | |
| return names.map((name, i) => { | |
| const base = Math.round(2000 + i * 300 + (Math.random() - 0.5) * 800) * (name === 'MOEX Russia' ? 5 : 1); | |
| const change = Number(((Math.random() - 0.5) * 1.2).toFixed(2)); | |
| const changePct = Number(((change / Math.max(1, base)) * 100).toFixed(2)); | |
| const spark = this.series(base, 26); | |
| const gi: GlobalIndex = { | |
| id: `${name.replace(/\s+/g, '_')}_${i}`, | |
| name, | |
| country: 'Russia', | |
| region: 'Russia', | |
| price: base, | |
| change, | |
| changePct, | |
| sparkline: spark, | |
| } as GlobalIndex; | |
| gi.miniPath = this.sparkPath(gi.sparkline || []); | |
| gi.isUp = (gi.change || 0) >= 0; | |
| return gi; | |
| }); | |
| } | |
| // Helper to find a company object by symbol across all indices | |
| private findCompanyBySymbol(sym: string): Company | undefined { | |
| if (!sym) return undefined; | |
| const lists = Object.values(this.companiesByIndex || {}); | |
| for (const list of lists) { | |
| for (const c of list) { | |
| if ((c.sym || '').toString() === sym.toString()) return c; | |
| } | |
| } | |
| return undefined; | |
| } | |
| // Indices to display in template: indiaIndices when India selected, otherwise globalIndices set by applySelection | |
| get displayIndices(): GlobalIndex[] { | |
| const sel = (this.selectedCountry || '').toLowerCase().trim(); | |
| let list: GlobalIndex[] = []; | |
| if (sel === 'india') list = this.indiaIndices || []; | |
| else list = this.globalIndices || []; | |
| // Deduplicate by name (case-insensitive) and limit visible cards to a sensible number (9) | |
| const unique = this.uniqueByName(list); | |
| // apply small display fixes: normalize long Dow Jones name and boost MOEX Russia demo value | |
| const adjusted = unique.slice(0, 14).map(g => { | |
| const copy = { ...g } as GlobalIndex; | |
| if (typeof copy.name === 'string') { | |
| // replace long backend label if present | |
| copy.name = copy.name.replace(/Dow Jones Industrial Average/ig, 'Dow Jones'); | |
| } | |
| // if MOEX Russia appears and looks small (likely demo), scale it up for visibility | |
| if (typeof copy.name === 'string' && /moex\s*russia/i.test(copy.name)) { | |
| // scale price but avoid absurdly large values: if price < 10000, multiply | |
| if (typeof copy.price === 'number' && copy.price > 0 && copy.price < 10000) { | |
| copy.price = Math.round(copy.price * 5); | |
| } | |
| } | |
| return copy; | |
| }); | |
| return adjusted.slice(0, 9); | |
| } | |
| // Fetch live markets from backend | |
| private fetchLiveMarkets(): void { | |
| this.market.getCompanies().pipe(take(1)).subscribe({ | |
| next: (data: any) => { | |
| console.log('fetchLiveMarkets - getCompanies response:', data); | |
| try { | |
| if (data.indices && Array.isArray(data.indices) && data.indices.length) { | |
| // map backend indices to IndexItem | |
| this.indices = data.indices.map((it: any) => ({ code: it.code, price: it.price, chg: it.chg })); | |
| this.indices.forEach((idx) => this.refreshIndexSpark(idx)); | |
| } | |
| if (data.companiesByIndex) { | |
| this.companiesByIndex = data.companiesByIndex; | |
| // Immediately fetch live quotes for active index now that companies are loaded | |
| try { | |
| console.log('Companies loaded for indices, fetching live quotes for', this.activeIndex); | |
| this.fetchLiveQuotesForActiveIndex(); | |
| } catch (e) { | |
| console.warn('fetchLiveQuotesForActiveIndex failed shortly after companies load', e); | |
| } | |
| } | |
| if (data.sectors) { | |
| this.sectors = data.sectors; | |
| } | |
| if (data.lastUpdated) { | |
| this.lastUpdated = data.lastUpdated; | |
| } | |
| // update derived lists and chart | |
| this.updateGainersLosers(); | |
| const initial = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (initial) this.renderAreaChart(initial.sym, initial.ltp || 100); | |
| // if backend provided marketCards in /getcompanies payload use them | |
| const respMarketCards = data.marketCards ?? data.data?.marketCards ?? data.results?.marketCards ?? data.market_cards ?? data.cards ?? null; | |
| if (Array.isArray(respMarketCards) && respMarketCards.length) { | |
| console.log('fetchLiveMarkets: found marketCards in /getcompanies payload', respMarketCards); | |
| // apply live marketCards immediately and cache them | |
| const mapped = respMarketCards.map((m: any) => { | |
| const display = m.display ?? m.price ?? m.value ?? m.last ?? m.close ?? null; | |
| const value = this.formatPrice(display); | |
| const pctRaw = m.chgPct ?? m.chg_pct ?? m.pct ?? m.changePct ?? m.change_percent ?? m.change ?? null; | |
| const pct = (pctRaw !== undefined && pctRaw !== null) ? Number(pctRaw) : (Number(m.chg || 0) && typeof display === 'number' ? ((Number(m.chg) / (display - Number(m.chg))) * 100) : 0); | |
| const n = Number(pct || 0); | |
| return { | |
| title: m.title ?? m.name ?? (m.symbol ?? 'β'), | |
| value, | |
| chg: isNaN(n) ? 'β' : ((n >= 0 ? '+' : '') + n.toFixed(2) + '%'), | |
| dir: isNaN(n) ? 'neutral' : (n > 0 ? 'up' : n < 0 ? 'down' : 'neutral') | |
| } as MarketCard; | |
| }); | |
| this.marketCards = mapped; | |
| try { localStorage.setItem('marketCardsCache', JSON.stringify(mapped)); } catch { /* ignore */ } | |
| } else { | |
| console.log('fetchLiveMarkets: no marketCards found in /getcompanies payload'); | |
| } | |
| // persist companies snapshot so table shows immediately on reload | |
| try { | |
| const snapshot = { companiesByIndex: this.companiesByIndex, lastUpdated: this.lastUpdated }; | |
| localStorage.setItem(this.companiesCacheKey, JSON.stringify(snapshot)); | |
| } catch { /* ignore */ } | |
| } catch (e) { | |
| console.error('Error processing market data', e); | |
| } | |
| }, | |
| error: (err: any) => { | |
| console.warn('Failed to fetch live markets', err); | |
| } | |
| }); | |
| } | |
| // Fetch market overview cards from backend (uses /getmarketcards) | |
| private fetchMarketCards(): void { | |
| this.market.getMarketCards().pipe(take(1)).subscribe({ | |
| next: (cards: any[]) => { | |
| console.log('fetchLiveMarkets - /getmarketcards response:', cards); | |
| try { | |
| if (cards && Array.isArray(cards) && cards.length) { | |
| const mapped = cards.map((c: any) => { | |
| const display = c.display ?? c.price ?? c.value ?? c.last ?? c.close ?? null; | |
| const value = this.formatPrice(display); | |
| const pctRaw = c.chgPct ?? c.chg_pct ?? c.pct ?? c.changePct ?? c.change_percent ?? c.change ?? null; | |
| const pct = (pctRaw !== undefined && pctRaw !== null) ? Number(pctRaw) : (Number(c.chg || 0) && typeof display === 'number' ? ((Number(c.chg) / (display - Number(c.chg))) * 100) : 0); | |
| const n = Number(pct || 0); | |
| return { | |
| title: c.title ?? c.name ?? (c.symbol ?? 'β'), | |
| value, | |
| chg: isNaN(n) ? 'β' : ((n >= 0 ? '+' : '') + n.toFixed(2) + '%'), | |
| dir: isNaN(n) ? 'neutral' : (n > 0 ? 'up' : n < 0 ? 'down' : 'neutral') | |
| } as MarketCard; | |
| }); | |
| this.marketCards = mapped; | |
| try { localStorage.setItem('marketCardsCache', JSON.stringify(mapped)); } catch { /* ignore */ } | |
| } | |
| } catch (e) { | |
| console.error('Error processing market cards', e); | |
| } | |
| }, | |
| error: (err: any) => { | |
| console.warn('Failed to fetch market cards', err); | |
| } | |
| }); | |
| } | |
| // UI helpers | |
| fmt(n: number): string { | |
| return n.toFixed(2); | |
| } | |
| random(): number { | |
| return Math.random(); | |
| } | |
| // Format a price for display. Treat null/undefined/empty as missing and return 'β'./ | |
| private formatPrice(val: any): string { | |
| if (val === null || val === undefined) return 'β'; | |
| if (typeof val === 'number') return val.toLocaleString(undefined, { maximumFractionDigits: 2 }); | |
| if (typeof val === 'string') return val.trim() === '' ? 'β' : val; | |
| return String(val); | |
| } | |
| // Format index name for UI: remove bracketed suffixes like "(OMXS30)" | |
| public formatIndexName(name: string | undefined | null): string { | |
| if (!name) return ''; | |
| return String(name).replace(/\s*([^)]*)\)\s*/g, '').replace(/\s*\([^)]*\)\s*/g, '').trim(); | |
| } | |
| public setActiveIndex(code: string): void { | |
| this.activeIndex = code; | |
| // fetch live quotes for newly active index immediately | |
| this.fetchLiveQuotesForActiveIndex(); | |
| // update selected company to first of the newly active index | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| // draw initial chart for selected company (try live intraday) | |
| this.fetchAndDrawChart(first.sym, first.ltp).catch(() => { /* ignore */ }); | |
| } | |
| } | |
| // Create demo UK companies per index | |
| private createUKCompaniesByIndex(): Record<string, Company[]> { | |
| return { | |
| 'FTSE 100': [ | |
| { sym: 'HSBA', ltp: 520.10, chg: +0.8, high: 525.0, low: 515.0 }, | |
| { sym: 'BP', ltp: 310.22, chg: -0.3, high: 315.0, low: 308.0 }, | |
| { sym: 'GSK', ltp: 1380.50, chg: +0.2, high: 1395.0, low: 1370.0 }, | |
| { sym: 'BARC', ltp: 190.44, chg: +1.1, high: 192.0, low: 188.0 }, | |
| { sym: 'VOD', ltp: 120.60, chg: -0.6, high: 122.0, low: 119.0 }, | |
| ], | |
| 'FTSE 250': [ | |
| { sym: 'SMT', ltp: 45.12, chg: +0.4, high: 46.0, low: 44.0 }, | |
| { sym: 'TPK', ltp: 8.34, chg: -0.2, high: 8.8, low: 8.0 }, | |
| ] | |
| }; | |
| } | |
| private createUKSectors(): Sector[] { | |
| return [ | |
| { name: 'UK Financials', picks: ['HSBA', 'BARC', 'LLOY'] }, | |
| { name: 'Energy', picks: ['BP', 'RDSA'] }, | |
| { name: 'Telecom', picks: ['VOD', 'BT'] }, | |
| ]; | |
| } | |
| // Create demo German companies per index | |
| private createGermanCompaniesByIndex(): Record<string, Company[]> { | |
| return { | |
| 'DAX': [ | |
| { sym: 'SAP', ltp: 110.12, chg: +0.5, high: 112.0, low: 109.0 }, | |
| { sym: 'BMW', ltp: 85.34, chg: -0.4, high: 86.5, low: 84.0 }, | |
| { sym: 'DAI', ltp: 75.22, chg: +0.7, high: 76.0, low: 74.0 }, | |
| { sym: 'BAYN', ltp: 55.66, chg: -1.1, high: 57.0, low: 55.0 }, | |
| ] | |
| }; | |
| } | |
| private createGermanSectors(): Sector[] { | |
| return [ | |
| { name: 'Automotive', picks: ['BMW', 'DAI', 'VOW3'] }, | |
| { name: 'Software', picks: ['SAP', 'SOW'] }, | |
| ]; | |
| } | |
| // Create demo Sweden companies per index | |
| private createSwedenCompaniesByIndex(): Record<string, Company[]> { | |
| return { | |
| 'OMX Stockholm 30': [ | |
| { sym: 'ERIC', ltp: 78.12, chg: +0.9, high: 79.5, low: 76.8 }, | |
| { sym: 'SEB', ltp: 120.34, chg: -0.3, high: 121.8, low: 119.0 }, | |
| { sym: 'VOLV', ltp: 150.44, chg: +0.4, high: 152.0, low: 149.0 }, | |
| ] | |
| }; | |
| } | |
| private createSwedenSectors(): Sector[] { | |
| return [ | |
| { name: 'Industrial', picks: ['VOLV', 'ATCO'] }, | |
| { name: 'Telecom', picks: ['ERIC'] }, | |
| ]; | |
| } | |
| // Create demo Russia companies per index | |
| private createRussiaCompaniesByIndex(): Record<string, Company[]> { | |
| return { | |
| 'MOEX Russia': [ | |
| { sym: 'SBER', ltp: 170.12, chg: +1.2, high: 172.0, low: 168.0 }, | |
| { sym: 'GAZP', ltp: 280.34, chg: -0.5, high: 285.0, low: 278.0 }, | |
| ] | |
| }; | |
| } | |
| private createRussiaSectors(): Sector[] { | |
| return [ | |
| { name: 'Energy', picks: ['GAZP', 'LKOH'] }, | |
| { name: 'Banks', picks: ['SBER', 'VTBR'] }, | |
| ]; | |
| } | |
| private applySelection(country: string): void { | |
| const sel = (country || '').toLowerCase().trim(); | |
| if (!sel) { | |
| this.globalIndices = []; | |
| return; | |
| } | |
| if (sel === 'india') { | |
| // Prefer prioritized live India indices; fallback to demo if none | |
| const built = this.buildCountryIndices('india'); | |
| if (built && built.length) this.indiaIndices = built; | |
| else if (!this.indiaIndices || this.indiaIndices.length === 0) this.populateDefaultIndiaIndices(); | |
| this.globalIndices = []; | |
| // Restore India companies/sectors and reset active index so UI reflects India after switching back | |
| this.companiesByIndex = this.createIndiaCompaniesByIndex(); | |
| this.sectors = [ | |
| { name: 'IT & Software', picks: ['TCS', 'INFY', 'HCLTECH', 'TECHM'] }, | |
| { name: 'Banking & Finance', picks: ['HDFCBANK', 'ICICIBANK', 'SBIN', 'AXISBANK'] }, | |
| { name: 'Energy & Materials', picks: ['RELIANCE', 'ONGC', 'TATASTEEL', 'COALINDIA'] }, | |
| { name: 'Auto', picks: ['TATAMOTORS', 'MARUTI', 'M&M', 'TVSMOTOR'] }, | |
| { name: 'FMCG', picks: ['ITC', 'HINDUNILVR', 'NESTLEIND', 'DABAR'] }, | |
| { name: 'Telecom', picks: ['BHARTIARTL', 'VODAFONEIDE', 'TATACOMM', 'INDUSTOWER'] }, | |
| ]; | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| // update derived lists | |
| this.updateGainersLosers(); | |
| return; | |
| } | |
| // try to find live entries in cache | |
| const matched = (this.allGlobalIndices || []).filter((g) => this.countryMatches(sel, (g.country || '').toLowerCase()) && (g.region || '').toLowerCase() !== 'india'); | |
| if (matched && matched.length) { | |
| this.globalIndices = matched; | |
| // Even when we have live index entries we may still want to populate demo companies/sectors | |
| // for certain countries so the "Companies by Index", sector picks and toppers/losers update. | |
| if (sel === 'us') { | |
| this.companiesByIndex = this.createUSCompaniesByIndex(); | |
| this.sectors = this.createUSSectors(); | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| this.updateGainersLosers(); | |
| } else if (sel === 'uk') { | |
| this.companiesByIndex = this.createUKCompaniesByIndex(); | |
| this.sectors = this.createUKSectors(); | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| this.updateGainersLosers(); | |
| } else if (sel === 'german' || sel === 'germany') { | |
| this.companiesByIndex = this.createGermanCompaniesByIndex(); | |
| this.sectors = this.createGermanSectors(); | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| this.updateGainersLosers(); | |
| } else if (sel === 'sweden') { | |
| this.companiesByIndex = this.createSwedenCompaniesByIndex(); | |
| this.sectors = this.createSwedenSectors(); | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| this.updateGainersLosers(); | |
| } else if (sel === 'russia') { | |
| this.companiesByIndex = this.createRussiaCompaniesByIndex(); | |
| this.sectors = this.createRussiaSectors(); | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| this.updateGainersLosers(); | |
| } | |
| // For live data we don't have a mapping of companies per index; keep existing companiesByIndex | |
| return; | |
| } | |
| // no live data for this country β create demo indices for known countries so UI shows names immediately | |
| let demo: GlobalIndex[] = []; | |
| if (sel === 'us') demo = this.createUSIndices(); | |
| else if (sel === 'uk') demo = this.createUKIndices(); | |
| else if (sel === 'german') demo = this.createGermanIndices(); | |
| else if (sel === 'sweden') demo = this.createSwedenIndices(); | |
| else if (sel === 'russia') demo = this.createRussiaIndices(); | |
| this.globalIndices = demo; | |
| // merge demo into cache to improve subsequent lookups | |
| if (demo && demo.length) { | |
| const byId = new Map<string, GlobalIndex>(); | |
| [...this.allGlobalIndices, ...demo].forEach(i => byId.set(i.id, i)); | |
| this.allGlobalIndices = Array.from(byId.values()); | |
| } | |
| // --- NEW: populate demo companies & sectors for this country so related cards update --- | |
| if (sel === 'us') { | |
| this.companiesByIndex = this.createUSCompaniesByIndex(); | |
| this.sectors = this.createUSSectors(); | |
| // reset active index to first available and refresh chart/quotes | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| // try to fetch live quotes for US symbols (backend may support them) | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| // update gainers/losers immediately from demo data | |
| this.updateGainersLosers(); | |
| } else if (sel === 'uk') { | |
| this.companiesByIndex = this.createUKCompaniesByIndex(); | |
| this.sectors = this.createUKSectors(); | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| this.updateGainersLosers(); | |
| } else if (sel === 'german' || sel === 'germany') { | |
| this.companiesByIndex = this.createGermanCompaniesByIndex(); | |
| this.sectors = this.createGermanSectors(); | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| this.updateGainersLosers(); | |
| } else if (sel === 'sweden') { | |
| this.companiesByIndex = this.createSwedenCompaniesByIndex(); | |
| this.sectors = this.createSwedenSectors(); | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| this.updateGainersLosers(); | |
| } else if (sel === 'russia') { | |
| this.companiesByIndex = this.createRussiaCompaniesByIndex(); | |
| this.sectors = this.createRussiaSectors(); | |
| const firstKey = Object.keys(this.companiesByIndex)[0]; | |
| if (firstKey) { | |
| this.activeIndex = firstKey; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.renderAreaChart(first.sym, first.ltp || 100); | |
| } | |
| this.fetchLiveQuotesForActiveIndex().catch(() => { /* ignore */ }); | |
| } | |
| this.updateGainersLosers(); | |
| } | |
| } | |
| // New: load full constituents from backend for active index (uses /getcompanies?code=) | |
| public async loadAllConstituents(): Promise<void> { | |
| try { | |
| const code = this.activeIndex || this.indexCodes[0]; | |
| if (!code) return; | |
| const resp: any = await lastValueFrom(this.market.getConstituents(code)); | |
| if (!resp) return; | |
| // backend payload uses 'constituents' array of {symbol, company} | |
| const rows = resp.constituents || resp.results || resp.data || []; | |
| if (!Array.isArray(rows) || rows.length === 0) return; | |
| // convert to Company[] using yfinance symbol mapping for India (append .NS) | |
| const companies: Company[] = rows.map((r: any) => { | |
| const s = (r.symbol || r.Symbol || r.symbol || '').toString().trim(); | |
| const sym = s || (r.company || r.Company || '').toString().trim(); | |
| // turn into NSE ticker if needed | |
| const isIndia = (code || '').toLowerCase().includes('nifty') || (code || '').toLowerCase().includes('sensex') || (this.selectedCountry || '').toLowerCase() === 'india'; | |
| let symT = sym; | |
| if (isIndia && sym && !sym.includes('.') && !sym.includes(':')) symT = `${sym}.NS`; | |
| return { sym: symT.replace('.NS', ''), ltp: 0, chg: 0, high: 0, low: 0 } as Company; | |
| }); | |
| // store under active code name (keep original key format) | |
| this.companiesByIndex[code] = companies; | |
| // fetch live quotes for new list | |
| await this.fetchLiveQuotesForActiveIndex(); | |
| this.updateGainersLosers(); | |
| } catch (e) { | |
| console.warn('loadAllConstituents failed', e); | |
| } | |
| } | |
| // Ensure selectedCompany is valid for current activeIndex and draw chart synchronously | |
| private ensureSelectedCompanyAndDraw(): void { | |
| try { | |
| const keys = Object.keys(this.companiesByIndex || {}); | |
| if (keys.length === 0) return; | |
| if (!keys.includes(this.activeIndex)) this.activeIndex = keys[0]; | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.chartSymbol = first.sym; | |
| // Draw chart synchronously to ensure UI reflects selection immediately | |
| // Use a short timeout so that template updates (table) have rendered | |
| setTimeout(() => { | |
| this.renderAreaChart(first.sym, first.ltp); | |
| }, 20); | |
| } | |
| } catch (e) { | |
| // ignore | |
| } | |
| } | |
| // Fetch live quotes for companies in the active index and update table | |
| private async fetchLiveQuotesForActiveIndex(): Promise<void> { | |
| // show spinner in table | |
| this.quotesLoading = true; | |
| try { | |
| const tickers = this.buildTickersForIndex(this.activeIndex || ''); | |
| if (!tickers || tickers.length === 0) { | |
| this.quotesLoading = false; | |
| return; | |
| } | |
| // helper: sleep | |
| const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); | |
| // helper: fetch with retries + exponential backoff | |
| const fetchWithRetries = async (batch: string[], maxRetries = 3, baseDelay = 500): Promise<any[] | null> => { | |
| for (let attempt = 0; attempt < maxRetries; attempt++) { | |
| try { | |
| const obs = this.market.getQuotes(batch); | |
| const res = await lastValueFrom(obs); | |
| // treat empty result as failure (backend may return [] on error) | |
| if (Array.isArray(res) && res.length > 0) return res; | |
| // if got empty but this was final attempt, consider failure | |
| } catch (err) { | |
| // fall through to retry | |
| console.warn('fetchWithRetry attempts Gi failed', err); | |
| } | |
| const delay = baseDelay * Math.pow(2, attempt); | |
| await sleep(delay); | |
| } | |
| return null; | |
| }; | |
| // batch tickers to avoid very long query strings; adjust size as needed | |
| const batchSize = 10; | |
| const batches: string[][] = []; | |
| for (let i = 0; i < tickers.length; i += batchSize) { | |
| batches.push(tickers.slice(i, i + batchSize)); | |
| } | |
| const allQuotes: any[] = []; | |
| for (const b of batches) { | |
| const batchRes = await fetchWithRetries(b, 3, 400); | |
| if (batchRes === null) { | |
| // At least one batch failed after retries β fallback to demo ticks | |
| console.warn('Quotes batch failed after retries, falling back to demo ticks'); | |
| this.quotesLoading = false; | |
| // Apply one demo tick step immediately so UI updates | |
| this.tickCompanies(); | |
| this.updateGainersLosers(); | |
| return; | |
| } | |
| allQuotes.push(...batchRes); | |
| } | |
| // If we reach here, we have fetched quotes for all batches | |
| const list = this.companiesByIndex[this.activeIndex] || []; | |
| const byReq = new Map<string, any>(); | |
| allQuotes.forEach((q: any) => { | |
| if (!q) return; | |
| const s = (q.symbol || '').toString(); | |
| byReq.set(s.toUpperCase(), q); | |
| }); | |
| list.forEach((c) => { | |
| const reqSym = (this.selectedCountry.toLowerCase() === 'india' && !c.sym.includes('.') && !c.sym.includes(':')) ? `${c.sym}.NS`.toUpperCase() : c.sym.toUpperCase(); | |
| const q = byReq.get(reqSym) || byReq.get(c.sym.toUpperCase()); | |
| if (!q) return; | |
| if (q.price !== undefined && q.price !== null) c.ltp = Number(q.price); | |
| // backend provides chgPct as percentage; use it if present | |
| if (q.chgPct !== undefined && q.chgPct !== null) c.chg = Number(q.chgPct); | |
| else if (q.chg !== undefined && q.chg !== null && c.ltp) { | |
| // approximate percent from absolute change | |
| const prev = c.ltp - Number(q.chg); | |
| if (prev && !Number.isNaN(prev)) c.chg = (Number(q.chg) / prev) * 100; | |
| } | |
| if (q.high !== undefined && q.high !== null) c.high = Number(q.high); | |
| if (q.low !== undefined && q.low !== null) c.low = Number(q.low); | |
| }); | |
| this.updateGainersLosers(); | |
| this.lastUpdated = new Date().toLocaleString(); | |
| // persist updated quotes so reload shows latest values immediately | |
| try { | |
| const snapshot = { companiesByIndex: this.companiesByIndex, lastUpdated: this.lastUpdated }; | |
| localStorage.setItem(this.companiesCacheKey, JSON.stringify(snapshot)); | |
| } catch { /* ignore */ } | |
| } catch (e) { | |
| console.warn('fetchLiveQuotesForActiveIndex unexpected error', e); | |
| // fallback to demo ticks | |
| this.tickCompanies(); | |
| this.updateGainersLosers(); | |
| } finally { | |
| this.quotesLoading = false; | |
| } | |
| } | |
| // Build yfinance-compatible tickers for a company symbol depending on country | |
| private buildTickersForIndex(indexCode: string): string[] { | |
| const list = this.companiesByIndex[indexCode] || []; | |
| const isIndia = (indexCode || '').toLowerCase().includes('nifty') || (indexCode || '').toLowerCase().includes('sensex') || (this.selectedCountry || '').toLowerCase() === 'india'; | |
| return list.map(c => { | |
| const sym = (c.sym || '').trim(); | |
| if (!sym) return ''; | |
| // For India, append NSE suffix if not already present | |
| if (isIndia && !sym.includes('.') && !sym.includes(':')) return `${sym}.NS`; | |
| return sym; | |
| }).filter(Boolean); | |
| } | |
| // Create demo India indices with sparklines and miniPath for UI when live data not available | |
| private populateDefaultIndiaIndices(): void { | |
| const now = Date.now(); | |
| const items: GlobalIndex[] = this.defaultIndiaIndexNames.map((name: string, i: number) => { | |
| const base = Math.round(20000 + i * 1000 + (Math.random() - 0.5) * 2000); | |
| const change = Number(((Math.random() - 0.5) * 1.5).toFixed(2)); | |
| const changePct = Number((change / base * 100).toFixed(2)); | |
| const spark = this.series(base, 26); | |
| const gi: GlobalIndex = { | |
| id: `${name.replace(/\s+/g, '_')}_${i}`, | |
| name, | |
| country: 'India', | |
| region: 'India', | |
| price: base, | |
| change, | |
| changePct, | |
| sparkline: spark, | |
| } as GlobalIndex; | |
| gi.miniPath = this.sparkPath(gi.sparkline || []); | |
| gi.isUp = (gi.change || 0) >= 0; | |
| return gi; | |
| }); | |
| this.indiaIndices = items; | |
| // also ensure cache contains these so other logic can find India entries | |
| const byId = new Map<string, GlobalIndex>(); | |
| [...this.allGlobalIndices, ...items].forEach(i => byId.set(i.id, i)); | |
| this.allGlobalIndices = Array.from(byId.values()); | |
| } | |
| private countryMatches(selected: string, actual: string): boolean { | |
| if (!selected || !actual) return false; | |
| if (selected === actual) return true; | |
| // check aliases for selected label | |
| const aliases = this.countryAliases[selected]; | |
| if (aliases) { | |
| for (const a of aliases) { | |
| if (actual.includes(a)) return true; | |
| } | |
| } | |
| // check if actual has a known alias that matches selected | |
| const key = Object.keys(this.countryAliases).find(k => this.countryAliases[k].some((a: string) => actual.includes(a))); | |
| if (key && key === selected) return true; | |
| // fallback: partial match | |
| return actual.includes(selected) || selected.includes(actual); | |
| } | |
| // Clock | |
| private setClock(): void { | |
| this.lastUpdated = new Date().toLocaleString(); | |
| } | |
| // Indices sparkline generation | |
| private series(base: number, pts = 26): number[] { | |
| const arr = [base]; | |
| for (let i = 1; i < pts; i++) { | |
| const step = (Math.random() - 0.5) * (base * 0.002); // Β±0.2% | |
| arr.push(Math.max(1, arr[i - 1] + step)); | |
| } | |
| return arr; | |
| } | |
| private sparkPath(values: number[], w = 240, h = 28, pad = 2): string { | |
| if (!values || values.length === 0) return ''; | |
| const max = Math.max(...values); | |
| const min = Math.min(...values); | |
| const norm = (v: number) => h - pad - ((v - min) / Math.max(1e-6, max - min)) * (h - 2 * pad); | |
| const step = (w - 2 * pad) / (values.length - 1 || 1); | |
| let d = ''; | |
| values.forEach((v, i) => { | |
| const x = pad + i * step; | |
| const y = norm(v); | |
| d += i === 0 ? `M ${x} ${y}` : ` L ${x} ${y}`; | |
| }); | |
| return d; | |
| } | |
| private refreshIndexSpark(idx: IndexItem): void { | |
| const vals = this.series(idx.price, 26); | |
| idx.isUp = vals[vals.length - 1] >= vals[0]; | |
| idx.miniPath = this.sparkPath(vals); | |
| } | |
| private tickIndices(): void { | |
| this.indices.forEach((idx) => { | |
| const delta = (Math.random() - 0.5) * (idx.price * 0.0008); | |
| idx.price = Math.max(1, idx.price + delta); | |
| idx.chg += (Math.random() - 0.5) * 0.04; // drift | |
| this.refreshIndexSpark(idx); | |
| }); | |
| } | |
| // Companies random walk tick | |
| private tickCompanies(): void { | |
| Object.values(this.companiesByIndex).forEach((list) => { | |
| list.forEach((c) => { | |
| const base = c.ltp; | |
| const move = (Math.random() - 0.5) * (base * 0.0025); // Β±0.25% | |
| c.ltp = Math.max(1, base + move); | |
| const ref = base / (1 + c.chg / 100); | |
| c.chg = ((c.ltp - ref) / ref) * 100; | |
| c.high = Math.max(c.high, c.ltp); | |
| c.low = Math.min(c.low, c.ltp); | |
| }); | |
| }); | |
| } | |
| private flattenCompanies(): Company[] { | |
| return Object.values(this.companiesByIndex).flat().map((c) => ({ ...c })); | |
| } | |
| private updateGainersLosers(): void { | |
| const all = this.flattenCompanies(); | |
| const sorted = [...all].sort((a, b) => b.chg - a.chg); | |
| this.gainers = sorted.slice(0, 5); | |
| this.losers = sorted.slice(-5).reverse(); | |
| } | |
| // Chart | |
| private makeIntraday(base = 100, points = 90): number[] { | |
| const arr = [base]; | |
| for (let i = 1; i < points; i++) { | |
| const step = (Math.random() - 0.5) * (base * 0.004); | |
| arr.push(Math.max(1, arr[i - 1] + step)); | |
| } | |
| return arr; | |
| } | |
| // Compatibility wrapper for template calls β delegate to SVG renderer | |
| public drawTodayChart(symbol = 'β', base = 100): void { | |
| this.renderAreaChart(symbol, base); | |
| } | |
| // Render an SVG area chart into the chart container. If `series` is provided it is used | |
| // as the plotted points (array of numbers). Otherwise a simulated series is generated. | |
| private renderAreaChart(symbol: string, base = 100, series?: number[]): void { | |
| const container = this.chartContainerRef?.nativeElement; | |
| if (!container) return; | |
| // ensure chartSymbol reflects what we're drawing | |
| this.chartSymbol = symbol; | |
| // clear | |
| container.innerHTML = ''; | |
| // use device pixel width for responsiveness | |
| const rect = container.getBoundingClientRect(); | |
| const width = Math.max(600, Math.round(rect.width || 700)); | |
| const height = 260; | |
| const pts = series && series.length ? series : this.makeIntraday(base, 90); | |
| const padLeft = 40; | |
| const padRight = 40; | |
| const padTop = 16; | |
| const padBottom = 36; | |
| const min = Math.min(...pts); | |
| const max = Math.max(...pts); | |
| const toX = (i: number) => padLeft + (i * (width - padLeft - padRight)) / (pts.length - 1 || 1); | |
| const toY = (v: number) => padTop + ((max - v) / Math.max(1e-6, max - min)) * (height - padTop - padBottom); | |
| // SVG root | |
| const ns = 'http://www.w3.org/2000/svg'; | |
| const svg = document.createElementNS(ns, 'svg'); | |
| svg.setAttribute('width', String(width)); | |
| svg.setAttribute('height', String(height)); | |
| svg.setAttribute('viewBox', `0 0 ${width} ${height}`); | |
| svg.style.width = '100%'; | |
| svg.style.height = '260px'; | |
| svg.style.display = 'block'; | |
| // grid lines | |
| for (let g = 0; g < 4; g++) { | |
| const y = padTop + (g * (height - padTop - padBottom)) / 3; | |
| const line = document.createElementNS(ns, 'line'); | |
| line.setAttribute('x1', String(padLeft)); | |
| line.setAttribute('y1', String(y)); | |
| line.setAttribute('x2', String(width - padRight)); | |
| line.setAttribute('y2', String(y)); | |
| line.setAttribute('stroke', '#e6ecff'); | |
| line.setAttribute('stroke-opacity', '0.06'); | |
| line.setAttribute('stroke-width', '1'); | |
| svg.appendChild(line); | |
| } | |
| // build path string | |
| let d = ''; | |
| let area = ''; | |
| pts.forEach((v, i) => { | |
| const x = toX(i); | |
| const y = toY(v); | |
| if (i === 0) { | |
| d += `M ${x} ${y}`; | |
| area += `M ${x} ${y}`; | |
| } else { | |
| d += ` L ${x} ${y}`; | |
| area += ` L ${x} ${y}`; | |
| } | |
| }); | |
| // close area to baseline | |
| const lastX = toX(pts.length - 1); | |
| area += ` L ${lastX} ${height - padBottom} L ${padLeft} ${height - padBottom} Z`; | |
| // area fill | |
| const areaPath = document.createElementNS(ns, 'path'); | |
| areaPath.setAttribute('d', area); | |
| areaPath.setAttribute('fill', '#0f9d58'); | |
| areaPath.setAttribute('fill-opacity', '0.08'); | |
| svg.appendChild(areaPath); | |
| // line path | |
| const path = document.createElementNS(ns, 'path'); | |
| path.setAttribute('d', d); | |
| path.setAttribute('fill', 'none'); | |
| path.setAttribute('stroke', '#0f9d58'); | |
| path.setAttribute('stroke-width', '2'); | |
| path.setAttribute('stroke-linejoin', 'round'); | |
| path.setAttribute('stroke-linecap', 'round'); | |
| svg.appendChild(path); | |
| // last point marker | |
| const lastYVal = pts[pts.length - 1]; | |
| const lastCx = toX(pts.length - 1); | |
| const lastCy = toY(lastYVal); | |
| const circ = document.createElementNS(ns, 'circle'); | |
| circ.setAttribute('cx', String(lastCx)); | |
| circ.setAttribute('cy', String(lastCy)); | |
| circ.setAttribute('r', '4'); | |
| circ.setAttribute('fill', '#0f9d58'); | |
| svg.appendChild(circ); | |
| // y-axis labels (right side) | |
| const labelRight = (val: number, y: number) => { | |
| const txt = document.createElementNS(ns, 'text'); | |
| txt.setAttribute('x', String(width - padRight + 8)); | |
| txt.setAttribute('y', String(y + 4)); | |
| txt.setAttribute('fill', '#fff'); | |
| txt.setAttribute('font-size', '12'); | |
| txt.setAttribute('opacity', '0.9'); | |
| txt.textContent = val.toFixed(2); | |
| svg.appendChild(txt); | |
| }; | |
| labelRight(max, toY(max)); | |
| labelRight(min, toY(min)); | |
| // x-axis labels (bottom) | |
| const ticks = [0, Math.floor(pts.length / 4), Math.floor(pts.length / 2), Math.floor((3 * pts.length) / 4), pts.length - 1]; | |
| ticks.forEach((ti) => { | |
| const x = toX(ti); | |
| const txt = document.createElementNS(ns, 'text'); | |
| txt.setAttribute('x', String(x)); | |
| txt.setAttribute('y', String(height - 8)); | |
| txt.setAttribute('fill', '#999'); | |
| txt.setAttribute('font-size', '11'); | |
| txt.setAttribute('text-anchor', 'middle'); | |
| // fake times: show HH:MM using index | |
| const minutes = (ti * 5) % 60; | |
| const hour = 9 + Math.floor(ti / 12); | |
| txt.textContent = `${hour}:${String(minutes).padStart(2, '0')} AM`; | |
| svg.appendChild(txt); | |
| }); | |
| container.appendChild(svg); | |
| // expose small tooltip on hover (basic) | |
| svg.addEventListener('mousemove', (ev) => { | |
| const rect = svg.getBoundingClientRect(); | |
| const x = ev.clientX - rect.left; | |
| // find closest index | |
| let closest = 0; | |
| let bestDist = Infinity; | |
| for (let i = 0; i < pts.length; i++) { | |
| const dx = Math.abs(toX(i) - x); | |
| if (dx < bestDist) { bestDist = dx; closest = i; } | |
| } | |
| // simple tooltip: set title on container | |
| const v = pts[closest]; | |
| svg.setAttribute('title', `Index: ${symbol}\nValue: ${v.toFixed(2)}`); | |
| }); | |
| } | |
| // Fetch intraday series for symbol and draw chart. Falls back to simulated when live data present | |
| private async fetchAndDrawChart(sym: string, base = 100): Promise<void> { | |
| try { | |
| // try backend intraday endpoint (via MarketService.getIntraday) | |
| const obs = this.market.getIntraday(sym, '1d', '1m'); | |
| const resp: any = await lastValueFrom(obs); | |
| // expected resp: { symbol, timestamps: string[], closes: number[] } | |
| if (resp && Array.isArray(resp.closes) && resp.closes.length > 0) { | |
| this.chartSymbol = sym; | |
| this.drawSeriesChart(resp.closes, sym); | |
| return; | |
| } | |
| } catch (e) { | |
| // ignore and fallback | |
| } | |
| // fallback: simulate intraday | |
| this.chartSymbol = sym; | |
| this.renderAreaChart(sym, base); | |
| } | |
| // Draw given numeric series on canvas (replaces simulated draw when live data present) | |
| private drawSeriesChart(values: number[], symbol = 'β'): void { | |
| this.chartSymbol = symbol; | |
| const canvas = this.canvasRef?.nativeElement; | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return; | |
| const w = canvas.width; | |
| const h = canvas.height; | |
| ctx.clearRect(0, 0, w, h); | |
| const min = Math.min(...values); | |
| const max = Math.max(...values); | |
| const padX = 28; | |
| const padY = 16; | |
| const toX = (i: number) => padX + (i * (w - padX * 2)) / (values.length - 1 || 1); | |
| const toY = (v: number) => h - padY - ((v - min) / Math.max(1e-6, max - min)) * (h - padY * 2); | |
| // grid | |
| ctx.globalAlpha = 0.4; | |
| ctx.strokeStyle = 'rgba(255,255,255,0.08)'; | |
| ctx.lineWidth = 1; | |
| for (let g = 0; g < 4; g++) { | |
| const gy = padY + (g * (h - padY * 2)) / 3; | |
| ctx.beginPath(); | |
| ctx.moveTo(padX, gy); | |
| ctx.lineTo(w - padX, gy); | |
| ctx.stroke(); | |
| } | |
| ctx.globalAlpha = 1; | |
| const up = values[values.length - 1] >= values[0]; | |
| const styles = getComputedStyle(this.host.nativeElement); | |
| const stroke = styles.getPropertyValue(up ? '--up' : '--down').trim() || (up ? '#12c48b' : '#ff5b6b'); | |
| ctx.strokeStyle = stroke; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| values.forEach((v, i) => { | |
| const x = toX(i); | |
| const y = toY(v); | |
| ctx.lineTo(x, y); | |
| }); | |
| ctx.stroke(); | |
| // last price marker | |
| const lastX = toX(values.length - 1); | |
| const lastY = toY(values[values.length - 1]); | |
| ctx.fillStyle = stroke; | |
| ctx.beginPath(); | |
| ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| public selectCountry(country: string): void { | |
| this.selectedCountry = country; | |
| // always apply selection immediately so UI updates synchronously | |
| this.applySelection(country); | |
| // if we haven't fetched live data yet, fetch (fetchGlobalIndices will re-apply selection once data arrives) | |
| if (!this.globalFetched) this.fetchGlobalIndices(); | |
| // Ensure activeIndex points to a valid index for the newly selected companies list | |
| const keys = Object.keys(this.companiesByIndex || {}); | |
| if (keys.length) { | |
| if (!keys.includes(this.activeIndex)) { | |
| this.activeIndex = keys[0]; | |
| } | |
| const first = this.companiesByIndex[this.activeIndex]?.[0]; | |
| if (first) { | |
| this.selectedCompany = first.sym; | |
| this.chartSymbol = first.sym; | |
| // draw chart for the first company (prefer live intraday) | |
| this.fetchAndDrawChart(first.sym, first.ltp).catch(() => { | |
| this.renderAreaChart(first.sym, first.ltp); | |
| }); | |
| return; | |
| } | |
| } | |
| // If no company available for activeIndex, try to keep current selectedCompany if present | |
| if (this.selectedCompany) { | |
| const comp = this.findCompanyBySymbol(this.selectedCompany); | |
| const base = comp?.ltp ?? 100; | |
| this.chartSymbol = this.selectedCompany; | |
| this.fetchAndDrawChart(this.selectedCompany, base).catch(() => { | |
| this.renderAreaChart(this.selectedCompany as string, base); | |
| }); | |
| } | |
| } | |
| // expose as a public property (arrow) so Angular template type-checker recognizes it | |
| // (removed duplicate - use the method defined later in file) | |
| // Handle company row / button clicks from template | |
| public onCompanyClick(e: Event, c: Company): void { | |
| if (e && typeof e.preventDefault === 'function') e.preventDefault(); | |
| if (!c) return; | |
| this.selectedCompany = c.sym; | |
| // try to fetch live intraday series and draw; fallback to simulated when not available | |
| this.fetchAndDrawChart(c.sym, c.ltp).catch(() => { | |
| this.renderAreaChart(c.sym, c.ltp); | |
| }); | |
| } | |
| } | |